mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 02:01:27 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f102d78804 | ||
|
|
7719ddd9a8 | ||
|
|
a04c445210 | ||
|
|
57d70f807b | ||
|
|
142fd2a345 | ||
|
|
bf73601f9c | ||
|
|
dd990dd401 | ||
|
|
5e0bb582f0 |
No files matched your search
Generated
+1
-1
@@ -347,7 +347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.4.3-preview.6"
|
||||
version = "0.4.4-preview.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.4.3-preview.6"
|
||||
version = "0.4.4-preview.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
For a Preview release, specify an actual published tag:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.3-preview.6
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.4-preview.1
|
||||
```
|
||||
|
||||
Replace the example tag with the published version you want to install. Enter the sudo password when prompted. See the [installation guide](docs/user-guide/installation.md) for updates, repair, and uninstallation.
|
||||
@@ -49,6 +49,7 @@ Start at the [documentation index](docs/README.md):
|
||||
|
||||
- [Install, update, repair, roll back, and uninstall](docs/user-guide/installation.md)
|
||||
- [Basic usage](docs/user-guide/basic-usage.md) · [Install and manage plugins](docs/user-guide/plugins.md)
|
||||
- [APK Manager](docs/user-guide/apk-manager.md) · [Space launcher](docs/launcher.md)
|
||||
- [Network management panel](docs/user-guide/network-panel.md)
|
||||
- [Plugin development through publishing](docs/developer-guide/README.md) · [Publish plugins and sources](docs/developer-guide/publishing.md)
|
||||
- [SDK API](docs/developer-guide/sdk.md) · [Manifest configuration](docs/developer-guide/manifest.md) · [Lifecycle](docs/plugin-lifecycle.md)
|
||||
|
||||
+2
-1
@@ -34,7 +34,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
安装 Preview 时指定实际已发布的标签:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.3-preview.6
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.4-preview.1
|
||||
```
|
||||
|
||||
将示例标签替换为需要安装的已发布版本,并在提示时输入 sudo 密码。更新、修复和卸载步骤见[安装指南](docs/zh-CN/user-guide/installation.md)。
|
||||
@@ -49,6 +49,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
|
||||
- [安装、更新、修复、回滚与卸载](docs/zh-CN/user-guide/installation.md)
|
||||
- [基本使用](docs/zh-CN/user-guide/basic-usage.md) · [插件安装与管理](docs/zh-CN/user-guide/plugins.md)
|
||||
- [APK 管理](docs/zh-CN/user-guide/apk-manager.md) · [空间启动台](docs/zh-CN/launcher.md)
|
||||
- [网络管理面板](docs/zh-CN/user-guide/network-panel.md)
|
||||
- [插件开发到发布完整流程](docs/zh-CN/developer-guide/README.md) · [发布插件和插件源](docs/zh-CN/developer-guide/publishing.md)
|
||||
- [SDK API](docs/zh-CN/developer-guide/sdk.md) · [Manifest 配置](docs/zh-CN/developer-guide/manifest.md) · [生命周期](docs/zh-CN/plugin-lifecycle.md)
|
||||
|
||||
@@ -10,6 +10,7 @@ Framely manages plugins on Steam Frame through device services, a SteamVR interf
|
||||
2. [Basic usage](user-guide/basic-usage.md): Dock, manager, enabling plugins, favorites and safe mode.
|
||||
3. [Install and manage plugins](user-guide/plugins.md): plugin library, sources, subscriptions, local packages and updates.
|
||||
4. [Network panel](user-guide/network-panel.md): address, first password setup, login and phone/desktop access.
|
||||
5. [APK Manager](user-guide/apk-manager.md): sideload installation, updates, containers, retained data and cleanup.
|
||||
|
||||
## Plugin development and publishing
|
||||
|
||||
|
||||
@@ -153,4 +153,4 @@ Standalone plugin windows display the plugin page without a host title bar. Plug
|
||||
|
||||
### Host page scrolling
|
||||
|
||||
The host bootstrap installs vertical trigger-drag scrolling for existing plugin pages as well as newly built SDK pages. It selects the scrollable ancestors under the pointer and chains at their boundaries. Controls such as inputs, textareas, editable content and sliders are excluded. Use `data-framely-no-scroll-drag` on an element (or ancestor) whose vertical dragging is handled by the plugin; native `draggable="true"` elements are also excluded. No new manifest field or plugin rebuild is required.
|
||||
The native CEF host installs vertical trigger-drag scrolling whenever a frame finishes loading, including existing plugin pages, nested frames and `localWeb` windows without the SDK. The shared document guard prevents duplicate installation by the host bootstrap or SDK. Laser-targeted stick scrolling also works in these windows through native wheel input. It selects the scrollable ancestors under the pointer and chains at their boundaries. Controls such as inputs, textareas, editable content and sliders are excluded. Use `data-framely-no-scroll-drag` on an element (or ancestor) whose vertical dragging is handled by the plugin; native `draggable="true"` elements are also excluded. No new manifest field or plugin rebuild is required.
|
||||
@@ -0,0 +1,80 @@
|
||||
# APK Manager
|
||||
|
||||
[简体中文](../zh-CN/user-guide/apk-manager.md)
|
||||
|
||||
[Documentation](../README.md) · [Launcher](../launcher.md)
|
||||
|
||||
APK Manager installs and manages sideloaded Lepton applications on Steam Frame. Use the header switch to move between Plugin Manager and APK Manager in the headset or authenticated network panel. Visiting the device IP opens Plugin Manager by default. APK Manager has its own Apps, Containers and Data cleanup navigation. It is a file-based manager: it has no store, recommendations, download sources, or automatic APK updates.
|
||||
|
||||
## Requirements and discovery
|
||||
|
||||
Install Lepton through Steam first. Framely uses the Steam session user's Lepton and Podman; it does not install Android globally or operate as root. Lepton compatibility varies: Google services, vendor APIs and DRM may be unavailable.
|
||||
|
||||
Open **APK Manager → Refresh**. Discovery reads the standard Lepton context directories, saved Framely records and running containers' compatibility-data labels. It does not start containers during scanning. Steam-managed APKs appear in the container view; use Steam for their updates and removal.
|
||||
|
||||
For data stored elsewhere, open **Containers → Additional data locations** and add the `baked` directory containing `data_overlay/system/packages.xml`. Enter the original Lepton context name when it differs from the enclosing directory. Names cannot conflict with containers belonging to other data locations. Paths must belong to the session user. Removing a scan location does not delete its data. Custom tools can use layouts Framely cannot discover; missing locations must be registered explicitly.
|
||||
|
||||
Applications are identified by context plus package name. The same package in two containers is treated as two applications. A context name or location change can therefore create a different identity.
|
||||
|
||||
## Install and update
|
||||
|
||||
Choose **Install APK**, select an individual `.apk`, and review its name, package, version, minimum Android SDK and target container. Uploads from the network panel are staged on Frame's persistent filesystem, not its limited `/tmp`. Transfer shows actual bytes; subsequent work shows named stages instead of invented percentages.
|
||||
|
||||
Inside Frame, file inputs in both management panels use Framely’s built-in file picker, including APKs, plugin packages and language files. It opens Downloads by default, remembers the last selected folder, and provides a lazily loaded folder tree for Home, temporary files and mounted storage, with folder navigation, path entry, search, hidden files and file-type filtering. Select a file and confirm; B/Escape cancels the picker without closing the panel. External browsers continue using their system file picker. The launcher’s **Install APK** shortcut opens APK Manager.
|
||||
|
||||
New apps default to independent containers. Existing containers are offered only when they can accept the target package without overwriting another app's shared APK mount. Lepton recommends one application per container because Android apps do not have the usual enforced isolation inside its containers.
|
||||
|
||||
After installation, choose **Open** if desired; installation does not automatically launch the APK. An app with no detected launch entry remains manageable but does not appear in the launcher.
|
||||
|
||||
To update, open the app's management dialog and choose **Install newer APK**. Package identity and version are checked; Android enforces signing compatibility during replacement. Downgrades are refused, and signature failures never trigger an uninstall/reinstall workaround. Same-package replacement retains data. Updating may stop the target container, affecting its other applications.
|
||||
|
||||
Before replacing an existing installation, Framely saves a container-data snapshot while it is stopped. Scratch overlay work directories, runtime sockets and FIFOs are excluded. Backups include a SHA-256 integrity inventory checked before restoration. Backup failure aborts replacement. Snapshots are recovery material, not a guarantee that every Android data migration is reversible. One-click restore supports sideloaded containers with a single recorded application and a matching data location, including external containers; shared snapshots require manual recovery. Restoration creates another snapshot of the current state and requires launching the app to verify saved data afterward.
|
||||
|
||||
Once a modifying operation starts, cancellation is disabled until its actual outcome is known. Uploads and preparation can be cancelled. Switching panels does not stop a submitted task; reopening APK Manager resumes its progress display. Interrupted restores are recovered from a local transaction journal before the next modifying operation. After interruption, use **Reconcile installed state** before retrying or deleting data. An unreadable container displays Installation state unknown, preserves the previous record and disables launching, reinstallation and deletion until its actual state is reconciled.
|
||||
|
||||
## Launch and window display
|
||||
|
||||
The launcher uses declared MAIN/LAUNCHER, television launcher and recognized VR entries, plus the running Android package manager's resolved launcher entry. Disabled packages/components are excluded. A launch entry does not guarantee that an app will run successfully.
|
||||
|
||||
Window handling is automatic by default. **Advanced settings → Show Android window** is a troubleshooting override, applied on container restart. It does not convert a flat application into VR or a VR application into a flat application. Lepton development contexts may mount their APK at a different path after a restart. Framely re-registers the same signed APK when Android can no longer resolve it, preserving data and reporting failures. Advanced settings also allow selecting an enabled, declared Activity when a nonstandard app needs a different entry.
|
||||
|
||||
Launcher favorites, search and manual ordering continue to use stable context/package identities. Updating and refreshing do not reorder icons. Uninstalling hides an icon without deleting its favorite/order record; reinstalling in the same context restores it. Long-press a local Lepton icon to open APK management or removal confirmation.
|
||||
|
||||
## Close, uninstall and clean data
|
||||
|
||||
**Close app** force-stops only the selected package. **Stop container** affects every app inside it. All non-Steam sideloaded containers with a verified data location support deletion after confirmation of affected apps and permanent data loss. Standard Lepton context directories are removed as a whole; registered external locations remove only the baked directory, preserving unrelated files in the parent. Uninstalling an app, even with Delete app data enabled, does not delete its container.
|
||||
|
||||
Uninstallation retains saved data by default. Enable **Delete app data** to remove it. Retained apps appear under **Uninstalled, data retained**, where you can reinstall or remove retained data.
|
||||
|
||||
Android may require the original signed APK to remove an already uninstalled package's retained state. Framely keeps the original APK when it manages an uninstall. If an externally uninstalled app has no recoverable APK, supply its original APK through reinstallation before removing its retained state. Framely does not bypass signing checks or delete arbitrary directories to simulate success.
|
||||
|
||||
**Data cleanup** lists retained data, old APKs, recovery snapshots and expired temporary files. Saved data and backups are not selected by default. Unidentified data is displayed without an automatic deletion action. Each selected item is checked again before removal; a reinstalled application cannot be cleaned as an uninstalled one. Batch results report actual released file bytes and individual failures. File-byte counts may differ from physical disk usage due to compression, reflinks and shared storage.
|
||||
|
||||
## Logs and recovery without the panel
|
||||
|
||||
Use the app's **Logs → Export logs** to save its operation log. Detailed Android errors remain in logs. Review logs before sharing them.
|
||||
|
||||
As the Steam session user, the installed binary also provides these commands:
|
||||
|
||||
```bash
|
||||
/home/.framely/current/bin/framely apk list
|
||||
/home/.framely/current/bin/framely apk inspect --file /path/to/app.apk
|
||||
/home/.framely/current/bin/framely apk install '{"ticket":"REVIEW_TICKET","approve":true}'
|
||||
/home/.framely/current/bin/framely apk launch '{"app":"CONTEXT/PACKAGE"}'
|
||||
/home/.framely/current/bin/framely apk logs '{"app":"CONTEXT/PACKAGE"}'
|
||||
/home/.framely/current/bin/framely apk cleanup-list
|
||||
```
|
||||
|
||||
Reviews expire after 15 minutes. APKs, operation records and recovery material are stored in `~/.local/share/framely/apk-manager`; managed Lepton data lives under `~/.local/share/lepton/contexts`. Framely uninstallation retains these user-owned directories. Back them up before removing Framely; the APK CLI requires a compatible Framely binary, while Lepton remains independently installed through Steam.
|
||||
|
||||
For a Framely-managed context, a manual Lepton launch must preserve data:
|
||||
|
||||
```bash
|
||||
LEPTON_NO_CLEANUP=true ~/.local/share/Steam/steamapps/common/Lepton/lepton start CONTEXT
|
||||
```
|
||||
|
||||
Do not invoke Lepton's global cleanup commands as an application-specific uninstall.
|
||||
|
||||
## Supported formats
|
||||
|
||||
The initial implementation supports individual APKs. Split APK bundles, XAPK/APKM, OBB import, Google Play installation and automatic Steam shortcut creation are outside this version. Unsupported bundles are rejected rather than treated as ordinary APKs.
|
||||
@@ -62,3 +62,6 @@ The output must be a new file. If the binary cannot run, an extracted release al
|
||||
Point the laser at a page and move either controller's stick to scroll, without clicking or focusing the page first. Scrolling follows the element under the laser, including nested plugin panels. Keyboard input pauses page scrolling. Launcher stick navigation keeps its category/page controls.
|
||||
|
||||
Hold the primary trigger and move the laser up or down to drag scrollable page content. Small movements remain ordinary clicks; once a vertical drag starts, releasing does not activate the pressed button. Text inputs, sliders and explicit custom drag targets keep their own interactions. The launcher retains its icon arrangement and horizontal page-drag gestures.
|
||||
|
||||
|
||||
Launcher blank-space clicks only dismiss the icon menu. Click outside the launcher window or press B to dismiss it (B exits arrangement first). Steam’s move/depth controls keep the window open and pause stick navigation until the stick returns to neutral. While dragging an icon in arrangement mode, horizontal stick input changes pages; vertical input does not change categories. The boundary rails remain an alternative: hold the pointer beyond a rail for 650 ms to move to the adjacent page.
|
||||
@@ -12,6 +12,7 @@ Framely 是 Steam Frame 的插件管理器,包含设备端服务、SteamVR 界
|
||||
2. [基本使用](user-guide/basic-usage.md):Dock 入口、管理窗口、启停、收藏、安全模式。
|
||||
3. [安装与管理插件](user-guide/plugins.md):插件库、来源、订阅、本地包和更新。
|
||||
4. [网络管理面板](user-guide/network-panel.md):地址、首次密码、登录、手机/电脑使用。
|
||||
5. [APK 管理](user-guide/apk-manager.md):侧载安装、更新、容器、保留数据与清理。
|
||||
|
||||
## 插件开发与发布
|
||||
|
||||
|
||||
@@ -160,4 +160,4 @@ const off = framely.ui.onVisibilityChanged(state => {
|
||||
|
||||
### 本体页面滚动
|
||||
|
||||
本体桥接脚本会为现有插件页面和新版 SDK 页面安装主扳机纵向拖动滚动。它选择指针下可滚动的祖先区域,并在边界继续滚动外层。输入框、文本域、可编辑内容和滑块保留自身交互。若插件需要自行处理纵向拖拽,请在对应元素或其祖先上设置 `data-framely-no-scroll-drag`;原生 `draggable="true"` 元素也会排除。无需新增清单字段或重新打包插件。
|
||||
原生 CEF 宿主在每个页面加载完成时安装主扳机纵向拖动滚动,覆盖现有插件页面、嵌套页面,以及不使用 SDK 的 `localWeb` 窗口。共用的页面标记防止本体桥接或 SDK 重复安装。激光指向的摇杆滚动也通过原生滚轮输入支持这些窗口。它选择指针下可滚动的祖先区域,并在边界继续滚动外层。输入框、文本域、可编辑内容和滑块保留自身交互。若插件需要自行处理纵向拖拽,请在对应元素或其祖先上设置 `data-framely-no-scroll-drag`;原生 `draggable="true"` 元素也会排除。无需新增清单字段或重新打包插件。
|
||||
@@ -0,0 +1,84 @@
|
||||
# APK 管理
|
||||
|
||||
[English](../../user-guide/apk-manager.md)
|
||||
|
||||
[文档首页](../README.md) · [启动台](../launcher.md)
|
||||
|
||||
APK 管理用于安装和管理 Steam Frame 上的 Lepton 侧载应用,本地管理面板与登录后的网络面板均可使用。它只管理用户提供的文件,不提供商店、推荐、下载源或自动更新。
|
||||
|
||||
通过顶部切换器可在插件管理与 APK 管理面板之间切换。直接访问设备 IP 默认进入插件管理;APK 管理有独立的应用、容器和数据清理导航。
|
||||
|
||||
## 环境与发现
|
||||
|
||||
先通过 Steam 安装 Lepton。Framely 使用 Steam 会话用户的 Lepton 与 Podman,不以 root 操作,也不全局安装 Android。应用兼容性取决于 Lepton;Google 服务、设备专用接口和 DRM 可能不可用。
|
||||
|
||||
打开“APK 管理 → 刷新”,扫描标准 Lepton 容器目录、Framely 已保存记录及运行中容器的兼容数据标签。扫描不会启动容器。Steam 管理的 APK 在容器视图中标识,更新和卸载仍通过 Steam。
|
||||
|
||||
其他工具使用自定义位置时,在“容器 → 其他数据位置”添加包含 `data_overlay/system/packages.xml` 的 `baked` 目录。如果原容器名称与上级目录不同,还需填写原容器名称;名称不能与其他数据目录对应的容器冲突。目录必须属于会话用户;移除扫描位置不会删除数据。无法自动发现的自定义布局需手动登记。
|
||||
|
||||
应用按“容器+包名”识别,同包在不同容器里分别管理。改变容器名称或数据位置可能改变应用身份。
|
||||
|
||||
## 安装与更新
|
||||
|
||||
点击“安装 APK”,选择单个 `.apk`,核对名称、包名、版本、最低 Android SDK 和目标容器。网络上传暂存于 Frame 持久化文件系统,避免占满较小的 `/tmp`。上传显示真实字节进度;后续显示处理阶段,不使用假百分比。
|
||||
|
||||
在 Frame 内,两个管理面板的文件选择入口统一使用 Framely 内置文件选择器,包括 APK、插件包和语言文件。默认打开下载目录,记住上次选择的目录;左侧为按需加载的文件夹树,覆盖主目录、临时目录和已挂载存储,支持逐级展开、收起和当前路径定位,提供目录导航、手动输入路径、搜索、隐藏文件和文件类型筛选。选中文件后确认;B/Escape 只取消选择器,不关闭管理面板。外部浏览器继续使用系统文件选择器。启动台右侧的“安装APK”按钮打开 APK 管理面板。
|
||||
|
||||
新应用默认独立容器。现有容器只有在不会覆盖其他应用的共享 APK 挂载层时才可作为目标。Lepton 建议每个应用独立容器,因为容器内没有通常 Android 应用之间的强制隔离。
|
||||
|
||||
安装不会自动打开应用,完成后可以点击“打开”。没有检测到启动入口的 APK 仍可管理,但不会放进启动台。
|
||||
|
||||
更新时进入应用管理,点击“安装新版 APK”。先核对包名与版本,再由 Android 在覆盖安装时执行签名兼容校验。默认拒绝降级;签名失败不会自动卸载重装。同包覆盖安装保留数据。更新可能停止目标容器,影响其中其他应用。
|
||||
|
||||
替换已有安装前,会停止容器并保存数据快照,排除临时 overlay 工作目录、运行时套接字和 FIFO,保留删除标记。恢复前核对 SHA-256 文件清单;校验失败不会替换数据。备份失败则中止替换。快照是恢复材料,不保证所有 Android 数据迁移都可逆。仅包含一个应用、且数据目录与备份一致的侧载容器支持一键恢复,包括外部容器;共享容器需手动恢复。一键恢复先备份当前状态,恢复后需启动应用核对存档。
|
||||
|
||||
修改操作开始后等待实际结果,不能安全中断时不提供取消。上传与准备阶段可取消。异常中断后先“核对实际安装状态”,再重试或清理数据;容器读不到不代表应用已卸载;界面显示“安装状态未知”,保留之前记录,禁用启动、重新安装和删除操作,先核对实际状态。
|
||||
|
||||
## 启动入口与窗口
|
||||
|
||||
后台识别清单中的 MAIN/LAUNCHER、电视启动入口和已知 VR 入口,并结合运行中的 Android 包管理器解析结果。停用的包与组件排除。有入口不代表一定能正常运行。
|
||||
|
||||
默认自动处理窗口显示。“高级设置 → 显示安卓窗口”用于排查,并在重启容器后生效。它不会把二维应用转成 VR,也不会把 VR 应用转成二维。Lepton 开发容器重启后可能改变 APK 挂载路径;Android 无法识别时,Framely 会重新登记同签名 APK,保留数据并报告错误。非标准应用还可从已启用、已声明的 Activity 中指定启动入口。
|
||||
|
||||
启动台继续按稳定的容器与包名保存收藏、搜索和排序。更新或刷新不重排图标;卸载隐藏图标,保留收藏与位置记录,在原容器重新安装后恢复。长按本地 Lepton 图标可进入管理或卸载确认。
|
||||
|
||||
## 关闭、卸载与清理
|
||||
|
||||
“关闭应用”只停止目标包;“停止容器”影响其中全部应用。所有可确认数据位置的非 Steam 侧载容器均支持删除,并需确认受影响应用及永久数据删除。标准 Lepton 目录中的容器会整体删除;手动登记的外部位置只删除 baked 目录,保留父目录中的其他文件。卸载应用、包括选择删除应用数据,都不会删除容器。
|
||||
|
||||
卸载默认保留存档与设置,开启“删除应用数据”才彻底删除。保留数据的应用显示在“已卸载但保留数据”中,可以重新安装或清除保留数据。
|
||||
|
||||
Android 清除已卸载包的保留状态时可能需要原签名 APK。Framely 管理卸载时会保存原 APK。如果其他工具已卸载应用且找不到原 APK,需要先提供原 APK 重新安装,再清除保留数据;不会绕过签名或删除任意目录假装成功。
|
||||
|
||||
“数据清理”列出保留数据、旧 APK、恢复快照及过期临时文件。存档与备份默认不选中;未知归属数据只展示,不自动删除。删除前重新核验,已重新安装的应用不会被当作残留清理。批量结果显示实际释放的文件字节及各项失败原因。由于压缩、reflink 和共享存储,文件字节数与物理磁盘占用可能不同。
|
||||
|
||||
切换面板不会中止已提交的任务;重新打开 APK 管理会继续显示进度。恢复流程使用本地事务记录,意外中断后会在下次修改操作前恢复到完整状态。
|
||||
|
||||
## 日志与面板外恢复
|
||||
|
||||
在应用的“日志 → 导出日志”保存操作记录。Android 详细错误保留在日志中,分享前应检查内容。
|
||||
|
||||
以 Steam 会话用户执行:
|
||||
|
||||
```bash
|
||||
/home/.framely/current/bin/framely apk list
|
||||
/home/.framely/current/bin/framely apk inspect --file /path/to/app.apk
|
||||
/home/.framely/current/bin/framely apk install '{"ticket":"REVIEW_TICKET","approve":true}'
|
||||
/home/.framely/current/bin/framely apk launch '{"app":"CONTEXT/PACKAGE"}'
|
||||
/home/.framely/current/bin/framely apk logs '{"app":"CONTEXT/PACKAGE"}'
|
||||
/home/.framely/current/bin/framely apk cleanup-list
|
||||
```
|
||||
|
||||
校验票据有效期为 15 分钟。APK、管理记录和恢复材料位于 `~/.local/share/framely/apk-manager`,托管 Lepton 数据位于 `~/.local/share/lepton/contexts`。卸载 Framely 保留这些用户目录;卸载前应备份。APK 命令仍需兼容的 Framely 二进制,Lepton 本身由 Steam 独立安装。
|
||||
|
||||
手动启动 Framely 托管容器时,必须保留数据:
|
||||
|
||||
```bash
|
||||
LEPTON_NO_CLEANUP=true ~/.local/share/Steam/steamapps/common/Lepton/lepton start CONTEXT
|
||||
```
|
||||
|
||||
不要将 Lepton 的全局清理命令用作单应用卸载。
|
||||
|
||||
## 格式范围
|
||||
|
||||
首版支持单个 APK。不包含分包、XAPK/APKM、OBB 导入、Google Play 安装及自动创建 Steam 快捷方式;不支持的包格式会明确拒绝。
|
||||
@@ -66,3 +66,6 @@ sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs
|
||||
激光指向页面后,拨动任意手柄的摇杆即可滚动,无需先点击或聚焦页面。滚动作用于激光下的元素,也支持插件页面的内层滚动区域。键盘打开时暂停页面摇杆滚动;启动台仍使用原有的摇杆分类与翻页操作。
|
||||
|
||||
按住主扳机并上下移动激光,可拖动可滚动的页面内容。小幅抖动仍视为普通点击;纵向拖动开始后,松开不会触发原先按下的按钮。文本输入、滑块及明确声明的自定义拖拽区域保留自身交互。启动台保留图标排列和横向拖动翻页操作。
|
||||
|
||||
|
||||
启动台窗口内部的空白点击只收起图标菜单。点击窗口外或按 B 关闭;调整模式下 B 先退出调整。Steam 的移动、推远和拉近控件会保持窗口打开,并暂停摇杆导航,摇杆回中后恢复。调整模式拖拽图标时可以用摇杆左右翻页,上下不会切换分类;也可将指针移过分页线并停留 650 毫秒跨页。
|
||||
+39
-11
@@ -1,3 +1,4 @@
|
||||
#include "include/capi/cef_dialog_handler_capi.h"
|
||||
#include "include/capi/cef_app_capi.h"
|
||||
#include "include/capi/cef_image_capi.h"
|
||||
#include "include/capi/cef_parser_capi.h"
|
||||
@@ -9,8 +10,11 @@
|
||||
#include "menu_geometry.h"
|
||||
#include "menu_input.h"
|
||||
#include "launcher_input.h"
|
||||
#include "launcher_fade.h"
|
||||
#include "launcher_pointer.h"
|
||||
#include "page_scroll.h"
|
||||
#include "page_scroll_injection.h"
|
||||
#include "include/capi/cef_load_handler_capi.h"
|
||||
#include <filesystem>
|
||||
#include "overlay_placement.h"
|
||||
#include "render_resolution.h"
|
||||
@@ -50,6 +54,8 @@ static GLXContext glcontext=nullptr;
|
||||
static GLXPbuffer glsurface=0;
|
||||
static std::atomic<unsigned> notification_badge_count{0};
|
||||
static bool launcher_open=false;
|
||||
static LauncherFade launcher_fade;
|
||||
static std::chrono::steady_clock::time_point launcher_close_at{};
|
||||
static LauncherDismissGuard launcher_dismiss;
|
||||
static LauncherDockGuard launcher_dock;
|
||||
static EntryTrigger launcher_close_button;
|
||||
@@ -89,6 +95,7 @@ struct View {
|
||||
std::string key,plugin,title;int width=600,height=840,render_scale=1;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false;
|
||||
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_at{};
|
||||
cef_mouse_event_t pointer{};bool pointer_valid=false,stick_scrolling=false;vr::TrackedDeviceIndex_t pointer_device=vr::k_unTrackedDeviceIndexInvalid;std::chrono::steady_clock::time_point pointer_at{};
|
||||
cef_file_dialog_callback_t* file_dialog=nullptr;std::string file_dialog_id;bool file_multiple=false,file_directory=false;
|
||||
bool active_seen=false,notification_content=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
|
||||
vr::VROverlayHandle_t overlay=0,thumbnail=0;GLuint textures[2]{};int texture_slot=0;bool texture_ready[2]{};PaintDamage texture_damage[2];std::vector<uint8_t> pixels;
|
||||
};
|
||||
@@ -96,9 +103,13 @@ static std::map<std::string,std::unique_ptr<View>> views;
|
||||
static View* find_browser(cef_browser_t* b){int id=b->get_identifier(b);for(auto& [key,v]:views)if(v->browser_id==id)return v.get();return nullptr;}
|
||||
static Handler<cef_client_t> client;
|
||||
static Handler<cef_render_handler_t> render_handler;
|
||||
static Handler<cef_dialog_handler_t> dialog_handler;
|
||||
static unsigned long long file_dialog_sequence=0;
|
||||
static void cancel_file_dialog(View& v){if(!v.file_dialog)return;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();callback->cancel(callback);callback->base.release(&callback->base);}
|
||||
static Handler<cef_life_span_handler_t> life_handler;
|
||||
static Handler<cef_display_handler_t> display_handler;
|
||||
static Handler<cef_request_handler_t> request_handler;
|
||||
static Handler<cef_load_handler_t> load_handler;
|
||||
static void str(cef_string_t& o,const std::string& s){cef_string_utf8_to_utf16(s.data(),s.size(),&o);}
|
||||
static std::string text(const cef_string_t* s){cef_string_utf8_t o{};cef_string_utf16_to_utf8(s->str,s->length,&o);std::string r(o.str,o.length);cef_string_utf8_clear(&o);return r;}
|
||||
static void execute(cef_frame_t* f,const std::string& code){if(!f||!f->is_valid(f))return;cef_string_t script{},url{};str(script,code);f->execute_java_script(f,&script,&url,1);cef_string_utf16_clear(&script);}
|
||||
@@ -194,7 +205,7 @@ static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_o
|
||||
}else {keyboard_done=keyboard_done||e.eventType==vr::VREvent_KeyboardDone;keyboard_close_pending=true;}
|
||||
return true;
|
||||
}
|
||||
static void close_view(View& v){if(v.closing)return;if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
|
||||
static void close_view(View& v){if(v.closing)return;cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
|
||||
static void anchor_to_head(View& v,float width,float x=0,float y=0,float z=-1.35f){vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);if(!poses[0].bPoseIsValid)return;auto m=poses[0].mDeviceToAbsoluteTracking;for(int r=0;r<3;r++)m.m[r][3]+=m.m[r][0]*x+m.m[r][1]*y+m.m[r][2]*z;overlays->SetOverlayWidthInMeters(v.overlay,width);overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&m);}
|
||||
static void anchor_notification(View& v){
|
||||
// Let the compositor follow the HMD's render pose; a captured standing-space
|
||||
@@ -260,9 +271,12 @@ static bool launcher_dock_interaction(){
|
||||
}
|
||||
static void hide_launcher(){
|
||||
if(keyboard_view=="launcher")close_keyboard();
|
||||
launcher_open=false;launcher_selected=false;launcher_dock.clear();
|
||||
if(views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay))
|
||||
overlays->ShowDashboard("valve.steam.gamepadui.main");
|
||||
const bool was_open=launcher_open;launcher_open=false;launcher_selected=false;launcher_dock.clear();launcher_stick={};
|
||||
if(was_open&&views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay)){
|
||||
auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);
|
||||
execute(frame,"window.dispatchEvent(new Event('framely.launcher.closing'))");frame->base.release(&frame->base);
|
||||
auto now=std::chrono::steady_clock::now();launcher_fade.close(now);launcher_close_at=now+std::chrono::milliseconds(launcher_fade.reduced?0:480);
|
||||
}
|
||||
}
|
||||
static void launcher_back(){
|
||||
auto now=std::chrono::steady_clock::now();if(now<launcher_keyboard_back_until)return;
|
||||
@@ -281,8 +295,12 @@ static void toggle_entry(bool launcher){
|
||||
if(launcher_open){hide_launcher();return;}
|
||||
auto& view=create_view("launcher","Framely launcher",origin+"/launcher",true,1440,800);
|
||||
vr::VREvent_t stale{};while(overlays->PollNextOverlayEvent(view.overlay,&stale,sizeof(stale))){}
|
||||
launcher_dock.clear();launcher_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={};
|
||||
launcher_close_at={};launcher_dock.clear();launcher_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={};
|
||||
// Steam must see a visible dashboard surface before activating it. Zero
|
||||
// alpha can prevent activation, so never wait for selection to start a fade.
|
||||
overlays->SetOverlayAlpha(view.overlay,1.f);
|
||||
overlays->ShowDashboard("launcher");
|
||||
launcher_fade.open(std::chrono::steady_clock::now());
|
||||
auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.open'))");frame->base.release(&frame->base);
|
||||
}else{hide_launcher();menu_open=!menu_open;if(menu_open)menu_button.opened(std::chrono::steady_clock::now());}
|
||||
}
|
||||
@@ -363,14 +381,14 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
// Pick a single controller and a single axis; never emit two directions.
|
||||
if(active&&(!stick_active||std::max(std::abs(x),std::abs(y))>std::max(std::abs(stick_x),std::abs(stick_y)))){stick_active=true;stick_x=x;stick_y=y;stick_device=device;}
|
||||
}
|
||||
if(launcher_open){int direction=launcher_stick.update(keyboard_open?0:stick_x,keyboard_open?0:stick_y,!keyboard_open&&stick_active);if(direction&&views.count("launcher")){auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:"+std::to_string(direction)+"}))");frame->base.release(&frame->base);}}
|
||||
if(launcher_open){const bool blocked=keyboard_open||launcher_dock_interaction();int direction=launcher_stick.update(blocked?0:stick_x,blocked?0:stick_y,!blocked&&stick_active);if(direction&&views.count("launcher")){auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:"+std::to_string(direction)+"}))");frame->base.release(&frame->base);}}
|
||||
else{launcher_stick={};launcher_close_button={};}
|
||||
page_scroll(stick_x,stick_y,stick_active,stick_device,now);
|
||||
if(launcher_open&&launcher_outside_at.time_since_epoch().count()&&now>=launcher_outside_at){launcher_outside_at={};if(!keyboard_open&&!launcher_dock_interaction())launcher_back();}
|
||||
}
|
||||
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="launcher.close"){hide_launcher();}else if(kind=="menu.open"){hide_launcher();auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){hide_launcher();if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely 插件管理",origin+"/manager"+((page=="catalog"||page=="settings"||page=="updates"||page=="notification-settings"||page=="launcher-settings"||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(page=="catalog"||page=="settings"||page=="updates"||page=="notification-settings"||page=="launcher-settings"){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){hide_launcher();std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");keyboard_state(keyboard_open);if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "<<e.what()<<"\n";}}
|
||||
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="launcher.close"){hide_launcher();}else if(kind=="menu.open"){hide_launcher();auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){hide_launcher();if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely 插件管理",origin+"/manager"+((page=="catalog"||page=="settings"||page=="updates"||page=="notification-settings"||page=="launcher-settings"||page=="apk"||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(page=="catalog"||page=="settings"||page=="updates"||page=="notification-settings"||page=="launcher-settings"||page=="apk"){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){hide_launcher();std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");keyboard_state(keyboard_open);if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "<<e.what()<<"\n";}}
|
||||
}
|
||||
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!launcher_dock_interaction())launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")close_view(v);break;default:break;}}
|
||||
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")close_view(v);break;default:break;}}
|
||||
|
||||
int main(int argc,char** argv){
|
||||
std::cout.setf(std::ios::unitbuf);std::string initial;int port=0;for(int i=1;i+1<argc;i++){std::string s=argv[i];if(s=="--url")initial=argv[++i];else if(s=="--port")port=std::stoi(argv[++i]);else if(s=="--runtime")runtime=argv[++i];}
|
||||
@@ -391,11 +409,19 @@ int main(int argc,char** argv){
|
||||
render_handler.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t* b,cef_screen_info_t* info){auto* v=find_browser(b);return render_screen_info(info,v?v->width:600,v?v->height:840,v?v->render_scale:1);};
|
||||
render_handler.api.on_paint=[](cef_render_handler_t*,cef_browser_t* b,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){auto* v=find_browser(b);if(!v||v->closing||type!=PET_VIEW||w!=v->width*v->render_scale||h!=v->height*v->render_scale)return;merge_paint(v->pixels,data,w,h,dirty_count,dirty,true);for(auto& damage:v->texture_damage)damage.add(w,h,dirty_count,dirty);v->dirty=true;};
|
||||
life_handler.api.on_before_popup=[](cef_life_span_handler_t*,cef_browser_t*,cef_frame_t*,int,const cef_string_t*,const cef_string_t*,cef_window_open_disposition_t,int,const cef_popup_features_t*,cef_window_info_t*,cef_client_t**,cef_browser_settings_t*,cef_dictionary_value_t**,int*){return 1;};
|
||||
life_handler.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t* b){auto* v=find_browser(b);if(v){v->closed=true;v->host->base.release(&v->host->base);v->host=nullptr;v->browser->base.release(&v->browser->base);v->browser=nullptr;}};
|
||||
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
|
||||
life_handler.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t* b){auto* v=find_browser(b);if(v){cancel_file_dialog(*v);v->closed=true;v->host->base.release(&v->host->base);v->host=nullptr;v->browser->base.release(&v->browser->base);v->browser=nullptr;}};
|
||||
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);if(v&&v->key=="launcher"){const auto value=text(message);if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=1")launcher_fade.reduced=true;else if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=0")launcher_fade.reduced=false;}std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
|
||||
load_handler.api.on_load_end=[](cef_load_handler_t*,cef_browser_t*,cef_frame_t* frame,int){inject_page_scroll(frame);};
|
||||
client.api.get_load_handler=[](cef_client_t*){return load_handler.acquire();};
|
||||
request_handler.api.on_before_browse=[](cef_request_handler_t*,cef_browser_t* b,cef_frame_t* f,cef_request_t* r,int,int){auto* uri=r->get_url(r);auto s=text(uri);cef_string_userfree_utf16_free(uri);auto* v=find_browser(b);return int(!allow_plugin_navigation(s,origin,!f||f->is_main(f),v&&!v->plugin.empty()));};
|
||||
request_handler.api.on_render_process_terminated=[](cef_request_handler_t*,cef_browser_t* b,cef_termination_status_t,int,const cef_string_t*){auto* v=find_browser(b);if(v){std::cerr<<"Renderer crashed: "<<v->key<<"\n";close_view(*v);if(v->key=="menu")running=0;if(v->key=="launcher")launcher_open=false;}};
|
||||
client.api.get_render_handler=[](cef_client_t*){return render_handler.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life_handler.acquire();};client.api.get_display_handler=[](cef_client_t*){return display_handler.acquire();};client.api.get_request_handler=[](cef_client_t*){return request_handler.acquire();};
|
||||
dialog_handler.api.on_file_dialog=[](cef_dialog_handler_t*,cef_browser_t* b,cef_file_dialog_mode_t mode,const cef_string_t*,const cef_string_t* default_path,cef_string_list_t filters,cef_string_list_t extensions,cef_string_list_t,cef_file_dialog_callback_t* callback){
|
||||
auto* v=find_browser(b);if(!v||v->key!="framely.manager"||mode==FILE_DIALOG_SAVE)return 0;
|
||||
cancel_file_dialog(*v);callback->base.add_ref(&callback->base);v->file_dialog=callback;v->file_multiple=mode==FILE_DIALOG_OPEN_MULTIPLE;v->file_directory=mode==FILE_DIALOG_OPEN_FOLDER;v->file_dialog_id=std::to_string(++file_dialog_sequence)+"-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
|
||||
json accept=json::array();for(auto list:{filters,extensions}){if(!list)continue;for(size_t i=0;i<cef_string_list_size(list);++i){cef_string_t value{};cef_string_list_value(list,i,&value);auto filter=text(&value);cef_string_utf16_clear(&value);if(!filter.empty())accept.push_back(filter);}}
|
||||
auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.fileDialog',{detail:"+json({{"id",v->file_dialog_id},{"multiple",v->file_multiple},{"directory",v->file_directory},{"accept",accept},{"defaultPath",default_path?text(default_path):std::string{}}}).dump()+"}))");frame->base.release(&frame->base);return 1;
|
||||
};
|
||||
client.api.get_dialog_handler=[](cef_client_t*){return dialog_handler.acquire();};client.api.get_render_handler=[](cef_client_t*){return render_handler.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life_handler.acquire();};client.api.get_display_handler=[](cef_client_t*){return display_handler.acquire();};client.api.get_request_handler=[](cef_client_t*){return request_handler.acquire();};
|
||||
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);overlays->SetOverlayInputMethod(button,vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{dock_canvas_width,dock_canvas_height}};overlays->SetOverlayMouseScale(button,&scale);vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;mask.m_Primitive.m_Rectangle={dock_icon_left,0,128,128};if(overlays->SetOverlayIntersectionMask(button,&mask,1)){std::cerr<<"Dock input mask unavailable\n";overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayFlag(button,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(button,0);overlays->SetOverlayFlag(button,vr::VROverlayFlags_HideLaserIntersection,false);
|
||||
try{create_view("menu","Framely",initial,false,600,840);}catch(const std::exception& e){std::cerr<<e.what()<<"\n";running=0;}
|
||||
std::thread poller([port]{int failures=0;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_primary.store(data.value("launcher",json::object()).value("primaryTrigger",true));std::lock_guard<std::mutex> lock(queue_mutex);for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
@@ -417,12 +443,14 @@ int main(int argc,char** argv){
|
||||
if(main_overlay&&!overlays->GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose;
|
||||
if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);}
|
||||
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())launcher_open=false;for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher")close_view(*v);}was_visible=visible;
|
||||
if(launcher_close_at.time_since_epoch().count()&&now>=launcher_close_at){launcher_close_at={};if(!launcher_open&&views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay))overlays->ShowDashboard("valve.steam.gamepadui.main");}
|
||||
// The runtime owns dashboard placement, curvature, visibility and switching.
|
||||
// Never move the launcher into standing space or hide another app's overlay.
|
||||
if(launcher_open)launcher_dock_interaction();
|
||||
if(views.count("launcher")){
|
||||
bool selected=overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay);
|
||||
if(launcher_open&&selected)launcher_selected=true;
|
||||
if(launcher_open||launcher_close_at.time_since_epoch().count())overlays->SetOverlayAlpha(views.at("launcher")->overlay,launcher_open?std::max(.02f,launcher_fade.value(now)):launcher_fade.value(now));
|
||||
if(launcher_open&&!selected&&overlays->IsDashboardVisible()&&!launcher_dock_interaction()&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(1)))launcher_open=false;
|
||||
}
|
||||
// Toggle once for a matched left click; never activate on a stale release.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
// Compositor alpha fades even when a cached CEF texture has not repainted yet.
|
||||
struct LauncherFade {
|
||||
bool reduced=false;float from=1,to=1;int duration=0;
|
||||
std::chrono::steady_clock::time_point started{};
|
||||
float value(std::chrono::steady_clock::time_point now)const{
|
||||
if(reduced||!duration)return to;
|
||||
float t=std::clamp(std::chrono::duration<float,std::milli>(now-started).count()/duration,0.f,1.f);
|
||||
float eased=t*t*(3-2*t);return from+(to-from)*eased;
|
||||
}
|
||||
void open(std::chrono::steady_clock::time_point now){from=0;to=1;duration=580;started=now;}
|
||||
void close(std::chrono::steady_clock::time_point now){from=value(now);to=0;duration=440;started=now;}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
#include "include/capi/cef_frame_capi.h"
|
||||
#include "native_scroll_script.h"
|
||||
// Run in every newly loaded frame; no network request, SDK or bridge required.
|
||||
inline void inject_page_scroll(cef_frame_t* frame){
|
||||
if(!frame||!frame->is_valid(frame))return;
|
||||
cef_string_t script{},source{};
|
||||
cef_string_utf8_to_utf16(native_scroll_script,sizeof(native_scroll_script)-1,&script);
|
||||
frame->execute_java_script(frame,&script,&source,1);
|
||||
cef_string_utf16_clear(&script);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Embedded into the native host, including localWeb and nested plugin frames.
|
||||
// Shares the document guard with the SDK bootstrap to avoid duplicate gestures.
|
||||
import {installScrollGestures} from './scroll-gestures';
|
||||
installScrollGestures();
|
||||
+2500
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,530 @@
|
||||
//! Bounded Android binary XML/resource reader. No APK code is executed.
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, fs::File, io::Read, path::Path};
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Metadata {
|
||||
pub package: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub version_code: u64,
|
||||
pub min_sdk: u32,
|
||||
pub activities: Vec<String>,
|
||||
pub declared_activities: Vec<String>,
|
||||
pub icon: Option<String>,
|
||||
pub abis: Vec<String>,
|
||||
pub vr: bool,
|
||||
}
|
||||
fn u16at(b: &[u8], p: usize) -> Result<u16> {
|
||||
Ok(u16::from_le_bytes(
|
||||
b.get(p..p + 2)
|
||||
.context("Truncated APK metadata")?
|
||||
.try_into()?,
|
||||
))
|
||||
}
|
||||
fn u32at(b: &[u8], p: usize) -> Result<u32> {
|
||||
Ok(u32::from_le_bytes(
|
||||
b.get(p..p + 4)
|
||||
.context("Truncated APK metadata")?
|
||||
.try_into()?,
|
||||
))
|
||||
}
|
||||
fn string_len(b: &[u8], p: &mut usize, utf8: bool) -> Result<usize> {
|
||||
if utf8 {
|
||||
let a = *b.get(*p).context("Invalid string length")?;
|
||||
*p += 1;
|
||||
if a & 128 != 0 {
|
||||
let v =
|
||||
((a as usize & 127) << 8) | *b.get(*p).context("Invalid string length")? as usize;
|
||||
*p += 1;
|
||||
Ok(v)
|
||||
} else {
|
||||
Ok(a as usize)
|
||||
}
|
||||
} else {
|
||||
let a = u16at(b, *p)?;
|
||||
*p += 2;
|
||||
if a & 0x8000 != 0 {
|
||||
let v = ((a as usize & 0x7fff) << 16) | u16at(b, *p)? as usize;
|
||||
*p += 2;
|
||||
Ok(v)
|
||||
} else {
|
||||
Ok(a as usize)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn strings(b: &[u8]) -> Result<Vec<String>> {
|
||||
let count = u32at(b, 8)? as usize;
|
||||
ensure!(count <= 262144, "APK string pool is too large");
|
||||
let utf8 = u32at(b, 16)? & 256 != 0;
|
||||
let start = u32at(b, 20)? as usize;
|
||||
let header = u16at(b, 2)? as usize;
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let mut p = start
|
||||
.checked_add(u32at(b, header + i * 4)? as usize)
|
||||
.context("Invalid string offset")?;
|
||||
let n = string_len(b, &mut p, utf8)?;
|
||||
if utf8 {
|
||||
let bytes = string_len(b, &mut p, true)?;
|
||||
Ok(
|
||||
std::str::from_utf8(b.get(p..p + bytes).context("Invalid APK string")?)?
|
||||
.to_owned(),
|
||||
)
|
||||
} else {
|
||||
let bytes = b.get(p..p + n * 2).context("Invalid UTF16 string")?;
|
||||
Ok(String::from_utf16(
|
||||
&bytes
|
||||
.chunks_exact(2)
|
||||
.map(|s| u16::from_le_bytes([s[0], s[1]]))
|
||||
.collect::<Vec<_>>(),
|
||||
)?)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn chunks(b: &[u8], mut p: usize) -> Result<Vec<&[u8]>> {
|
||||
let mut out = Vec::new();
|
||||
while p < b.len() {
|
||||
let n = u32at(b, p + 4)? as usize;
|
||||
ensure!(n >= 8 && n <= b.len() - p, "Invalid APK chunk size");
|
||||
out.push(&b[p..p + n]);
|
||||
p += n;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[derive(Clone)]
|
||||
enum Val {
|
||||
Text(String),
|
||||
Ref(u32),
|
||||
Int(u32),
|
||||
}
|
||||
fn val(b: &[u8], p: usize, s: &[String]) -> Result<Val> {
|
||||
let typ = *b.get(p + 3).context("Invalid value")?;
|
||||
let v = u32at(b, p + 4)?;
|
||||
Ok(match typ {
|
||||
3 => Val::Text(
|
||||
s.get(v as usize)
|
||||
.context("Invalid string reference")?
|
||||
.clone(),
|
||||
),
|
||||
1 => Val::Ref(v),
|
||||
_ => Val::Int(v),
|
||||
})
|
||||
}
|
||||
fn resources(b: &[u8]) -> Result<BTreeMap<u32, Val>> {
|
||||
let mut map = BTreeMap::new();
|
||||
let mut pool = Vec::new();
|
||||
for c in chunks(b, u16at(b, 2)? as usize)? {
|
||||
match u16at(c, 0)? {
|
||||
1 => pool = strings(c)?,
|
||||
0x200 => {
|
||||
let pkg = u32at(c, 8)?;
|
||||
for t in chunks(c, u16at(c, 2)? as usize)? {
|
||||
if u16at(t, 0)? != 0x201 {
|
||||
continue;
|
||||
}
|
||||
let id = *t.get(8).context("Invalid resource type")? as u32;
|
||||
let flags = t[9];
|
||||
if flags != 0 {
|
||||
continue;
|
||||
}
|
||||
let count = u32at(t, 12)? as usize;
|
||||
let start = u32at(t, 16)? as usize;
|
||||
let h = u16at(t, 2)? as usize;
|
||||
ensure!(count <= 65536, "Too many APK resources");
|
||||
for i in 0..count {
|
||||
let offset = u32at(t, h + i * 4)?;
|
||||
if offset == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
let e = start + offset as usize;
|
||||
let size = u16at(t, e)? as usize;
|
||||
if u16at(t, e + 2)? & 1 != 0 {
|
||||
continue;
|
||||
}
|
||||
let key = (pkg << 24) | (id << 16) | i as u32;
|
||||
let v = val(t, e + size, &pool)?;
|
||||
map.entry(key).or_insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
fn text(v: Option<&Val>, r: &BTreeMap<u32, Val>) -> String {
|
||||
let mut v = v;
|
||||
for _ in 0..16 {
|
||||
match v {
|
||||
Some(Val::Text(s)) => return s.clone(),
|
||||
Some(Val::Int(n)) => return n.to_string(),
|
||||
Some(Val::Ref(id)) => v = r.get(id),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
fn numeric(v: Option<&Val>) -> u32 {
|
||||
match v {
|
||||
Some(Val::Int(n)) => *n,
|
||||
Some(Val::Text(s)) => s.parse().unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
pub fn valid_package(s: &str) -> bool {
|
||||
s.len() <= 255
|
||||
&& s.contains('.')
|
||||
&& s.split('.').all(|p| {
|
||||
!p.is_empty()
|
||||
&& p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
&& !p.as_bytes()[0].is_ascii_digit()
|
||||
})
|
||||
}
|
||||
fn activity(p: &str, n: &str) -> String {
|
||||
if n.starts_with('.') {
|
||||
format!("{p}{n}")
|
||||
} else if !n.contains('.') {
|
||||
format!("{p}.{n}")
|
||||
} else {
|
||||
n.into()
|
||||
}
|
||||
}
|
||||
fn xml(b: &[u8], r: &BTreeMap<u32, Val>) -> Result<Metadata> {
|
||||
ensure!(u16at(b, 0)? == 3, "APK manifest must be Android binary XML");
|
||||
let mut m = Metadata::default();
|
||||
let mut pool = Vec::new();
|
||||
let mut stack: Vec<String> = Vec::new();
|
||||
let mut current = None::<(String, bool, bool, bool, bool)>;
|
||||
let mut label = None;
|
||||
let mut icon = None;
|
||||
let mut app_enabled = true;
|
||||
for c in chunks(b, u16at(b, 2)? as usize)? {
|
||||
match u16at(c, 0)? {
|
||||
1 => pool = strings(c)?,
|
||||
0x102 => {
|
||||
let tag = pool
|
||||
.get(u32at(c, 20)? as usize)
|
||||
.context("Invalid XML element")?
|
||||
.clone();
|
||||
let start = 16 + u16at(c, 24)? as usize;
|
||||
let stride = u16at(c, 26)? as usize;
|
||||
let count = u16at(c, 28)? as usize;
|
||||
ensure!(stride >= 20 && count <= 4096, "Invalid XML attributes");
|
||||
let mut attrs = BTreeMap::new();
|
||||
for i in 0..count {
|
||||
let p = start + i * stride;
|
||||
let name = pool
|
||||
.get(u32at(c, p + 4)? as usize)
|
||||
.context("Invalid XML attribute")?
|
||||
.clone();
|
||||
attrs.insert(name, val(c, p + 12, &pool)?);
|
||||
}
|
||||
match tag.as_str() {
|
||||
"manifest" => {
|
||||
m.package = text(attrs.get("package"), r);
|
||||
m.version = text(attrs.get("versionName"), r);
|
||||
m.version_code = numeric(attrs.get("versionCode")) as u64
|
||||
| ((numeric(attrs.get("versionCodeMajor")) as u64) << 32);
|
||||
}
|
||||
"uses-sdk" => m.min_sdk = numeric(attrs.get("minSdkVersion")),
|
||||
"application" => {
|
||||
label = attrs.get("label").cloned();
|
||||
icon = attrs.get("icon").cloned();
|
||||
app_enabled = attrs.get("enabled").is_none_or(|v| numeric(Some(v)) != 0);
|
||||
}
|
||||
"activity" | "activity-alias" => {
|
||||
let n = activity(&m.package, &text(attrs.get("name"), r));
|
||||
let enabled = app_enabled
|
||||
&& attrs.get("enabled").is_none_or(|v| numeric(Some(v)) != 0)
|
||||
&& attrs.get("exported").is_none_or(|v| numeric(Some(v)) != 0);
|
||||
m.declared_activities.push(n.clone());
|
||||
current = Some((n, enabled, false, false, false));
|
||||
}
|
||||
"intent-filter" => {
|
||||
if let Some(a) = current.as_mut() {
|
||||
a.2 = false;
|
||||
a.3 = false;
|
||||
}
|
||||
}
|
||||
"action" => {
|
||||
if let Some(a) = current.as_mut() {
|
||||
if text(attrs.get("name"), r) == "android.intent.action.MAIN" {
|
||||
a.2 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
"category" => {
|
||||
let name = text(attrs.get("name"), r);
|
||||
if let Some(a) = current.as_mut() {
|
||||
if [
|
||||
"android.intent.category.LAUNCHER",
|
||||
"android.intent.category.LEANBACK_LAUNCHER",
|
||||
"com.oculus.intent.category.VR",
|
||||
]
|
||||
.contains(&name.as_str())
|
||||
{
|
||||
a.3 = true;
|
||||
}
|
||||
}
|
||||
if name == "com.oculus.intent.category.VR" {
|
||||
m.vr = true;
|
||||
}
|
||||
}
|
||||
"meta-data" => {
|
||||
let name = text(attrs.get("name"), r);
|
||||
if name == "com.samsung.android.vr.application.mode"
|
||||
|| name == "com.oculus.supportedDevices"
|
||||
{
|
||||
m.vr = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
stack.push(tag);
|
||||
}
|
||||
0x103 => {
|
||||
let tag = stack.pop().context("Unbalanced APK XML")?;
|
||||
if tag == "intent-filter" {
|
||||
if let Some(a) = current.as_mut() {
|
||||
a.4 |= a.2 && a.3;
|
||||
}
|
||||
}
|
||||
if tag == "activity" || tag == "activity-alias" {
|
||||
if let Some((n, enabled, main, launcher, matched)) = current.take() {
|
||||
if enabled && (matched || main && launcher) {
|
||||
m.activities.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ensure!(valid_package(&m.package), "Invalid APK package name");
|
||||
m.name = text(label.as_ref(), r);
|
||||
if m.name.is_empty() {
|
||||
m.name = m.package.clone();
|
||||
}
|
||||
if m.version.is_empty() {
|
||||
m.version = m.version_code.to_string();
|
||||
}
|
||||
let path = text(icon.as_ref(), r);
|
||||
if path.starts_with("res/") {
|
||||
m.icon = Some(path);
|
||||
}
|
||||
m.activities.sort();
|
||||
m.activities.dedup();
|
||||
Ok(m)
|
||||
}
|
||||
fn entry(z: &mut zip::ZipArchive<File>, name: &str, max: u64) -> Result<Vec<u8>> {
|
||||
let mut f = z.by_name(name)?;
|
||||
ensure!(f.size() <= max, "APK metadata exceeds size limit");
|
||||
let mut b = Vec::new();
|
||||
f.by_ref().take(max + 1).read_to_end(&mut b)?;
|
||||
ensure!(b.len() as u64 <= max, "APK metadata exceeds size limit");
|
||||
Ok(b)
|
||||
}
|
||||
pub fn read(path: &Path) -> Result<Metadata> {
|
||||
let mut z = zip::ZipArchive::new(File::open(path)?).context("Invalid APK archive")?;
|
||||
ensure!(z.len() <= 65536, "Too many APK files");
|
||||
let r = match entry(&mut z, "resources.arsc", 64 * 1024 * 1024) {
|
||||
Ok(b) => resources(&b)?,
|
||||
Err(e) => {
|
||||
if z.by_name("resources.arsc").is_ok() {
|
||||
return Err(e);
|
||||
}
|
||||
BTreeMap::new()
|
||||
}
|
||||
};
|
||||
let b = entry(&mut z, "AndroidManifest.xml", 8 * 1024 * 1024)?;
|
||||
let mut m = xml(&b, &r)?;
|
||||
if let Some(path) = m.icon.take() {
|
||||
let mime = if path.ends_with(".png") {
|
||||
Some("image/png")
|
||||
} else if path.ends_with(".webp") {
|
||||
Some("image/webp")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(mime) = mime {
|
||||
if let Ok(b) = entry(&mut z, &path, 2 * 1024 * 1024) {
|
||||
m.icon = Some(format!("data:{mime};base64,{}", STANDARD.encode(b)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..z.len() {
|
||||
let f = z.by_index(i)?;
|
||||
if let Some(p) = f.name().strip_prefix("lib/") {
|
||||
if let Some((abi, _)) = p.split_once('/') {
|
||||
if !m.abis.contains(&abi.to_string()) {
|
||||
m.abis.push(abi.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !m.abis.is_empty()
|
||||
&& !m
|
||||
.abis
|
||||
.iter()
|
||||
.any(|a| a == "arm64-v8a" || a == "armeabi-v7a")
|
||||
{
|
||||
bail!("APK has no compatible ARM libraries");
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn package_and_activity_validation() {
|
||||
assert!(valid_package("com.example.app"));
|
||||
for p in ["../x", "com..app", "com.1app", "app", "com.app;rm"] {
|
||||
assert!(!valid_package(p));
|
||||
}
|
||||
assert_eq!(activity("com.a", ".Main"), "com.a.Main");
|
||||
}
|
||||
#[test]
|
||||
fn malformed_chunks_and_resource_cycles_fail_safely() {
|
||||
assert!(chunks(&[0; 8], 0).is_err());
|
||||
let r = BTreeMap::from([(1, Val::Ref(2)), (2, Val::Ref(1))]);
|
||||
assert_eq!(text(Some(&Val::Ref(1)), &r), "");
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fixture(path: &Path, launch: bool, version: u32) {
|
||||
use std::io::Write;
|
||||
fn chunk(kind: u16, header: u16, body: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
b.extend(kind.to_le_bytes());
|
||||
b.extend(header.to_le_bytes());
|
||||
b.extend(((body.len() + 8) as u32).to_le_bytes());
|
||||
b.extend(body);
|
||||
b
|
||||
}
|
||||
let names = [
|
||||
"manifest",
|
||||
"package",
|
||||
"com.example.app",
|
||||
"versionCode",
|
||||
"versionName",
|
||||
"1.0",
|
||||
"application",
|
||||
"label",
|
||||
"Example",
|
||||
"activity",
|
||||
"name",
|
||||
".Main",
|
||||
"intent-filter",
|
||||
"action",
|
||||
"android.intent.action.MAIN",
|
||||
"category",
|
||||
"android.intent.category.LAUNCHER",
|
||||
"enabled",
|
||||
];
|
||||
let mut data = Vec::new();
|
||||
let mut offsets = Vec::new();
|
||||
for s in names {
|
||||
offsets.push(data.len() as u32);
|
||||
data.push(s.len() as u8);
|
||||
data.push(s.len() as u8);
|
||||
data.extend(s.as_bytes());
|
||||
data.push(0);
|
||||
}
|
||||
while data.len() % 4 != 0 {
|
||||
data.push(0)
|
||||
}
|
||||
let mut pool = Vec::new();
|
||||
pool.extend((names.len() as u32).to_le_bytes());
|
||||
pool.extend(0u32.to_le_bytes());
|
||||
pool.extend(256u32.to_le_bytes());
|
||||
pool.extend((28 + names.len() as u32 * 4).to_le_bytes());
|
||||
pool.extend(0u32.to_le_bytes());
|
||||
for o in offsets {
|
||||
pool.extend(o.to_le_bytes())
|
||||
}
|
||||
pool.extend(data);
|
||||
let mut body = chunk(1, 28, &pool);
|
||||
fn start(tag: u32, attrs: &[(u32, u8, u32)]) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
b.extend(1u32.to_le_bytes());
|
||||
b.extend(u32::MAX.to_le_bytes());
|
||||
b.extend(u32::MAX.to_le_bytes());
|
||||
b.extend(tag.to_le_bytes());
|
||||
b.extend(20u16.to_le_bytes());
|
||||
b.extend(20u16.to_le_bytes());
|
||||
b.extend((attrs.len() as u16).to_le_bytes());
|
||||
b.extend([0; 6]);
|
||||
for (name, typ, value) in attrs {
|
||||
b.extend(u32::MAX.to_le_bytes());
|
||||
b.extend(name.to_le_bytes());
|
||||
b.extend(u32::MAX.to_le_bytes());
|
||||
b.extend(8u16.to_le_bytes());
|
||||
b.push(0);
|
||||
b.push(*typ);
|
||||
b.extend(value.to_le_bytes());
|
||||
}
|
||||
chunk(0x102, 16, &b)
|
||||
}
|
||||
fn end(tag: u32) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
b.extend(1u32.to_le_bytes());
|
||||
b.extend(u32::MAX.to_le_bytes());
|
||||
b.extend(u32::MAX.to_le_bytes());
|
||||
b.extend(tag.to_le_bytes());
|
||||
chunk(0x103, 16, &b)
|
||||
}
|
||||
body.extend(start(0, &[(1, 3, 2), (3, 0x10, version), (4, 3, 5)]));
|
||||
body.extend(start(6, &[(7, 3, 8)]));
|
||||
body.extend(start(9, &[(10, 3, 11)]));
|
||||
if launch {
|
||||
body.extend(start(12, &[]));
|
||||
body.extend(start(13, &[(10, 3, 14)]));
|
||||
body.extend(end(13));
|
||||
body.extend(start(15, &[(10, 3, 16)]));
|
||||
body.extend(end(15));
|
||||
body.extend(end(12));
|
||||
}
|
||||
body.extend(end(9));
|
||||
body.extend(end(6));
|
||||
body.extend(end(0));
|
||||
let xml = chunk(3, 8, &body);
|
||||
let mut z = zip::ZipWriter::new(File::create(path).unwrap());
|
||||
z.start_file(
|
||||
"AndroidManifest.xml",
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
z.write_all(&xml).unwrap();
|
||||
z.finish().unwrap();
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod manifest_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn launchers_labels_versions_and_services() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("a.apk");
|
||||
fixture(&p, true, 42);
|
||||
let m = read(&p).unwrap();
|
||||
assert_eq!(m.package, "com.example.app");
|
||||
assert_eq!(m.name, "Example");
|
||||
assert_eq!(m.version_code, 42);
|
||||
assert_eq!(m.activities, vec!["com.example.app.Main"]);
|
||||
fixture(&p, false, 43);
|
||||
assert!(read(&p).unwrap().activities.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn corrupt_zip_and_xml_are_rejected() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("a.apk");
|
||||
std::fs::write(&p, b"not an APK").unwrap();
|
||||
assert!(read(&p).is_err());
|
||||
for n in 0..100 {
|
||||
assert!(xml(&vec![0; n], &BTreeMap::new()).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-172
@@ -1,11 +1,10 @@
|
||||
//! User-session applications: XDG desktop entries and installed Lepton packages.
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -19,12 +18,8 @@ pub struct App {
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
enum Target {
|
||||
ManagedLepton(String),
|
||||
Desktop(PathBuf),
|
||||
Lepton {
|
||||
runner: PathBuf,
|
||||
context: String,
|
||||
package: String,
|
||||
},
|
||||
}
|
||||
fn entries(input: &str) -> BTreeMap<String, String> {
|
||||
let mut active = false;
|
||||
@@ -223,76 +218,22 @@ fn desktop_apps(home: &Path, dirs: &[PathBuf], steam: &BTreeSet<u32>) -> Vec<App
|
||||
}
|
||||
apps
|
||||
}
|
||||
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
let rest = tag.split_once(&format!("{name}=\""))?.1;
|
||||
Some(rest.split_once('"')?.0)
|
||||
}
|
||||
fn safe_component(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& !s.starts_with('-')
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
|
||||
}
|
||||
fn lepton_apps(home: &Path) -> Vec<App> {
|
||||
let runner = [
|
||||
home.join(".local/share/Steam/steamapps/common/Lepton/lepton"),
|
||||
home.join(".steam/steam/steamapps/common/Lepton/lepton"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| p.is_file());
|
||||
let Some(runner) = runner else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut apps = Vec::new();
|
||||
let root = data_home(home).join("lepton/contexts");
|
||||
let Ok(contexts) = fs::read_dir(root) else {
|
||||
return apps;
|
||||
};
|
||||
for context in contexts.flatten() {
|
||||
let name = context.file_name().to_string_lossy().into_owned();
|
||||
if !safe_component(&name) || name.starts_with("steamlaunch-") {
|
||||
continue;
|
||||
}
|
||||
let data = context.path().join("baked/data_overlay");
|
||||
let Ok(xml) = fs::read_to_string(data.join("system/packages.xml")) else {
|
||||
continue;
|
||||
};
|
||||
if xml.len() > 8 * 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
for tag in xml.split('<').filter(|t| t.starts_with("package ")) {
|
||||
let Some(package) = attr(tag, "name").filter(|s| safe_component(s)) else {
|
||||
continue;
|
||||
};
|
||||
let Some(code) = attr(tag, "codePath").and_then(|s| s.strip_prefix("/data/")) else {
|
||||
continue;
|
||||
};
|
||||
// Android system packages are not launcher apps. A removed package's stale
|
||||
// metadata is ignored; validate that its installed APK is still present.
|
||||
let path = data.join(code);
|
||||
if code.split('/').any(|s| s == "..") || !path.join("base.apk").is_file() {
|
||||
continue;
|
||||
}
|
||||
let icon = icon_file(
|
||||
&data
|
||||
.join("system_ce/0/shortcut_service/bitmaps")
|
||||
.join(format!("{package}.png")),
|
||||
);
|
||||
apps.push(App {
|
||||
id: format!("{name}/{package}"),
|
||||
name: package.rsplit('.').next().unwrap_or(package).into(),
|
||||
crate::apk::launcher(home)
|
||||
.into_iter()
|
||||
.filter_map(|v| {
|
||||
let id = v["id"].as_str()?.to_owned();
|
||||
Some(App {
|
||||
id: id.clone(),
|
||||
name: v["name"].as_str()?.into(),
|
||||
kind: "lepton",
|
||||
icon,
|
||||
target: Target::Lepton {
|
||||
runner: runner.clone(),
|
||||
context: name.clone(),
|
||||
package: package.into(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
apps
|
||||
icon: v["icon"].as_str().map(str::to_owned),
|
||||
target: Target::ManagedLepton(id),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn discover(home: &Path) -> Vec<App> {
|
||||
let ids = crate::steam::discover(home)
|
||||
.into_iter()
|
||||
@@ -312,6 +253,7 @@ pub fn launch(home: &Path, id: &str) -> Result<()> {
|
||||
}
|
||||
fn launch_target(target: Target) -> Result<()> {
|
||||
match target {
|
||||
Target::ManagedLepton(id) => crate::apk::launch_app(&crate::steam::home()?, &id)?,
|
||||
Target::Desktop(path) => {
|
||||
crate::process::launch_handoff(
|
||||
crate::process::tool("gio").arg("launch").arg(path),
|
||||
@@ -319,78 +261,10 @@ fn launch_target(target: Target) -> Result<()> {
|
||||
)
|
||||
.map_err(|error| anyhow::anyhow!("Desktop launch failed: {error:#}"))?;
|
||||
}
|
||||
Target::Lepton {
|
||||
runner,
|
||||
context,
|
||||
package,
|
||||
} => {
|
||||
// Use the same session-owned context and Android launcher intent; never
|
||||
// reinstall the APK or accept an arbitrary shell command from the UI.
|
||||
let running = crate::process::command_output_timeout(
|
||||
crate::process::tool("podman").args([
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{.State.Running}}",
|
||||
&format!("lepton-{context}"),
|
||||
]),
|
||||
std::time::Duration::from_secs(5),
|
||||
true,
|
||||
)
|
||||
.context("Cannot inspect Lepton context")?;
|
||||
let running = running.status.success()
|
||||
&& String::from_utf8_lossy(&running.stdout).trim() == "true";
|
||||
let child = if running {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
std::process::Command::new(&runner)
|
||||
.args(["start", &context])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.context("Cannot start Lepton context")?,
|
||||
)
|
||||
};
|
||||
if let Some(mut child) = child {
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
let mut ready = false;
|
||||
while start.elapsed() < std::time::Duration::from_secs(35) {
|
||||
let output = crate::process::command_output_timeout(
|
||||
crate::process::tool("podman").args([
|
||||
"exec",
|
||||
&format!("lepton-{context}"),
|
||||
"monkey",
|
||||
"-p",
|
||||
&package,
|
||||
"-c",
|
||||
"android.intent.category.LAUNCHER",
|
||||
"1",
|
||||
]),
|
||||
std::time::Duration::from_secs(35)
|
||||
.saturating_sub(start.elapsed())
|
||||
.min(std::time::Duration::from_secs(5)),
|
||||
false,
|
||||
)
|
||||
.context("Lepton launch handoff failed")?;
|
||||
if output.status.success() {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
ensure!(
|
||||
ready,
|
||||
"Lepton application did not become ready; retry after the context starts"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -435,14 +309,14 @@ mod tests {
|
||||
.join(".local/share/lepton/contexts/test/baked/data_overlay");
|
||||
fs::create_dir_all(data.join("system")).unwrap();
|
||||
fs::create_dir_all(data.join("app/example")).unwrap();
|
||||
fs::write(data.join("app/example/base.apk"), "apk").unwrap();
|
||||
crate::apk_metadata::fixture(&data.join("app/example/base.apk"), true, 1);
|
||||
fs::write(data.join("system/packages.xml"),"<packages><package name=\"com.example.app\" codePath=\"/data/app/example\"/><package name=\"com.android.system\" codePath=\"/system/app/system\"/><package name=\"com.removed.app\" codePath=\"/data/app/missing\"/></packages>").unwrap();
|
||||
let apps = lepton_apps(dir.path());
|
||||
assert_eq!(apps.len(), 1);
|
||||
assert_eq!(apps[0].id, "test/com.example.app");
|
||||
}
|
||||
#[test]
|
||||
fn launch_handoffs_use_literal_arguments_and_reuse_live_lepton_context() {
|
||||
fn desktop_launch_handoff_uses_literal_arguments() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tools = dir.path().join("tools");
|
||||
@@ -458,36 +332,9 @@ mod tests {
|
||||
fs::read_to_string(dir.path().join("gio-args")).unwrap(),
|
||||
format!("launch\n{}\n", path.display())
|
||||
);
|
||||
launch_target(Target::Lepton {
|
||||
runner: dir.path().join("must-not-start"),
|
||||
context: "existing".into(),
|
||||
package: "com.example.app".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(fs::read_to_string(dir.path().join("podman-args")).unwrap(),"exec\nlepton-existing\nmonkey\n-p\ncom.example.app\n-c\nandroid.intent.category.LAUNCHER\n1\n");
|
||||
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = None);
|
||||
}
|
||||
#[test]
|
||||
fn stalled_lepton_inspect_and_launch_are_bounded() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let podman = dir.path().join("podman");
|
||||
for inspect in [true, false] {
|
||||
fs::write(&podman, if inspect { "#!/bin/sh\nexec sleep 30\n" } else { "#!/bin/sh\nif [ \"$1\" = inspect ]; then echo true; exit 0; fi\nexec sleep 30\n" }).unwrap();
|
||||
fs::set_permissions(&podman, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = Some(dir.path().into()));
|
||||
let started = std::time::Instant::now();
|
||||
let result = launch_target(Target::Lepton {
|
||||
runner: dir.path().join("must-not-start"),
|
||||
context: "existing".into(),
|
||||
package: "com.example.app".into(),
|
||||
});
|
||||
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = None);
|
||||
assert!(format!("{:#}", result.unwrap_err()).contains("timed out"));
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(7));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn unknown_id_never_launches() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(launch(dir.path(), "desktop:../../arbitrary").is_err());
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
time::UNIX_EPOCH,
|
||||
};
|
||||
|
||||
pub fn roots(home: &Path) -> Vec<(String, PathBuf)> {
|
||||
let mut roots = vec![
|
||||
("下载".into(), home.join("Downloads")),
|
||||
("主目录".into(), home.into()),
|
||||
("桌面".into(), home.join("Desktop")),
|
||||
("文档".into(), home.join("Documents")),
|
||||
("临时目录".into(), PathBuf::from("/tmp")),
|
||||
];
|
||||
if let Some(user) = home.file_name() {
|
||||
if let Ok(devices) = fs::read_dir(Path::new("/run/media").join(user)) {
|
||||
roots.extend(
|
||||
devices
|
||||
.flatten()
|
||||
.filter(|e| e.path().is_dir())
|
||||
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())),
|
||||
);
|
||||
}
|
||||
}
|
||||
roots
|
||||
.into_iter()
|
||||
.filter_map(|(name, path)| {
|
||||
path.canonicalize()
|
||||
.ok()
|
||||
.filter(|p| p.is_dir())
|
||||
.map(|p| (name, p))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn checked_roots(roots: &[(String, PathBuf)], path: &Path) -> Result<PathBuf> {
|
||||
let path = path
|
||||
.canonicalize()
|
||||
.context("文件或目录不存在,或无权访问")?;
|
||||
ensure!(
|
||||
roots.iter().any(|(_, root)| path.starts_with(root)),
|
||||
"只能选择用户目录、临时目录或已挂载存储中的文件"
|
||||
);
|
||||
Ok(path)
|
||||
}
|
||||
pub fn checked(home: &Path, path: &Path) -> Result<PathBuf> {
|
||||
checked_roots(&roots(home), path)
|
||||
}
|
||||
pub fn list(home: &Path, params: &Value) -> Result<Value> {
|
||||
let roots = roots(home);
|
||||
let default = roots.first().context("没有可访问的目录")?.1.clone();
|
||||
let path = checked_roots(
|
||||
&roots,
|
||||
Path::new(
|
||||
params["path"]
|
||||
.as_str()
|
||||
.filter(|p| !p.is_empty())
|
||||
.unwrap_or(default.to_str().context("Invalid file path")?),
|
||||
),
|
||||
)?;
|
||||
ensure!(path.is_dir(), "请选择目录");
|
||||
let query = params["query"].as_str().unwrap_or("").to_lowercase();
|
||||
ensure!(query.len() <= 1024, "搜索内容过长");
|
||||
let hidden = params["hidden"] == true;
|
||||
let extensions: Vec<String> = params["extensions"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str().map(str::to_lowercase))
|
||||
.collect();
|
||||
ensure!(
|
||||
extensions.len() <= 128
|
||||
&& extensions
|
||||
.iter()
|
||||
.all(|s| s.starts_with('.') && s.len() <= 128),
|
||||
"Invalid file filters"
|
||||
);
|
||||
let offset = params["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let mut entries = vec![];
|
||||
let mut skipped = 0;
|
||||
for (index, entry) in fs::read_dir(&path).context("无法读取目录")?.enumerate() {
|
||||
ensure!(index < 50000, "目录文件过多,请选择更具体的目录");
|
||||
let Ok(entry) = entry else {
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if (!hidden && name.starts_with('.')) || !name.to_lowercase().contains(&query) {
|
||||
continue;
|
||||
}
|
||||
let Ok(target) = checked_roots(&roots, &entry.path()) else {
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
let Ok(meta) = fs::metadata(&target) else {
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if !meta.is_file() && !meta.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if !meta.is_dir()
|
||||
&& (params["directoryOnly"] == true
|
||||
|| (!extensions.is_empty()
|
||||
&& !extensions
|
||||
.iter()
|
||||
.any(|ext| name.to_lowercase().ends_with(ext))))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
entries.push(json!({"name":name,"path":entry.path(),"directory":meta.is_dir(),"size":meta.len(),"modified":meta.modified().ok().and_then(|t|t.duration_since(UNIX_EPOCH).ok()).map(|t|t.as_secs())}));
|
||||
}
|
||||
entries.sort_by(|a, b| {
|
||||
b["directory"]
|
||||
.as_bool()
|
||||
.cmp(&a["directory"].as_bool())
|
||||
.then_with(|| {
|
||||
a["name"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_lowercase()
|
||||
.cmp(&b["name"].as_str().unwrap().to_lowercase())
|
||||
})
|
||||
.then_with(|| a["name"].as_str().cmp(&b["name"].as_str()))
|
||||
});
|
||||
let total = entries.len();
|
||||
let parent = path.parent().and_then(|p| checked_roots(&roots, p).ok());
|
||||
Ok(
|
||||
json!({"path":path,"parent":parent,"roots":roots.iter().map(|(name,path)|json!({"name":name,"path":path})).collect::<Vec<_>>(),"entries":entries.into_iter().skip(offset).take(200).collect::<Vec<_>>(),"total":total,"offset":offset,"skipped":skipped}),
|
||||
)
|
||||
}
|
||||
pub fn selection(home: &Path, params: &Value) -> Result<Vec<PathBuf>> {
|
||||
let paths = params["paths"]
|
||||
.as_array()
|
||||
.context("Missing selected files")?;
|
||||
ensure!(paths.len() <= 64, "选择的文件过多");
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let path = checked(
|
||||
home,
|
||||
Path::new(p.as_str().context("Invalid selected file")?),
|
||||
)?;
|
||||
if params["directory"] == true {
|
||||
ensure!(path.is_dir(), "请选择目录");
|
||||
} else {
|
||||
ensure!(path.is_file(), "请选择文件");
|
||||
fs::File::open(&path).context("无法读取所选文件")?;
|
||||
}
|
||||
Ok(path)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn browsing_filters_sorts_and_keeps_selection_within_roots() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(home.path().join("Downloads")).unwrap();
|
||||
fs::create_dir(home.path().join("Downloads/Z folder")).unwrap();
|
||||
fs::write(home.path().join("Downloads/a.apk"), b"apk").unwrap();
|
||||
fs::write(home.path().join("Downloads/.hidden"), b"hidden").unwrap();
|
||||
std::os::unix::fs::symlink("/etc/passwd", home.path().join("Downloads/escape")).unwrap();
|
||||
let listed = list(home.path(), &json!({})).unwrap();
|
||||
assert_eq!(listed["total"], 2);
|
||||
assert_eq!(listed["entries"][0]["name"], "Z folder");
|
||||
assert_eq!(
|
||||
list(home.path(), &json!({"query":"A.APK"})).unwrap()["total"],
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
list(home.path(), &json!({"hidden":true})).unwrap()["total"],
|
||||
3
|
||||
);
|
||||
assert!(selection(
|
||||
home.path(),
|
||||
&json!({"paths":[home.path().join("Downloads/escape")]})
|
||||
)
|
||||
.is_err());
|
||||
assert!(selection(
|
||||
home.path(),
|
||||
&json!({"paths":[home.path().join("Downloads/a.apk")]})
|
||||
)
|
||||
.is_ok());
|
||||
assert!(selection(
|
||||
home.path(),
|
||||
&json!({"paths":[home.path().join("Downloads")]})
|
||||
)
|
||||
.is_err());
|
||||
assert!(selection(
|
||||
home.path(),
|
||||
&json!({"paths":[home.path().join("Downloads")],"directory":true})
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
+31
-2
@@ -110,7 +110,7 @@ impl Jobs {
|
||||
fn create(&self, kind: &str) -> Result<(String, Cancellation, bool)> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::expire(&mut state);
|
||||
if kind.starts_with("catalog:") {
|
||||
if kind.starts_with("catalog:") || kind == "apk:list" {
|
||||
if let Some((id, job)) = state.jobs.iter().find(|(_, j)| {
|
||||
j.active && j.public["kind"] == kind && !j.cancel.flag.load(Ordering::Acquire)
|
||||
}) {
|
||||
@@ -157,11 +157,29 @@ impl Jobs {
|
||||
.public
|
||||
.clone())
|
||||
}
|
||||
pub fn active_apk(&self) -> Value {
|
||||
let state = self.state.lock().unwrap();
|
||||
state
|
||||
.jobs
|
||||
.iter()
|
||||
.find(|(_, job)| {
|
||||
job.public["kind"] == "apk:operation"
|
||||
&& !matches!(
|
||||
job.public["phase"].as_str(),
|
||||
Some("done" | "failed" | "cancelled")
|
||||
)
|
||||
})
|
||||
.map(|(id, _)| json!({"job":id}))
|
||||
.unwrap_or(json!({"job":null}))
|
||||
}
|
||||
pub fn cancel(&self, id: &str) -> Result<Value> {
|
||||
let cancel = {
|
||||
let state = self.state.lock().unwrap();
|
||||
let j = state.jobs.get(id).context("任务不存在")?;
|
||||
ensure!(j.public["kind"] != "install", "安装已经开始,不能中途取消");
|
||||
ensure!(
|
||||
j.public["kind"] != "install" && j.public["cancellable"] != false,
|
||||
"安装已经开始,不能中途取消"
|
||||
);
|
||||
j.cancel.clone()
|
||||
};
|
||||
// Serialize cancellation with state-changing commits, never with the jobs lock held.
|
||||
@@ -615,6 +633,17 @@ mod tests {
|
||||
panic!("job timeout");
|
||||
}
|
||||
#[test]
|
||||
fn submitted_apk_operations_reject_cancellation_without_waiting_for_worker() {
|
||||
let jobs = Jobs::default();
|
||||
let (id, _, _) = jobs.create("apk:operation").unwrap();
|
||||
assert_eq!(jobs.active_apk()["job"], id);
|
||||
jobs.patch(&id, json!({"cancellable":false,"phase":"installing"}));
|
||||
assert!(jobs.cancel(&id).is_err());
|
||||
assert_eq!(jobs.status(&id).unwrap()["phase"], "installing");
|
||||
jobs.patch(&id, json!({"phase":"done"}));
|
||||
assert_eq!(jobs.active_apk()["job"], Value::Null);
|
||||
}
|
||||
#[test]
|
||||
fn cancellation_and_limits_do_not_allow_unreviewed_install() {
|
||||
let jobs = Jobs::default();
|
||||
let (a, _, _) = jobs.create("inspect").unwrap();
|
||||
|
||||
+42
@@ -1,7 +1,10 @@
|
||||
mod apk;
|
||||
mod apk_metadata;
|
||||
mod auth;
|
||||
mod desktop;
|
||||
mod diagnostics;
|
||||
mod http;
|
||||
mod file_browser;
|
||||
mod ipc;
|
||||
mod jobs;
|
||||
mod launcher_search;
|
||||
@@ -35,6 +38,15 @@ struct Cli {
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Manage sideloaded APKs as the Steam session user (also works without the panel).
|
||||
Apk {
|
||||
operation: String,
|
||||
#[arg(default_value = "{}")]
|
||||
params: String,
|
||||
/// Local APK file for inspection; file paths are never accepted by the web API.
|
||||
#[arg(long)]
|
||||
file: Option<PathBuf>,
|
||||
},
|
||||
/// Export logs even when the manager service is unavailable.
|
||||
ExportLogs {
|
||||
#[arg(long, default_value = "/var/lib/framely")]
|
||||
@@ -140,6 +152,36 @@ enum Cmd {
|
||||
}
|
||||
fn run() -> Result<()> {
|
||||
match Cli::parse().command {
|
||||
Cmd::Apk {
|
||||
operation,
|
||||
params,
|
||||
file,
|
||||
} => {
|
||||
let home = steam::home()?;
|
||||
let p: Value = serde_json::from_str(¶ms)?;
|
||||
let result = match operation.as_str() {
|
||||
"metadata" => json!(apk_metadata::read(
|
||||
&file.context("Use --file for APK inspection")?
|
||||
)?),
|
||||
"list" => apk::list(&home)?,
|
||||
"cleanup-list" => apk::cleanup_list(&home)?,
|
||||
"logs" => apk::logs(&home, p["app"].as_str().context("Missing app")?)?,
|
||||
"inspect" => apk::inspect(
|
||||
&home,
|
||||
&file.context("Use --file for APK inspection")?,
|
||||
&jobs::Cancellation::default(),
|
||||
)?,
|
||||
_ => apk::operate(
|
||||
&home,
|
||||
&operation,
|
||||
&p,
|
||||
jobs::Cancellation::default(),
|
||||
std::sync::Arc::new(|p| eprintln!("{p}")),
|
||||
)?,
|
||||
};
|
||||
println!("{}", serde_json::to_string(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
Cmd::ExportLogs { state, output } => {
|
||||
let archive = diagnostics::collect(&state)?;
|
||||
let bytes = STANDARD.decode(archive["data"].as_str().context("Missing archive")?)?;
|
||||
|
||||
+5
-1
@@ -28,7 +28,11 @@ impl Drop for Staged {
|
||||
}
|
||||
impl Staged {
|
||||
pub fn create() -> Result<Self> {
|
||||
let directory = PathBuf::from("/tmp").join(format!(
|
||||
Self::create_in(Path::new("/tmp"))
|
||||
}
|
||||
/// Session-local staging; privileged IPC continues to require /tmp snapshots.
|
||||
pub fn create_in(parent: &Path) -> Result<Self> {
|
||||
let directory = parent.join(format!(
|
||||
"framely-package-{}",
|
||||
hex::encode(rand::random::<[u8; 24]>())
|
||||
));
|
||||
|
||||
@@ -336,6 +336,12 @@ impl Agent {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if remote && r.method() == &Method::Get && path == "/" {
|
||||
r.respond(
|
||||
Response::empty(StatusCode(302)).with_header(header("Location", "/manager")),
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
if path == "/host/poll" {
|
||||
ensure!(!remote && self.native(&r), "Native authentication failed");
|
||||
if r.method() == &Method::Post {
|
||||
@@ -513,6 +519,29 @@ impl Agent {
|
||||
},
|
||||
"Session authentication failed"
|
||||
);
|
||||
if path.starts_with("/host/files/") {
|
||||
ensure!(!remote && r.method() == &Method::Post, "File browser is only available inside Frame");
|
||||
ensure!(r.headers().iter().any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin), "Cross-origin file browser request denied");
|
||||
ensure!(self.core("agreement.status", json!({}))?["accepted"] == true, "请先同意用户协议和隐私声明");
|
||||
let mut bytes = Vec::new();
|
||||
r.as_reader().take(65537).read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.len() <= 65536, "File browser request too large");
|
||||
let result = (|| -> Result<Value> {
|
||||
let params: Value = serde_json::from_slice(&bytes)?;
|
||||
let home = crate::steam::home()?;
|
||||
match path.as_str() {
|
||||
"/host/files/list" => crate::file_browser::list(&home, ¶ms),
|
||||
"/host/files/complete" => {
|
||||
let id = params["id"].as_str().filter(|s| !s.is_empty() && s.len() <= 128).context("Invalid file dialog")?;
|
||||
let paths = crate::file_browser::selection(&home, ¶ms)?;
|
||||
push(&self.commands, json!({"kind":"file.dialog.complete","id":id,"paths":paths}));
|
||||
Ok(json!(true))
|
||||
}
|
||||
_ => anyhow::bail!("Unknown file browser operation"),
|
||||
}
|
||||
})();
|
||||
return send_json(r, match result { Ok(v) => json!({"result":v}), Err(e) => json!({"error":e.to_string()}) });
|
||||
}
|
||||
if let Some(path) = path.strip_prefix("/api/upload/") {
|
||||
ensure!(r.method() == &Method::Post, "Invalid upload method");
|
||||
let origin = r
|
||||
@@ -645,6 +674,57 @@ impl Agent {
|
||||
// HTTP requests run on workers, so dictionary loading never blocks native input.
|
||||
self.launcher_search.lock().unwrap().batch(&path, &names)
|
||||
}
|
||||
"apk.upload.start" => {
|
||||
let size = p["size"].as_u64().context("Missing APK upload size")?;
|
||||
let root = crate::apk::upload_dir(&crate::steam::home()?, size)?;
|
||||
self.jobs.uploads.start_in(size, &root)
|
||||
}
|
||||
"apk.selection" => {
|
||||
let app = p["app"].as_str().context("Missing APK application")?;
|
||||
ensure!(app.len() <= 512, "Invalid APK application");
|
||||
self.events.lock().unwrap().append(
|
||||
json!({"kind":"apk.selection","app":app,"uninstall":p["uninstall"]==true}),
|
||||
);
|
||||
Ok(json!(true))
|
||||
}
|
||||
"apk.operation.active" => Ok(self.jobs.active_apk()),
|
||||
"apk.list.start" => {
|
||||
let home = crate::steam::home()?;
|
||||
self.jobs.task("apk:list", move |cancel| {
|
||||
cancel.check()?;
|
||||
crate::apk::list(&home)
|
||||
})
|
||||
}
|
||||
"apk.inspect.start" => {
|
||||
let home = crate::steam::home()?;
|
||||
let staged = self
|
||||
.jobs
|
||||
.uploads
|
||||
.take(p["upload"].as_str().context("Missing APK upload")?)?;
|
||||
self.jobs.task("apk:inspect", move |cancel| {
|
||||
crate::apk::inspect(&home, &staged.path, &cancel)
|
||||
})
|
||||
}
|
||||
"apk.review.drop" => crate::apk::reviews_drop(
|
||||
&crate::steam::home()?,
|
||||
p["ticket"].as_str().context("Missing APK review")?,
|
||||
),
|
||||
"apk.cleanup.list" => crate::apk::cleanup_list(&crate::steam::home()?),
|
||||
"apk.logs" => crate::apk::logs(
|
||||
&crate::steam::home()?,
|
||||
p["app"].as_str().context("Missing APK application")?,
|
||||
),
|
||||
"apk.operation.start" => {
|
||||
let home = crate::steam::home()?;
|
||||
let kind = p["operation"]
|
||||
.as_str()
|
||||
.context("Missing APK operation")?
|
||||
.to_owned();
|
||||
self.jobs
|
||||
.task_progress("apk:operation", move |cancel, progress| {
|
||||
crate::apk::operate(&home, &kind, &p, cancel, progress)
|
||||
})
|
||||
}
|
||||
"desktop.list" => Ok(json!(crate::desktop::discover(&crate::steam::home()?))),
|
||||
"desktop.launch" => {
|
||||
let id = p["app"].as_str().context("Missing desktop app")?;
|
||||
@@ -804,6 +884,7 @@ impl Agent {
|
||||
| "updates"
|
||||
| "notification-settings"
|
||||
| "launcher-settings"
|
||||
| "apk"
|
||||
)
|
||||
)
|
||||
{
|
||||
@@ -2000,6 +2081,9 @@ mod tests {
|
||||
assert!(ureq::get(&format!("{origin}/boot/{}", agent.web_key))
|
||||
.call()
|
||||
.is_err());
|
||||
for path in ["/host/files/list", "/host/files/complete"] {
|
||||
assert!(matches!(ureq::post(&format!("{origin}{path}")).set("Origin", &origin).send_json(json!({})), Err(ureq::Error::Status(403, _))));
|
||||
}
|
||||
assert!(ureq::get(&format!("{origin}/host/poll"))
|
||||
.set("X-Framely-Native", &agent.native_key)
|
||||
.call()
|
||||
@@ -2083,6 +2167,9 @@ mod tests {
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap();
|
||||
let landing = client.get(&origin).set("Cookie", cookie).call().unwrap();
|
||||
assert_eq!(landing.status(), 302);
|
||||
assert_eq!(landing.header("Location"), Some("/manager"));
|
||||
let result: Value = client
|
||||
.post(&format!("{origin}/api"))
|
||||
.set("Cookie", cookie)
|
||||
|
||||
+11
-1
@@ -119,7 +119,7 @@ fn root<'a>(v: &'a BTreeMap<String, Value>, key: &str) -> Option<&'a BTreeMap<St
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn discover(home: &Path) -> Vec<App> {
|
||||
pub(crate) fn library_roots(home: &Path) -> BTreeSet<PathBuf> {
|
||||
let steam = home.join(".local/share/Steam");
|
||||
let steam = if steam.exists() {
|
||||
steam
|
||||
@@ -146,6 +146,16 @@ pub fn discover(home: &Path) -> Vec<App> {
|
||||
}
|
||||
}
|
||||
}
|
||||
libraries
|
||||
}
|
||||
pub fn discover(home: &Path) -> Vec<App> {
|
||||
let steam = home.join(".local/share/Steam");
|
||||
let steam = if steam.exists() {
|
||||
steam
|
||||
} else {
|
||||
home.join(".steam/steam")
|
||||
};
|
||||
let libraries = library_roots(home);
|
||||
let mut apps = BTreeMap::new();
|
||||
for library in libraries {
|
||||
let Ok(entries) = fs::read_dir(library.join("steamapps")) else {
|
||||
|
||||
+25
-1
@@ -27,12 +27,15 @@ impl Uploads {
|
||||
}
|
||||
}
|
||||
pub fn start(&self, total: u64) -> Result<Value> {
|
||||
self.start_in(total, std::path::Path::new("/tmp"))
|
||||
}
|
||||
pub fn start_in(&self, total: u64, parent: &std::path::Path) -> Result<Value> {
|
||||
ensure!(total > 0, "Empty package");
|
||||
let mut slot = self.0.lock().unwrap();
|
||||
Self::expire(&mut slot);
|
||||
ensure!(slot.is_none(), "An upload is already running");
|
||||
let id = hex::encode(rand::random::<[u8; 24]>());
|
||||
let package = crate::package::Staged::create()?;
|
||||
let package = crate::package::Staged::create_in(parent)?;
|
||||
let file = OpenOptions::new().write(true).open(&package.path)?;
|
||||
*slot = Some(Upload {
|
||||
id: id.clone(),
|
||||
@@ -125,6 +128,27 @@ mod tests {
|
||||
assert!(uploads.take(&id).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn persistent_uploads_are_private_and_removed_on_cancel_or_drop() {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let uploads = Uploads::default();
|
||||
let started = uploads.start_in(3, parent.path()).unwrap();
|
||||
let id = started["upload"].as_str().unwrap();
|
||||
uploads.append(id, 0, b"apk").unwrap();
|
||||
let staged = uploads.take(id).unwrap();
|
||||
assert!(staged.path.starts_with(parent.path()));
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(&staged.path).unwrap().permissions().mode() & 0o777,
|
||||
0o400
|
||||
);
|
||||
let path = staged.path.clone();
|
||||
drop(staged);
|
||||
assert!(!path.exists());
|
||||
let started = uploads.start_in(1, parent.path()).unwrap();
|
||||
uploads.abort(started["upload"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(fs::read_dir(parent.path()).unwrap().count(), 0);
|
||||
}
|
||||
#[test]
|
||||
fn cancellation_expiry_and_chunk_bound() {
|
||||
let uploads = Uploads::default();
|
||||
let id = uploads.start((CHUNK + 1) as u64).unwrap()["upload"]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';import{createRoot}from'react-dom/client';
|
||||
import{ApkManager}from'../ui/src/apk-manager';import{configureLanguage,t}from'../ui/src/i18n';import{Brand,Icon}from'../ui/src/icons';import'../ui/src/style.css';
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unknown)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(-500));};
|
||||
const meta=(pkg:string,name:string,launch=true)=>({package:pkg,name,version:'1.2.3',versionCode:123,minSdk:24,activities:launch?[`${pkg}.Main`]:[],declaredActivities:[`${pkg}.Main`],abis:['arm64-v8a'],vr:false});
|
||||
const apps:any[]=[{id:'framely-wolvic/com.igalia.wolvic',context:'framely-wolvic',metadata:meta('com.igalia.wolvic','Wolvic'),installed:true,running:false,managed:true,size:128*1024*1024},{id:'dev/com.example.service',context:'dev',metadata:meta('com.example.service','Background service',false),installed:true,running:true,managed:false,size:20*1024*1024},{id:'old/com.example.notes',context:'old',metadata:meta('com.example.notes','Notes'),installed:false,running:false,managed:true,size:4*1024*1024}];
|
||||
const containers=[{acceptsAdditionalApps:false,id:'framely-wolvic',name:'framely-wolvic',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/framely-wolvic/baked'},{acceptsAdditionalApps:true,id:'dev',name:'dev',managed:false,running:true,baked:'/home/steamos/.local/share/lepton/contexts/dev/baked'},{id:'old',name:'old',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/old/baked'}];
|
||||
const calls:{method:string;params:any}[]=[];const jobs=new Map<string,any>();let counter=0,fail=false,cleanupItems:any[]=[{id:'data:old/com.example.notes',kind:'data',name:'Notes',bytes:4*1024*1024,sensitive:true,app:'old/com.example.notes'},{id:'backups:backup1',kind:'backups',name:'20261005-backup',bytes:128*1024*1024,sensitive:true,context:'framely-wolvic',restorable:true},{id:'unknown:dev/com.example.orphan',kind:'unknown',name:'com.example.orphan',bytes:20,sensitive:true,unknown:true}];
|
||||
window.fetch=async(url,init)=>{if(String(url).startsWith('/api/upload/')){const offset=+String(url).split('/').at(-1)!;return new Response(JSON.stringify({result:{received:offset+(init?.body as Blob).size}}));}const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
|
||||
if(method==='apk.upload.start')result={upload:'fixture-upload',chunkSize:512*1024};
|
||||
else if(method==='apk.cleanup.list')result=cleanupItems;
|
||||
else if(method==='apk.logs')result={name:'apk.log',text:'Installation complete\nAndroid package manager: Success'};
|
||||
else if(method==='apk.list.start'||method==='apk.inspect.start'||method==='apk.operation.start'){
|
||||
const id='job-'+counter++;let value:any=true;
|
||||
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true};
|
||||
else if(method==='apk.inspect.start')value={ticket:'review-ticket',metadata:meta('com.example.new','New APK'),bytes:4};
|
||||
else{const app=apps.find(a=>a.id===params.app);if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall')app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
jobs.set(id,{id,kind:'apk',phase:fail&&method==='apk.operation.start'?'failed':'done',result:value,error:'Fixture signature mismatch',received:0});result={job:id};
|
||||
}else if(method==='job.status')result=jobs.get(params.job);
|
||||
return new Response(JSON.stringify({result}));};
|
||||
const root=createRoot(document.getElementById('root')!);function render(){root.render(<div className="app manager"><header className="shell-header"><Brand/><span>Framely</span></header><div className="workspace"><nav>{[['installed','已安装'],['download','插件库'],['download','APK 管理'],['settings','设置']].map(([icon,key])=><button className={key==='APK 管理'?'active':''} key={key}><Icon name={icon as any}/>{t(key)}</button>)}</nav><main className="content"><ApkManager/></main></div></div>);}
|
||||
const button=(text:string,scope:ParentNode=document)=>Array.from(scope.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent?.trim()===text)??(()=>{throw Error('Missing button '+text)})();
|
||||
async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIEW_INSTALL_'+name);await wait(180);}
|
||||
(async()=>{
|
||||
configureLanguage('en-US',[]);render();await until(()=>document.querySelectorAll('.apk-row').length===2);const content=document.querySelector('.content')!.getBoundingClientRect(),manager=document.querySelector('.apk-manager')!.getBoundingClientRect();if(Math.abs(manager.width-(content.width-64))>2&&innerWidth>1000)throw Error('Manager width does not fill content');const search=document.querySelector<HTMLInputElement>('.apk-filters input')!.getBoundingClientRect(),filter=document.querySelector<HTMLButtonElement>('.apk-filters .framely-select>button')!.getBoundingClientRect();if(Math.abs(search.y-filter.y)>1||Math.abs(search.height-filter.height)>1||search.right>=filter.left)throw Error('APK search/filter controls are not aligned');await snapshot('APK_EN');
|
||||
// Container startup and shutdown are not data deletion operations.
|
||||
button('Containers').click();await until(()=>document.querySelectorAll('.apk-container').length===3);
|
||||
for(const title of ['Start container','Stop container']){button(title).click();await until(()=>!!document.querySelector('.apk-dialog'));if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Container operation displayed data deletion warning');if(!button('Confirm',document.querySelector('.apk-dialog')!).classList.contains('primary'))throw Error('Non-destructive container confirmation uses destructive styling');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));}
|
||||
const external=document.querySelectorAll('.apk-container')[1];button('Delete container',external).click();await until(()=>document.querySelector('.apk-dialog'));if(!document.querySelector('.apk-dialog')!.textContent!.includes(t('容器内全部数据将永久删除。')))throw Error('External deletion lacks data warning');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));if(calls.some(c=>c.params.operation==='container.delete'))throw Error('Cancelled deletion modified external container');
|
||||
button('Apps').click();await until(()=>document.querySelectorAll('.apk-row').length===2);
|
||||
button('Manage',document.querySelector('.apk-row')!).click();await until(()=>document.querySelector('.apk-dialog'));const advanced=document.querySelector<HTMLDetailsElement>('.apk-advanced')!;advanced.open=true;document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Handle window display automatically"]')!.click();await until(()=>calls.some(c=>c.method==='apk.operation.start'&&c.params.operation==='settings'));await until(()=>!document.querySelector('progress'));await until(()=>document.querySelector('.apk-dialog')&&!button('Uninstall app',document.querySelector('.apk-dialog')!).disabled);
|
||||
button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>document.querySelector('[role=switch][aria-label="Delete app data"]'));if(document.querySelector('[aria-label="Delete app data"]')!.getAttribute('aria-checked')!=='false')throw Error('Uninstall defaults to data deletion');if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Data-preserving uninstall warns of permanent data deletion');document.querySelector<HTMLButtonElement>('[aria-label="Delete app data"]')!.click();await until(()=>document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')));document.querySelector<HTMLButtonElement>('[aria-label="Delete app data"]')!.click();await until(()=>!document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')));button('Confirm',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='uninstall'));await until(()=>!document.querySelector('progress'));if(calls.find(c=>c.params.operation==='uninstall')!.params.purge)throw Error('Uninstall purged by default');await until(()=>Array.from(document.querySelectorAll('.apk-dialog button')).some(b=>b.textContent==='Done'));button('Done',document.querySelector('.apk-dialog')!).click();
|
||||
button('Data cleanup').click();await until(()=>document.querySelectorAll('.apk-cleanup-row').length===3);if(document.querySelector('[role=switch][aria-checked=true]'))throw Error('Sensitive data selected by default');if(!document.querySelector<HTMLButtonElement>('[role=switch][aria-label*="com.example.orphan"]')!.disabled)throw Error('Unknown data can be deleted');await snapshot('APK_CLEANUP_EN');
|
||||
document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Select Notes"]')!.click();await until(()=>!button('Clean selected').disabled);button('Clean selected').click();await until(()=>document.querySelector('.apk-dialog'));button('Cancel',document.querySelector('.apk-dialog')!).click();if(calls.some(c=>c.params.operation==='cleanup'))throw Error('Cancel performed cleanup');
|
||||
apps[0].installed=true;await wait();configureLanguage('zh-CN',[]);console.log('APK_LOCALE '+t('APK 管理'));root.render(<div/>);await wait();configureLanguage('zh-CN',[]);render();await until(()=>document.querySelector('.apk-manager h1')?.textContent==='APK 管理');await until(()=>!document.querySelector('.empty'));await snapshot('APK_ZH');button('数据清理').click();await until(()=>document.querySelectorAll('.apk-cleanup-row').length===3);await snapshot('APK_CLEANUP_ZH');button('应用').click();
|
||||
const file=new File(['test'],'sample.apk',{type:'application/vnd.android.package-archive'}),transfer=new DataTransfer();transfer.items.add(file);const input=document.querySelector<HTMLInputElement>('input[type=file]')!;input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));await until(()=>document.querySelector('[aria-label="安装 APK"] .apk-meta'));if(calls.find(c=>c.method==='apk.upload.start')?.params.size!==4)throw Error('APK upload transport mismatch');await snapshot('APK_REVIEW_ZH');document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="目标容器"]')!.click();await until(()=>document.querySelector('[role=listbox]'));const targets=[...document.querySelectorAll('[role=option]')].map(b=>b.textContent);if(!targets.includes('dev')||targets.includes('framely-wolvic'))throw Error('Install targets ignore backend shared-mount policy');document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="目标容器"]')!.click();button('取消',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.method==='apk.review.drop'));if(calls.some(c=>c.params.operation==='install'))throw Error('Cancelled review installed APK');
|
||||
configureLanguage('en-US',[]);apps.push({id:'unknown/com.example.unknown',context:'unknown',metadata:meta('com.example.unknown','Unknown app'),installed:false,stateKnown:false,managed:false,size:10,pending:'Container unavailable; data has not been deleted'},{id:'steam/com.example.steam',context:'steam',metadata:meta('com.example.steam','Steam app'),installed:true,steam:true,size:10});root.render(<div/>);await wait();render();await until(()=>[...document.querySelectorAll('.apk-row')].some(r=>r.textContent?.includes('Unknown app')));if([...document.querySelectorAll('.apk-row')].some(r=>r.textContent?.includes('Steam app')))throw Error('Steam app exposed in side-loading app list');const unknown=[...document.querySelectorAll('.apk-row')].find(r=>r.textContent?.includes('Unknown app'))!;if(!unknown.textContent?.includes(t('安装状态未知'))||[...unknown.querySelectorAll('button')].some(b=>b.textContent==='Open'))throw Error('Unknown state presented as launchable or uninstalled');button('Manage',unknown).click();await until(()=>document.querySelector('.apk-dialog'));if([...document.querySelectorAll('.apk-dialog button')].some(b=>b.textContent==='Reinstall APK'))throw Error('Unknown app offers reinstallation');if(!button(t('清除保留数据'),document.querySelector('.apk-dialog')!).disabled)throw Error('Unknown app can delete retained data');button('Done',document.querySelector('.apk-dialog')!).click();
|
||||
console.log('FRAMELY_APK_MANAGER_PASS');console.log('FRAMELY_BRIDGE_PASS');
|
||||
})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e));
|
||||
@@ -1,3 +1,5 @@
|
||||
#include "../native/page_scroll_injection.h"
|
||||
#include "include/capi/cef_load_handler_capi.h"
|
||||
#include "../native/paint_buffer.h"
|
||||
#include "../native/launcher_pointer.h"
|
||||
// Integration test only: render the real manager and exercise its iframe bridge.
|
||||
@@ -22,6 +24,7 @@ static Handler<cef_client_t> client;
|
||||
static Handler<cef_render_handler_t> render;
|
||||
static Handler<cef_display_handler_t> display;
|
||||
static Handler<cef_life_span_handler_t> life;
|
||||
static Handler<cef_load_handler_t> load;
|
||||
static std::vector<unsigned char> pixels;static int pixel_width=0,pixel_height=0;
|
||||
static void snapshot(const std::string& name){const char* dir=std::getenv("FRAMELY_PREVIEW_DIR");if(!dir||pixels.empty())return;std::ofstream out(std::string(dir)+"/"+name+".ppm",std::ios::binary);out<<"P6\n"<<pixel_width<<" "<<pixel_height<<"\n255\n";for(size_t i=0;i<pixels.size();i+=4){unsigned char rgb[]={pixels[i+2],pixels[i+1],pixels[i]};out.write(reinterpret_cast<char*>(rgb),3);}}
|
||||
static bool install_review_mode=std::getenv("FRAMELY_INSTALL_REVIEW_TEST")!=nullptr;
|
||||
@@ -32,6 +35,9 @@ static bool ready=false,passed=false,failed=false,closed=false;static int paints
|
||||
static void str(cef_string_t& s,const std::string& v){cef_string_utf8_to_utf16(v.data(),v.size(),&s);}
|
||||
static std::string text(const cef_string_t* s){cef_string_utf8_t o{};cef_string_utf16_to_utf8(s->str,s->length,&o);std::string r(o.str,o.length);cef_string_utf8_clear(&o);return r;}
|
||||
int main(int argc,char** argv){
|
||||
load.api.on_load_end=[](cef_load_handler_t*,cef_browser_t*,cef_frame_t* frame,int){inject_page_scroll(frame);};
|
||||
client.api.get_load_handler=[](cef_client_t*){return load.acquire();};
|
||||
|
||||
std::cout.setf(std::ios::unitbuf);std::string url=argc>1?argv[1]:"",runtime=argc>2?argv[2]:"",cef=std::getenv("FRAMELY_CEF_ROOT")?std::getenv("FRAMELY_CEF_ROOT"):"";
|
||||
cef_main_args_t args{argc,argv};if(std::strcmp(cef_api_hash(CEF_API_VERSION,0),CEF_API_HASH_PLATFORM))return 2;int child=cef_execute_process(&args,nullptr,nullptr);if(child>=0)return child;if(url.empty()||runtime.empty()||cef.empty())return 2;
|
||||
cef_settings_t settings{};settings.size=sizeof(settings);settings.windowless_rendering_enabled=1;str(settings.root_cache_path,runtime+"/cache");str(settings.log_file,runtime+"/cef.log");str(settings.resources_dir_path,cef+"/Resources");str(settings.locales_dir_path,cef+"/Resources/locales");
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {FrameFilePicker} from '../ui/src/file-picker';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<100;i++){if(f())return;await wait();}throw Error('Timed out '+document.body.innerText);};
|
||||
const calls:{path:string;params:any}[]=[];
|
||||
const source=[{name:'Nested',path:'/home/steamos/Downloads/Nested',directory:true,size:0},{name:'Wolvic.apk',path:'/home/steamos/Downloads/Wolvic.apk',directory:false,size:157426910,modified:1791200000},{name:'language.json',path:'/home/steamos/Downloads/language.json',directory:false,size:100}];
|
||||
window.fetch=async(url,init)=>{const path=String(url),params=JSON.parse(String(init?.body));calls.push({path,params});if(path.endsWith('/complete'))return new Response(JSON.stringify({result:true}));if(!path.endsWith('/list'))throw Error('Unexpected request');await wait(params.query==='slow'?250:20);if(params.path==='/denied')return new Response(JSON.stringify({error:'无法读取目录'}));const directory=params.path||'/home/steamos/Downloads';let entries=directory==='/home/steamos'?[{name:'Downloads',path:'/home/steamos/Downloads',directory:true,size:0},{name:'Documents',path:'/home/steamos/Documents',directory:true,size:0}]:directory.endsWith('/Nested')||directory.endsWith('/Documents')?[]:source.filter(e=>e.directory||(!(params.extensions??[]).length||(params.extensions??[]).some((s:string)=>e.name.endsWith(s))));if(params.directoryOnly)entries=entries.filter(e=>e.directory);entries=entries.filter(e=>e.name.toLowerCase().includes((params.query??'').toLowerCase()));return new Response(JSON.stringify({result:{path:directory,parent:'/home/steamos',roots:[{name:'下载',path:'/home/steamos/Downloads'},{name:'主目录',path:'/home/steamos'},{name:'临时目录',path:'/tmp'}],entries,total:entries.length,offset:0,skipped:0}}));};
|
||||
const root=createRoot(document.getElementById('root')!);root.render(<><div className="app manager"><button>Underlying page</button></div><FrameFilePicker/></>);
|
||||
const open=(id:string,multiple=false,accept=['.apk'])=>window.dispatchEvent(new CustomEvent('framely.fileDialog',{detail:{id,multiple,directory:false,accept}}));
|
||||
const button=(name:string)=>[...document.querySelectorAll<HTMLButtonElement>('.frame-files button')].find(b=>b.textContent?.trim()===name)!;
|
||||
const row=(name:string)=>[...document.querySelectorAll<HTMLButtonElement>('.frame-file-row')].find(b=>b.textContent?.includes(name))!;
|
||||
function change(selector:string,value:string){const input=document.querySelector<HTMLInputElement>(selector)!;Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(input,value);input.dispatchEvent(new Event('input',{bubbles:true}));}
|
||||
(async()=>{try{
|
||||
configureLanguage('en-US',[]);await wait();if(document.querySelector('.frame-files'))throw Error('External browser automatically opened custom picker');open('apk');await until(()=>row('Wolvic.apk'));await until(()=>document.querySelector('[role=treeitem][aria-label=Downloads]'));const treeDownloads=document.querySelector<HTMLElement>('[role=treeitem][aria-label=Downloads]')!;if(treeDownloads.getAttribute('aria-selected')!=='true'||!document.querySelector('[role=treeitem][aria-label=Home] [role=group]'))throw Error('Current folder ancestors are not expanded');document.querySelector<HTMLButtonElement>('[aria-label="Collapse Home"]')!.click();await until(()=>!document.querySelector('[role=treeitem][aria-label=Downloads]'));document.querySelector<HTMLButtonElement>('[aria-label="Expand Home"]')!.click();await until(()=>document.querySelector('[role=treeitem][aria-label=Downloads]'));if(!document.getElementById('root')!.inert||row('language.json'))throw Error('Background or type filter incorrect');if(button('Select').disabled!==true)throw Error('Can select without a file');row('Wolvic.apk').click();await until(()=>!button('Select').disabled);button('Select').click();await until(()=>!document.querySelector('.frame-files'));if(calls.at(-1)?.params.paths[0]!=='/home/steamos/Downloads/Wolvic.apk'||document.getElementById('root')!.inert)throw Error('File selection not completed');
|
||||
open('multi',true,['.apk','.json']);await until(()=>row('language.json'));row('language.json').click();await wait();row('Wolvic.apk').click();await until(()=>document.querySelectorAll('.frame-file-row.selected').length===2);button('Select').click();await until(()=>!document.querySelector('.frame-files'));if(calls.at(-1)?.params.paths.length!==2)throw Error('Multiple selection lost');
|
||||
open('cancel');await until(()=>row('Wolvic.apk'));row('Nested').click();await until(()=>document.querySelector('.frame-files-list .empty'));document.querySelector<HTMLButtonElement>('[aria-label="Previous folder"]')!.click();await until(()=>row('Wolvic.apk'));let backgroundBack=false;const behind=()=>backgroundBack=true;window.addEventListener('framely.back',behind);window.dispatchEvent(new Event('framely.back'));await until(()=>!document.querySelector('.frame-files'));window.removeEventListener('framely.back',behind);if(backgroundBack||calls.at(-1)?.params.paths.length)throw Error('Back escaped picker or failed to cancel');
|
||||
open('errors');await until(()=>row('Wolvic.apk'));document.querySelector<HTMLButtonElement>('[aria-label="View options"]')!.click();await until(()=>document.querySelector('[aria-label="Supported files only"]'));document.querySelector<HTMLButtonElement>('[aria-label="Supported files only"]')!.click();await until(()=>row('language.json'));if(!row('language.json').disabled)throw Error('Unsupported file can be selected');document.querySelector<HTMLButtonElement>('[aria-label="Supported files only"]')!.click();await until(()=>!row('language.json'));document.querySelector<HTMLButtonElement>('[aria-label="View options"]')!.click();change('[aria-label="Folder path"]','/denied');button('Go').click();await until(()=>document.querySelector('[role=alert]'));if(!button('Select').disabled)throw Error('Stale directory remains selectable');button('Downloads').click();await until(()=>row('Wolvic.apk'));change('[aria-label="Search this folder"]','slow');await wait(220);change('[aria-label="Search this folder"]','wolvic');await until(()=>row('Wolvic.apk')&&!row('Nested'));await wait(300);if(!row('Wolvic.apk'))throw Error('Stale search response won');change('[aria-label="Search this folder"]','');await until(()=>row('Nested'));row('Wolvic.apk').click();await until(()=>document.querySelector('.frame-file-row.selected'));await wait(400);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PICKER_EN');await wait(650);button('Cancel').click();await until(()=>!document.querySelector('.frame-files'));
|
||||
configureLanguage('zh-CN',[]);open('zh');await until(()=>row('Wolvic.apk'));row('Wolvic.apk').click();await until(()=>document.querySelector('.frame-file-row.selected'));await wait(400);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PICKER_ZH');await wait(650);button('取消').click();await until(()=>!document.querySelector('.frame-files'));console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.log('FRAMELY_BRIDGE_FAIL '+String(e));}})();
|
||||
@@ -0,0 +1,10 @@
|
||||
#include "launcher_fade.h"
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
int main(){
|
||||
using namespace std::chrono;auto now=steady_clock::now();LauncherFade fade;
|
||||
fade.open(now);assert(fade.value(now)==0);assert(std::abs(fade.value(now+milliseconds(290))-.5f)<.001f);assert(fade.value(now+milliseconds(580))==1);
|
||||
// Closing during entry is continuous; reopening starts a new entry.
|
||||
auto interrupted=now+milliseconds(290);fade.close(interrupted);assert(std::abs(fade.value(interrupted)-.5f)<.001f);assert(std::abs(fade.value(interrupted+milliseconds(220))-.25f)<.001f);assert(fade.value(interrupted+milliseconds(440))==0);
|
||||
fade.open(interrupted);assert(fade.value(interrupted)==0);fade.reduced=true;assert(fade.value(interrupted)==1);fade.close(interrupted);assert(fade.value(interrupted)==0);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ function Fixture(){const[,render]=useState(0);redraw=()=>render(n=>n+1);return s
|
||||
function button(text:string){return [...document.querySelectorAll<HTMLButtonElement>('button')].find(b=>b.textContent===text)!;}
|
||||
function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Termix]')!;b.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));}
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
configureLanguage('en-US',[]);redraw();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
configureLanguage('en-US',[]);redraw();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10);await wait(640);
|
||||
if(document.body.textContent?.includes('Disabled app'))throw Error('Disabled plugin displayed');
|
||||
if(document.querySelector('.launcher-header')||!document.querySelector('.launcher-search input'))throw Error('Launcher contains panel controls');
|
||||
if(getComputedStyle(document.querySelector('.launcher-shell')!).backgroundColor!=='rgba(0, 0, 0, 0)')throw Error('Launcher is not transparent');
|
||||
@@ -30,19 +30,19 @@ function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-i
|
||||
const discs=[...document.querySelectorAll<HTMLElement>('.launch-disc')].map(n=>n.getBoundingClientRect());const middle=discs.filter(r=>Math.abs(r.y-discs[4].y)<1);const betweenIcons=middle[1].left-middle[0].right,gapToCategories=middle[0].left-categoryRight;const gapToShortcuts=document.querySelector('.launcher-shortcuts')!.getBoundingClientRect().left-middle[middle.length-1].right;if(betweenIcons<106||gapToCategories<132||gapToShortcuts<132||gapToCategories<=betweenIcons||gapToShortcuts<=betweenIcons)throw Error('Both capsule gaps must exceed the wider horizontal artwork gap '+JSON.stringify({betweenIcons,gapToCategories,gapToShortcuts}));
|
||||
const firstSlotY=document.querySelector<HTMLElement>('.launch-item')!.offsetTop;
|
||||
const sameLayout=()=>{const g=document.querySelector('.launcher-grid')!.getBoundingClientRect(),f=filters.getBoundingClientRect();if(g.width!==gridBefore.width||g.height!==gridBefore.height||g.x!==gridBefore.x||g.y!==gridBefore.y||f.x!==filterBefore.x||f.y!==filterBefore.y)throw Error('App count moved the fixed grid or categories '+JSON.stringify({before:gridBefore.toJSON(),after:g.toJSON(),filterBefore:filterBefore.toJSON(),filterAfter:f.toJSON()}));};
|
||||
const assertRows=(expected:number[])=>{const groups=new Map<string,DOMRect[]>();for(const item of document.querySelectorAll<HTMLElement>('.launch-item')){const r=item.querySelector('.launch-icon')!.getBoundingClientRect();if(item.style.gridRow.startsWith('1 /')&&Math.abs(item.offsetTop-firstSlotY)>1)throw Error('Partial rows moved the first icon row');const key=item.style.gridRow;groups.set(key,[...(groups.get(key)??[]),r]);}const rows=[...groups.values()];if(rows.map(row=>row.length).join(',')!==expected.join(','))throw Error('Unexpected row counts '+rows.map(row=>row.length));const center=(r:DOMRect)=>r.x+r.width/2;if(rows.length===1){const mean=rows[0].reduce((sum,r)=>sum+center(r),0)/rows[0].length;if(Math.abs(mean-gridBefore.x-gridBefore.width/2)>1)throw Error('Single row is not centered');}else{const step=center(rows[0][1])-center(rows[0][0]);if(Math.abs(center(rows[1][0])-center(rows[0][0])+step/2)>1)throw Error('Second row did not start half a slot left');if(rows[2]&&Math.abs(center(rows[2][0])-center(rows[0][0]))>1)throw Error('Third row is not aligned with the first');}};
|
||||
const assertRows=(expected:number[])=>{const groups=new Map<string,DOMRect[]>();for(const item of document.querySelectorAll<HTMLElement>('.launch-item')){const r=item.querySelector('.launch-icon')!.getBoundingClientRect();if(item.style.gridRow.startsWith('1 /')&&Math.abs(item.offsetTop-firstSlotY)>1)throw Error('Partial rows moved the first icon row');const key=item.style.gridRow;groups.set(key,[...(groups.get(key)??[]),r]);}const rows=[...groups.values()];if(rows.map(row=>row.length).join(',')!==expected.join(','))throw Error('Unexpected row counts '+rows.map(row=>row.length));const center=(r:DOMRect)=>r.x+r.width/2;if(rows.length===1){if(![...groups.keys()][0].startsWith('3 /'))throw Error('Single row is not in the middle');const mean=rows[0].reduce((sum,r)=>sum+center(r),0)/rows[0].length;if(Math.abs(mean-gridBefore.x-gridBefore.width/2)>1)throw Error('Single row is not centered');}else{const step=center(rows[1][1])-center(rows[1][0]);if(Math.abs(center(rows[1][0])-center(rows[0][0])+step/2)>1)throw Error('Second row did not start half a slot left');if(rows[2]&&Math.abs(center(rows[2][0])-center(rows[0][0]))>1)throw Error('Third row is not aligned with the first');}};
|
||||
assertRows([4,5,1]);
|
||||
plugins['demo.ten']={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:'demo.ten',name:'Tenth app'}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===11);sameLayout();assertRows([4,5,2]);await wait(300);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_TEN_EN');delete plugins['demo.ten'];redraw();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
for(let n=0;n<3;n++)plugins[`demo.extra${n}`]={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:`demo.extra${n}`,name:`Extra ${n}`}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===13);sameLayout();assertRows([4,5,4]);for(let n=3;n<7;n++)plugins[`demo.extra${n}`]={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:`demo.extra${n}`,name:`Extra ${n}`}};redraw();await until(()=>document.querySelectorAll('.launcher-pages button').length===2);sameLayout();if(document.querySelectorAll('.launch-icon').length!==13)throw Error('AVP page capacity changed');document.querySelectorAll<HTMLButtonElement>('.launcher-pages button')[1].click();await until(()=>document.querySelectorAll('.launch-icon').length===4);sameLayout();document.querySelectorAll<HTMLButtonElement>('.launcher-pages button')[0].click();await until(()=>document.querySelectorAll('.launch-icon').length===13);if(!document.querySelector('.launcher-page')!.getAnimations().length)throw Error('Page change has no animation');await wait(340);for(let n=0;n<7;n++)delete plugins[`demo.extra${n}`];redraw();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
if(Math.abs(document.querySelector('.launch-disc')!.getBoundingClientRect().width-96)>.1)throw Error('Launcher artwork is too large');
|
||||
document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=Plugin]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===3);sameLayout();if(!document.querySelector('.launcher-page')!.getAnimations().length)throw Error('Category change has no animation');const categoryMotion=document.querySelector('.launcher-page')!.getAnimations()[0].effect as KeyframeEffect;if(!String(categoryMotion.getKeyframes()[0].translate).includes('110px'))throw Error('Category motion is too subtle');const activeSurface=document.querySelector<HTMLElement>('.launcher-category-active')!;await wait(60);const surfaceY=activeSurface.getBoundingClientRect().top,selectedY=document.querySelector('.launcher-filters button.active')!.getBoundingClientRect().top;if(surfaceY<=filterBefore.top+7||surfaceY>=selectedY)throw Error('Category active surface did not slide through intermediate positions');await until(()=>Math.abs(activeSurface.getBoundingClientRect().top-selectedY)<=1);
|
||||
const arrangements=[[1],[2],[3],[4],[4,1],[4,2],[4,3],[4,4],[4,5],[4,5,1],[4,5,2],[4,5,3],[4,5,4]];
|
||||
const arrangements=[[1],[2],[3],[4],[5],[1,5],[2,5],[3,5],[4,5],[4,5,1],[4,5,2],[4,5,3],[4,5,4]];
|
||||
plugins['demo.alpha'].enabled=false;plugins['demo.beta'].enabled=false;
|
||||
redraw();await until(()=>document.querySelectorAll('.launch-icon').length===1);sameLayout();assertRows([1]);
|
||||
for(let n=1;n<13;n++){plugins[`layout.${n}`]={...plugins['demo.beta'],enabled:true,manifest:{...plugins['demo.beta'].manifest,id:`layout.${n}`,name:`Layout ${String(n).padStart(2,'0')}`}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===n+1);sameLayout();assertRows(arrangements[n]);}
|
||||
for(let n=1;n<=13;n++)delete plugins[`layout.${n}`];plugins['demo.alpha'].enabled=true;plugins['demo.beta'].enabled=true;redraw();await until(()=>document.querySelectorAll('.launch-icon').length===3);
|
||||
document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
const category=document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=Steam]')!;const categoryRect=category.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(categoryRect.x+categoryRect.width/2)}_${Math.round(categoryRect.y+categoryRect.height/2)}_28_15_150`);await until(()=>document.querySelectorAll('.launch-icon').length===7);sameLayout();assertRows([4,3]);document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
const category=document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=Steam]')!;const categoryRect=category.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(categoryRect.x+categoryRect.width/2)}_${Math.round(categoryRect.y+categoryRect.height/2)}_28_15_150`);await until(()=>document.querySelectorAll('.launch-icon').length===7);sameLayout();assertRows([2,5]);document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10);
|
||||
const assertControlsPainted=()=>{for(const node of document.querySelectorAll('.launcher-filters button svg,.launcher-pages button span')){const r=node.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_REGION_${Math.floor(r.x)}_${Math.floor(r.y)}_${Math.ceil(r.width)}_${Math.ceil(r.height)}`);}};
|
||||
await wait(500);assertControlsPainted();for(const label of document.querySelectorAll('.launch-name')){const r=label.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_INK_${Math.floor(r.x)}_${Math.floor(r.y)}_${Math.ceil(r.width)}_${Math.ceil(r.height)}`);}console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_EN');
|
||||
const icon=document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Termix]')!;
|
||||
@@ -72,13 +72,15 @@ function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-i
|
||||
await until(()=>!!document.querySelector('[role=menu]'));await until(()=>document.querySelector('.launcher-editing'));if(document.querySelector('[role=menu]'))throw Error('800 ms left the menu open');
|
||||
console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(last.x+last.width/2)}_${Math.round(last.y+last.height/2)}`);await until(()=>calls.some(c=>c.method==='launcher.order.save'));
|
||||
await until(()=>!document.querySelector('.launcher-drag-ghost'));const reordered=[...document.querySelectorAll<HTMLElement>('.launch-item')].map(n=>n.dataset.launchKey!);if(reordered[3]!==orderedBefore[0]||order[3]!==orderedBefore[0])throw Error('Drag did not insert and persist order');if(calls.filter(c=>c.method==='plugin.launch'||c.method==='steam.launch').length!==launchesBefore)throw Error('Arrange gesture launched an app');const doneStyle=getComputedStyle(button('Done')),railStyle=getComputedStyle(document.querySelector('.launcher-tools')!);if(doneStyle.backgroundColor!==railStyle.backgroundColor||doneStyle.borderTopColor!==railStyle.borderTopColor||doneStyle.boxShadow!==railStyle.boxShadow)throw Error('Done button does not match category rail material');console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_EDIT_EN');await until(()=>!button('Done').disabled);button('Done').click();await until(()=>!document.querySelector('.launcher-editing'));await wait(100);
|
||||
const selfIcon=document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Framely]')!;selfIcon.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await wait(80);button('Arrange icons').click();await until(()=>!!document.querySelector('.launcher-editing'));const closeCount=calls.filter(c=>c.method==='host.launcher.close').length;window.dispatchEvent(new Event('framely.back'));await until(()=>!document.querySelector('.launcher-editing'));if(calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('B closed the launcher instead of exiting arrangement');selfIcon.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await wait(80);button('Arrange icons').click();await until(()=>!!document.querySelector('.launcher-editing'));document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));await until(()=>!document.querySelector('.launcher-editing'));if(calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('Blank click closed launcher during arrangement');
|
||||
const selfIcon=document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Framely]')!;selfIcon.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await wait(80);button('Arrange icons').click();await until(()=>!!document.querySelector('.launcher-editing'));const closeCount=calls.filter(c=>c.method==='host.launcher.close').length;window.dispatchEvent(new Event('framely.back'));await until(()=>!document.querySelector('.launcher-editing'));if(calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('B closed the launcher instead of exiting arrangement');selfIcon.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await wait(80);button('Arrange icons').click();await until(()=>!!document.querySelector('.launcher-editing'));document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));await wait(100);if(!document.querySelector('.launcher-editing')||calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('Inside blank click exited arrangement');window.dispatchEvent(new Event('framely.back'));await until(()=>!document.querySelector('.launcher-editing'));
|
||||
// Add enough icons for edge paging and swipe navigation.
|
||||
for(let n=0;n<9;n++)plugins[`edge.${n}`]={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:`edge.${n}`,name:`Edge ${n}`}};redraw();await until(()=>document.querySelectorAll('.launcher-pages button').length===2);
|
||||
const arrangeIcon=document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Framely]')!;arrangeIcon.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await wait(80);button('Arrange icons').click();await until(()=>!!document.querySelector('.launcher-editing'));
|
||||
console.log('FRAMELY_LAUNCHER_PRESS_700_90_-180_0_550');await until(()=>!!document.querySelector('.launcher-adjacent-page'));await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');if(!document.querySelector('.launcher-editing'))throw Error('Swipe exited arrangement');await until(()=>!document.querySelector('.launcher-panning'));document.querySelectorAll<HTMLButtonElement>('.launcher-pages button')[0].click();await until(()=>document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')==='page');if(!document.querySelector('.launcher-editing'))throw Error('Page button exited arrangement');button('Done').click();await until(()=>!document.querySelector('.launcher-editing'));await wait(350);
|
||||
console.log('FRAMELY_LAUNCHER_PRESS_700_90_-180_0_550');await until(()=>!!document.querySelector('.launcher-adjacent-page'));const activePage=document.querySelector<HTMLElement>('.launcher-page')!,adjacent=document.querySelector<HTMLElement>('.launcher-adjacent-page')!;if(!document.querySelector('.launcher-grid')!.classList.contains('launcher-panning')||!getComputedStyle(activePage).translate.startsWith('-180px'))throw Error('Canvas did not follow the held trigger');if(getComputedStyle(adjacent).translate==='none')throw Error('Neighbor did not slide alongside canvas');if(document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')!=='page')throw Error('Swipe committed before release');await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');
|
||||
window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-1}));await until(()=>document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')==='page');
|
||||
await wait(340);const edgeFirst=document.querySelector<HTMLButtonElement>('.launch-icon')!.getBoundingClientRect();const saves=calls.filter(c=>c.method==='launcher.order.save').length;
|
||||
console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(edgeFirst.x+edgeFirst.width/2)}_${Math.round(edgeFirst.y+edgeFirst.height/2)}_0_0_2600`);await until(()=>document.querySelector('.launcher-editing'));const edgeBox=document.querySelector('.launcher-grid')!.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(edgeBox.right-25)}_400`);await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');console.log('FRAMELY_EDGE_PAGE_OK');await wait(340);const leftEdge=Math.max(edgeBox.left,categoryRight+24)+25;if(leftEdge<=categoryRight)throw Error('Left drag edge overlaps category rail');console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(leftEdge)}_400`);await until(()=>!!document.querySelector('.launcher-drag-edge.left'));const indicator=document.querySelector('.launcher-drag-edge.left')!.getBoundingClientRect();if(indicator.left<=categoryRight)throw Error('Left edge feedback is behind the category rail');await until(()=>calls.filter(c=>c.method==='launcher.order.save').length>saves);console.log('FRAMELY_EDGE_SAVE_OK');await until(()=>!button('Done').disabled);button('Done').click();await until(()=>!document.querySelector('.launcher-editing'));
|
||||
console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(edgeFirst.x+edgeFirst.width/2)}_${Math.round(edgeFirst.y+edgeFirst.height/2)}_0_0_4800`);await until(()=>document.querySelector('.launcher-editing'));const dragFilter=document.querySelector('.launcher-filters button.active')!.getAttribute('aria-label');window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:2}));await wait(80);if(document.querySelector('.launcher-filters button.active')!.getAttribute('aria-label')!==dragFilter)throw Error('Vertical stick changed drag category');window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:1}));await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');if(!document.querySelector('.launcher-drag-ghost')||!document.querySelector('.launcher-editing'))throw Error('Stick paging cancelled drag');window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-1}));await until(()=>document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')==='page');const edgeBox=document.querySelector('.launcher-grid')!.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(edgeBox.right+100)}_400`);await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');console.log('FRAMELY_EDGE_PAGE_OK');const shade=document.querySelector('.launcher-drag-edge.right')!;if(shade.querySelector('svg')||getComputedStyle(shade).backgroundColor!=='rgba(0, 0, 0, 0)'||!getComputedStyle(shade,'::before').backgroundImage.includes('radial-gradient'))throw Error('Paging edge did not use feathered shading');console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_EDGE_SHADE_EN');const rightIndicator=document.querySelector('.launcher-drag-edge.right')!.getBoundingClientRect(),rightRail=document.querySelector('.launcher-shortcuts')!.getBoundingClientRect(),rightGhost=document.querySelector('.launcher-drag-ghost')!.getBoundingClientRect();if(rightIndicator.right>=rightRail.left||rightGhost.right>rightIndicator.left+4.5)throw Error('Right boundary overlaps shortcut rail or dragged icon crossed boundary');if(!document.querySelector('.launcher-editing'))throw Error('Edge paging exited arrangement');await wait(340);const leftEdge=categoryRight-40;console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(leftEdge)}_400`);await until(()=>!!document.querySelector('.launcher-drag-edge.left.active'));const indicator=document.querySelector('.launcher-drag-edge.left')!.getBoundingClientRect();if(indicator.left<=categoryRight||document.querySelector('.launcher-drag-ghost')!.getBoundingClientRect().left<indicator.right-4.5)throw Error('Left boundary overlaps rail or dragged icon crossed boundary');await until(()=>document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')==='page');await until(()=>calls.filter(c=>c.method==='launcher.order.save').length>saves);console.log('FRAMELY_EDGE_SAVE_OK');await until(()=>!button('Done').disabled);button('Done').click();await until(()=>!document.querySelector('.launcher-editing'));
|
||||
for(let n=0;n<9;n++)delete plugins[`edge.${n}`];redraw();window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-1}));await wait(100);
|
||||
console.log('FRAMELY_EDGE_DONE_OK');
|
||||
// Framely and new app kinds use their own safe launch APIs.
|
||||
@@ -101,7 +103,7 @@ function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-i
|
||||
releaseStalled=undefined;stalledMethod='desktop.launch';document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label="Desktop apps"]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector<HTMLButtonElement>('.launch-icon')!.click();await until(()=>!!releaseStalled);await until(()=>!document.querySelector<HTMLButtonElement>('.launch-icon')!.disabled);
|
||||
stalledMethod='';releaseStalled!(new Response(JSON.stringify({result:true})));window.setTimeout=realTimeout;await wait(100);
|
||||
console.log('FRAMELY_LAUNCHER_TIMEOUT_RECOVERY_OK');
|
||||
const closes=calls.filter(c=>c.method==='host.launcher.close').length;document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));window.dispatchEvent(new Event('framely.back'));await until(()=>calls.filter(c=>c.method==='host.launcher.close').length===closes+2);
|
||||
const closes=calls.filter(c=>c.method==='host.launcher.close').length;document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));window.dispatchEvent(new Event('framely.back'));await until(()=>calls.filter(c=>c.method==='host.launcher.close').length===closes+1);window.dispatchEvent(new Event('framely.launcher.closing'));await wait(80);const closingAnimation=document.querySelector('.launcher-shell')!.getAnimations()[0];if(!closingAnimation||Number((closingAnimation.effect as KeyframeEffect).getKeyframes().at(-1)?.opacity)!==0)throw Error('Closing animation missing');window.dispatchEvent(new Event('framely.launcher.open'));await wait(500);
|
||||
settings=true;redraw();await wait();const switches=document.querySelectorAll<HTMLInputElement>('[role=switch]');switches[0].click();await wait();if(!config.primaryTrigger)throw Error('Trigger setting not saved');await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');configureLanguage('en-US',[]);redraw();await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
|
||||
createRoot(document.getElementById('root')!).render(<Fixture/>);wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
|
||||
@@ -44,4 +44,10 @@ vr::RenderModel_ComponentState_t joystick_rest{},joystick_x{},joystick_y{},joyst
|
||||
assert(!dock.holds(now+std::chrono::milliseconds(3250)));
|
||||
dock.clear();assert(!dock.holds(now+std::chrono::seconds(3)));
|
||||
|
||||
// A Dock depth gesture consumes stick input; a still-deflected stick must
|
||||
// not navigate after the guard expires until it has returned to neutral.
|
||||
LauncherStick depthStick;assert(!depthStick.update(0,0,true));
|
||||
assert(!depthStick.update(0,.9f,false));assert(!depthStick.update(0,.9f,true));
|
||||
assert(!depthStick.update(0,0,true));assert(depthStick.update(0,.9f,true)==2);
|
||||
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const favorite=(name:string)=>document.querySelector<HTMLButtonElement>(`.launch
|
||||
configureLanguage('en-US',[]);redraw();await until(()=>!document.querySelector('.launcher-initializing'));
|
||||
if(category('Favorites').getAttribute('aria-pressed')!=='true'||labels().sort().join('|')!=='Half-Life: Alyx|Termix')throw Error('Default favorites / disabled filtering incorrect '+labels().join('|'));
|
||||
const keys=[...document.querySelectorAll('.launcher-filters button')].map(n=>n.getAttribute('aria-label'));if(keys.join('|')!=='Favorites|All|Plugin|Steam|Lepton|Desktop apps')throw Error('Category order '+keys);
|
||||
category('All').click();await until(()=>labels().length===7);for(const [label,page] of [['Plugin library','catalog'],['Settings','settings']]){document.querySelector<HTMLButtonElement>(`.launcher-shortcuts button[aria-label="${label}"]`)!.click();await until(()=>calls.some(c=>c.method==='host.manager.open'&&c.params.page===page));await until(()=>!document.querySelector('.launcher-shortcuts button:disabled'));}const initialGrid=document.querySelector('.launcher-grid')!.getBoundingClientRect();const initialFilters=document.querySelector('.launcher-filters')!.getBoundingClientRect();
|
||||
category('All').click();await until(()=>labels().length===7);for(const [label,page] of [['Install APK','apk'],['Plugin library','catalog'],['Settings','settings']]){document.querySelector<HTMLButtonElement>(`.launcher-shortcuts button[aria-label="${label}"]`)!.click();await until(()=>calls.some(c=>c.method==='host.manager.open'&&c.params.page===page));await until(()=>!document.querySelector('.launcher-shortcuts button:disabled'));}const initialGrid=document.querySelector('.launcher-grid')!.getBoundingClientRect();const initialFilters=document.querySelector('.launcher-filters')!.getBoundingClientRect();
|
||||
const stable=()=>{const g=document.querySelector('.launcher-grid')!.getBoundingClientRect(),f=document.querySelector('.launcher-filters')!.getBoundingClientRect();if(g.x!==initialGrid.x||g.y!==initialGrid.y||g.height!==570||f.y!==initialFilters.y)throw Error('Search / category changed fixed layout');};
|
||||
await wait(400);const searchBottom=document.querySelector('.launcher-search')!.getBoundingClientRect().bottom;const artworkTop=document.querySelector('.launch-disc')!.getBoundingClientRect().top;if(artworkTop-searchBottom<72)throw Error('Search is too close to the icon artwork');
|
||||
// Every app kind keeps its position when favorited or unfavorited.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
const wait=(ms=50)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unknown)=>{for(let i=0;i<150;i++){if(f())return;await wait();}throw Error('Timed out '+document.body.innerText.slice(-500));};
|
||||
history.replaceState(null,'','/manager');let language='en-US',event:any=null;
|
||||
const app={id:'test/com.example.app',context:'test',metadata:{name:'Example',package:'com.example.app',version:'1.0',versionCode:1,activities:['com.example.app.Main'],declaredActivities:['com.example.app.Main'],abis:[],vr:false,minSdk:24},installed:true,size:42};
|
||||
window.fetch=async(_url,init)=>{const{method}=JSON.parse(String(init?.body));let result:any=true;
|
||||
if(method==='status')result={version:'0.4.3',database:{language,plugins:{},sources:[],safeMode:false},agreement:{version:'1',accepted:true},notifications:[],running:[]};
|
||||
else if(method==='language.list')result={packs:[]};else if(method==='ui.events'){result={cursor:1,events:event?[event]:[]};event=null;}
|
||||
else if(method==='apk.list.start')result={job:'list'};else if(method==='job.status')result={phase:'done',result:{apps:[app],containers:[{id:'test',name:'test',baked:'/test',managed:false}],roots:[],warnings:[],available:true}};else if(method==='apk.cleanup.list')result=[];
|
||||
return new Response(JSON.stringify({result}));};
|
||||
const button=(name:string)=>Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent?.trim()===name)!;
|
||||
(async()=>{await import('../ui/src/main');await until(()=>button('APK Manager'));if(document.querySelector('.apk-manager')||button('Plugin Manager')?.getAttribute('aria-pressed')!=='true')throw Error('Default is not Plugin Manager');button('APK Manager').click();await until(()=>document.querySelector('.apk-row'));if(document.querySelector('nav button')?.textContent?.trim()!=='Apps')throw Error('APK navigation not isolated');button('Containers').click();await until(()=>document.querySelector('.apk-container'));button('Apps').click();await until(()=>document.querySelector('.apk-row'));await wait(250);console.log('FRAMELY_PREVIEW_INSTALL_APK_PANEL_EN');button('Plugin Manager').click();await until(()=>!document.querySelector('.apk-manager'));if(location.hash)throw Error('Plugin switch retained APK hash');location.hash='apk';await until(()=>document.querySelector('.apk-manager'));event={kind:'apk.selection',app:app.id};await until(()=>document.querySelector('.apk-dialog'));button('Done').click();language='zh-CN';await until(()=>button('插件管理'));await wait(250);console.log('FRAMELY_PREVIEW_INSTALL_APK_PANEL_ZH');button('插件管理').click();await until(()=>!document.querySelector('.apk-manager'));console.log('FRAMELY_BRIDGE_PASS');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e));
|
||||
@@ -23,6 +23,13 @@ const click=(target:Element)=>target.dispatchEvent(new MouseEvent('click',{bubbl
|
||||
await until(()=>!!frame.contentDocument?.getElementById('framely-scrollbars'));const doc=frame.contentDocument!,scroller=doc.getElementById('scroller')!,pb=doc.getElementById('plugin-button')!;let pluginClicks=0;pb.addEventListener('click',()=>pluginClicks++);
|
||||
event(pb,'pointerdown',60,70);event(pb,'pointermove',60,20);event(pb,'pointerup',60,20,0);click(pb);if(scroller.scrollTop!==50||pluginClicks)throw Error('Old plugin bootstrap did not support drag');scroller.scrollTop=0;await wait(350);
|
||||
const f=frame.getBoundingClientRect(),pr=pb.getBoundingClientRect();console.log(`FRAMELY_PAGE_WHEEL_${Math.round(f.x+pr.x+15)}_${Math.round(f.y+pr.y+15)}_0_-120`);await until(()=>scroller.scrollTop>0);if(outer.scrollTop!==0)throw Error('Iframe wheel scrolled the parent');frame.remove();
|
||||
// A localWeb-style document has no SDK or host bridge, and may forbid scripts.
|
||||
// Native frame-load injection must still install the same gestures exactly once.
|
||||
const local=document.createElement('iframe');local.style.cssText='position:absolute;left:850px;top:200px;width:300px;height:240px';local.srcdoc='<meta http-equiv="Content-Security-Policy" content="script-src \'none\'"><style>body{margin:0}#area{height:180px;overflow:auto}#content{height:800px}</style><div id="area"><div id="content"><button id="local-button" style="margin:40px">Local plugin</button><input id="local-input"></div></div>';root.append(local);
|
||||
await until(()=>(local.contentDocument as any)?.__framelyScrollGestures);const ld=local.contentDocument!,area=ld.getElementById('area')!,lb=ld.getElementById('local-button')!;if(ld.getElementById('framely-scrollbars')||(local.contentWindow as any).__framelyBridge)throw Error('Local fixture accidentally loaded bootstrap');let localClicks=0;lb.addEventListener('click',()=>localClicks++);
|
||||
event(lb,'pointerdown',60,70);event(lb,'pointermove',60,20);event(lb,'pointerup',60,20,0);click(lb);if(area.scrollTop!==50||localClicks)throw Error('Native local plugin drag missing or duplicated');area.scrollTop=0;await wait(250);
|
||||
const input=ld.getElementById('local-input')!;event(input,'pointerdown',60,70);event(input,'pointermove',60,10);event(input,'pointerup',60,10,0);if(area.scrollTop)throw Error('Local input hijacked');await wait(500);const lr=local.getBoundingClientRect(),br=lb.getBoundingClientRect();console.log(`FRAMELY_PAGE_WHEEL_${Math.round(lr.x+br.x+40)}_${Math.round(lr.y+br.y+20)}_0_-120`);await until(()=>area.scrollTop>0);
|
||||
local.srcdoc='<style>#area{height:180px;overflow:auto}#content{height:800px}</style><div id="area"><div id="content">Reloaded</div></div>';await until(()=>local.contentDocument?.body.textContent==='Reloaded'&&(local.contentDocument as any).__framelyScrollGestures);local.remove();
|
||||
document.documentElement.classList.add('launcher-view');event(button,'pointerdown',120,160);event(button,'pointermove',120,100);event(button,'pointerup',120,100,0);if(outer.scrollTop!==0)throw Error('Launcher gesture intercepted');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e+' '+(e as Error).stack);}};
|
||||
|
||||
@@ -4,5 +4,6 @@ base=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cef=$(realpath "$1")
|
||||
out=$(realpath -m "${2:-$base/target/native}")
|
||||
mkdir -p "$out"
|
||||
(cd "$base" && node tools/build-scroll-gestures.mjs "$out")
|
||||
openvr="$base/native/vendor/openvr"
|
||||
g++ -std=c++17 -O2 -I"$cef" -I"$openvr" -I"$base/native" "$base/native/host.cpp" -L"$cef/Release" -L"$openvr/lib/linuxarm64" -lcef -lopenvr_api -lGL -lX11 -pthread -Wl,-rpath,'$ORIGIN:$ORIGIN/../openvr' -o "$out/framely-vr"
|
||||
g++ -std=c++17 -O2 -I"$cef" -I"$openvr" -I"$base/native" -I"$out" "$base/native/host.cpp" -L"$cef/Release" -L"$openvr/lib/linuxarm64" -lcef -lopenvr_api -lGL -lX11 -pthread -Wl,-rpath,'$ORIGIN:$ORIGIN/../openvr' -o "$out/framely-vr"
|
||||
@@ -0,0 +1,8 @@
|
||||
import {build} from 'esbuild';
|
||||
import {mkdir,writeFile} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
const directory=resolve(process.argv[2]);
|
||||
await mkdir(directory,{recursive:true});
|
||||
const result=await build({entryPoints:['sdk/src/native-scroll.ts'],bundle:true,minify:true,write:false,format:'iife',target:'chrome120'});
|
||||
const script=result.outputFiles[0].text;
|
||||
await writeFile(resolve(directory,'native_scroll_script.h'),`#pragma once\nstatic constexpr char native_scroll_script[] = ${JSON.stringify(script)};\n`);
|
||||
@@ -10,5 +10,6 @@ npm run build
|
||||
node --input-type=module -e "import {build} from 'esbuild';await build({entryPoints:['tests/keyboard_fixture.tsx'],bundle:true,outfile:'target/keyboard-fixture.js',define:{'process.env.NODE_ENV':'\"production\"'}});"
|
||||
# ICU must be adjacent to the executable before Chromium process startup.
|
||||
cp -a "$cef/Resources/." "$cef/Release/"
|
||||
g++ -std=c++17 -O2 -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread -Wl,-rpath,'$ORIGIN' -o "$cef/Release/framely-browser-probe"
|
||||
node tools/build-scroll-gestures.mjs "target/native-scroll-probe"
|
||||
g++ -std=c++17 -O2 -I"target/native-scroll-probe" -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread -Wl,-rpath,'$ORIGIN' -o "$cef/Release/framely-browser-probe"
|
||||
FRAMELY_CEF_ROOT="$cef" FRAMELY_BROWSER_PROBE="$cef/Release/framely-browser-probe" cargo test --locked cef_manager_plugin_bridge_integration -- --ignored --nocapture
|
||||
@@ -29,7 +29,8 @@ server_pid=$!
|
||||
for _ in {1..100}; do [[ ! -f $out/port ]] || break; sleep 0.05; done
|
||||
port=$(cat "$out/port")
|
||||
cp -a "$cef/Resources/." "$cef/Release/"
|
||||
g++ -std=c++17 -O2 -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread '-Wl,-rpath,$ORIGIN' -o "$cef/Release/framely-browser-probe"
|
||||
node tools/build-scroll-gestures.mjs "$out"
|
||||
g++ -std=c++17 -O2 -I"$out" -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread '-Wl,-rpath,$ORIGIN' -o "$cef/Release/framely-browser-probe"
|
||||
for mode in desktop mobile; do
|
||||
mkdir "$out/$mode"
|
||||
export FRAMELY_CEF_ROOT="$cef" FRAMELY_INSTALL_REVIEW_TEST=1 FRAMELY_PREVIEW_DIR="$out/$mode" GSETTINGS_BACKEND=memory
|
||||
|
||||
@@ -42,7 +42,8 @@ server_pid=$!
|
||||
for _ in {1..100}; do [[ ! -f $out/port ]] || break; sleep 0.05; done
|
||||
port=$(cat "$out/port")
|
||||
cp -a "$cef/Resources/." "$cef/Release/"
|
||||
g++ -std=c++17 -O2 -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread '-Wl,-rpath,$ORIGIN' -o "$cef/Release/framely-browser-probe"
|
||||
node tools/build-scroll-gestures.mjs "$out"
|
||||
g++ -std=c++17 -O2 -I"$out" -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread '-Wl,-rpath,$ORIGIN' -o "$cef/Release/framely-browser-probe"
|
||||
for mode in desktop; do
|
||||
mkdir "$out/$mode"
|
||||
export FRAMELY_CEF_ROOT="$cef" FRAMELY_LAUNCHER_TEST=1 FRAMELY_INSTALL_REVIEW_TEST=1 FRAMELY_PREVIEW_DIR="$out/$mode" GSETTINGS_BACKEND=memory
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$base"
|
||||
mkdir -p target/tests
|
||||
for test in menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_pointer page_scroll paint_buffer render_timing; do
|
||||
for test in menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_fade launcher_pointer page_scroll paint_buffer render_timing; do
|
||||
"${CXX:-g++}" -std=c++17 -O2 -Inative -Inative/vendor/openvr "tests/$test.cpp" -o "target/tests/$test"
|
||||
"target/tests/$test"
|
||||
done
|
||||
+148
-3
@@ -121,7 +121,7 @@
|
||||
"停用:": "Disable: ",
|
||||
"确认执行": "Confirm",
|
||||
"诊断与日志": "Diagnostics and logs",
|
||||
"暂无日志": "No logs available",
|
||||
"暂无日志": "No logs yet",
|
||||
"导入插件包": "Import plugin package",
|
||||
"插件包超过 64 MiB": "The plugin package exceeds 64 MiB",
|
||||
"插件包下载 URL": "Plugin package download URL",
|
||||
@@ -431,7 +431,7 @@
|
||||
"搜索通知应用": "Search notification apps",
|
||||
"搜索应用": "Search apps",
|
||||
"全局开关优先;关闭全局开关不会改变应用偏好。": "Global switches take priority. App preferences are kept when a global switch is off.",
|
||||
"应用": "App",
|
||||
"应用": "Apps",
|
||||
"未找到应用": "No matching apps",
|
||||
"本体通知,包括检查更新提醒。": "Framely notifications, including update reminders.",
|
||||
"启动台": "Launcher",
|
||||
@@ -483,6 +483,151 @@
|
||||
"启动方式与菜单": "Launch controls and menus",
|
||||
"当前已是此版本,无需重复更新。": "This version is already installed. No update is needed.",
|
||||
"快捷操作": "Quick actions",
|
||||
"操作等待超时;请确认应用是否已打开后再重试。": "The operation timed out. Check whether the application opened before retrying."
|
||||
"APK 管理": "APK Manager",
|
||||
"安装和管理 Lepton 侧载应用。": "Install and manage sideloaded Lepton apps.",
|
||||
"安装应用": "Install app",
|
||||
"安装 APK": "Install APK",
|
||||
"容器": "Containers",
|
||||
"数据清理": "Data cleanup",
|
||||
"第一版仅支持单个 APK 文件。": "This version supports individual APK files only.",
|
||||
"APK 已安装。应用数据和图标排序会保留。": "APK installed. Existing app data and icon order are preserved.",
|
||||
"已释放 {0},失败 {1} 项。": "Freed {0}; {1} items failed.",
|
||||
"备份已恢复,请启动应用验证存档;恢复前状态已另行备份。": "Backup restored. Launch the app to verify saved data; the previous state was backed up separately.",
|
||||
"操作完成": "Operation completed",
|
||||
"清除保留数据": "Remove retained data",
|
||||
"卸载应用": "Uninstall app",
|
||||
"默认保留存档和设置。此操作只影响该应用。": "Saved data and settings are retained by default. Only this app is affected.",
|
||||
"将永久删除该应用保留的存档和设置,无法撤销。": "Retained saved data and settings will be permanently deleted. This cannot be undone.",
|
||||
"此操作影响以下应用:": "This operation affects these apps:",
|
||||
"容器内全部数据将永久删除。": "All data in this container will be permanently deleted.",
|
||||
"未检测到 Lepton,请先通过 Steam 安装。": "Lepton was not found. Install it through Steam first.",
|
||||
"搜索 APK": "Search APKs",
|
||||
"搜索名称、包名或容器": "Search name, package or container",
|
||||
"应用筛选": "App filter",
|
||||
"已卸载但保留数据": "Uninstalled, data retained",
|
||||
"容器运行中": "Container running",
|
||||
"未发现启动入口": "No launch entry found",
|
||||
"没有匹配的 APK 应用。": "No matching APK apps.",
|
||||
"Steam 管理": "Managed by Steam",
|
||||
"Framely 管理": "Managed by Framely",
|
||||
"已有侧载容器": "Existing sideload container",
|
||||
"停止容器": "Stop container",
|
||||
"启动容器": "Start container",
|
||||
"删除容器": "Delete container",
|
||||
"其他数据位置": "Additional data locations",
|
||||
"添加其他工具使用的 baked 数据目录,不移动原有应用。": "Add a baked data directory used by another tool. Existing apps stay in place.",
|
||||
"Lepton 数据目录": "Lepton data directory",
|
||||
"原容器名称(可选)": "Original context name (optional)",
|
||||
"移除扫描位置": "Remove scan location",
|
||||
"存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。": "Saved data, settings and recovery backups are not selected by default. Unidentified data is never deleted automatically.",
|
||||
"确认数据清理": "Confirm data cleanup",
|
||||
"选中的文件将永久删除,无法撤销。": "Selected files will be permanently deleted. This cannot be undone.",
|
||||
"清理所选": "Clean selected",
|
||||
"保留的应用数据": "Retained app data",
|
||||
"恢复备份": "Recovery backup",
|
||||
"过期上传文件": "Expired upload",
|
||||
"旧 APK": "Old APK",
|
||||
"未知归属的数据": "Unidentified data",
|
||||
"无法确认归属,不提供自动删除。": "Ownership could not be established. Automatic removal is unavailable.",
|
||||
"删除后不可恢复": "Deletion cannot be undone",
|
||||
"恢复容器备份": "Restore container backup",
|
||||
"恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。": "Restoring replaces the state of apps in this container. A backup of the current state is created first; launch verification is required afterward.",
|
||||
"没有可清理的数据。": "No data to clean.",
|
||||
"部分容器无法读取": "Some containers could not be read",
|
||||
"空间占用": "Storage used",
|
||||
"文件大小": "File size",
|
||||
"最低 Android SDK": "Minimum Android SDK",
|
||||
"未声明": "Not specified",
|
||||
"安装新版 APK": "Install newer APK",
|
||||
"关闭应用": "Close app",
|
||||
"清除应用数据": "Clear app data",
|
||||
"将永久删除该应用的存档和设置,无法撤销。": "Saved data and settings will be permanently deleted. This cannot be undone.",
|
||||
"重新安装 APK": "Reinstall APK",
|
||||
"高级设置": "Advanced settings",
|
||||
"默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。": "Window display is handled automatically by default. This switch does not change whether an app is 2D or VR.",
|
||||
"自动处理窗口显示": "Handle window display automatically",
|
||||
"显示安卓窗口": "Show Android window",
|
||||
"窗口设置需要重启容器后生效。": "Restart the container to apply window settings.",
|
||||
"启动入口": "Launch entry",
|
||||
"自动选择": "Automatic",
|
||||
"确认更新 APK": "Confirm APK update",
|
||||
"确认安装 APK": "Confirm APK installation",
|
||||
"目标容器": "Target container",
|
||||
"独立容器(默认)": "Independent container (default)",
|
||||
"操作可能需要停止目标容器,影响其中正在运行的应用。": "The operation may stop the target container and affect apps running inside it.",
|
||||
"同包名更新保留数据;签名不匹配或降级不会自动卸载重装。": "Updates retain data for the same package. Signature mismatches and downgrades never trigger automatic uninstall/reinstall.",
|
||||
"共享或外部容器的备份不提供一键恢复,避免覆盖其他应用数据。": "One-click restoration is unavailable for shared or external container backups to avoid overwriting other apps.",
|
||||
"删除应用数据": "Delete app data",
|
||||
"关闭时保留存档和设置。": "Keep saved data and settings when switched off.",
|
||||
"永久删除的数据无法撤销。": "Permanently deleted data cannot be recovered.",
|
||||
"正在处理 APK,请等待实际结果。": "Processing APK. Please wait for the actual result.",
|
||||
"安装已提交,等待 Android 完成;此阶段不能安全取消。": "The operation has been submitted. Waiting for Android; cancellation is unavailable at this stage.",
|
||||
"APK 操作进度": "APK operation progress",
|
||||
"APK 日志": "APK logs",
|
||||
"核对实际安装状态": "Reconcile installed state",
|
||||
"管理应用": "Manage app",
|
||||
"正在读取": "Reading",
|
||||
"准备中": "Preparing",
|
||||
"上传 APK": "Uploading APK",
|
||||
"校验 APK": "Validating APK",
|
||||
"核对安装结果": "Verifying installation",
|
||||
"备份应用数据": "Backing up app data",
|
||||
"选择": "Select",
|
||||
"添加": "Add",
|
||||
"恢复": "Restore",
|
||||
"日志": "Logs",
|
||||
"切换管理面板": "Switch manager",
|
||||
"插件管理": "Plugin Manager",
|
||||
"Lepton 侧载管理": "Lepton sideload management",
|
||||
"操作等待超时;请确认应用是否已打开后再重试。": "The operation timed out. Check whether the application opened before retrying.",
|
||||
"未发现已安装的应用,启动容器不会安装 APK。": "No installed apps were found. Starting the container does not install an APK.",
|
||||
"安装状态未知": "Installation state unknown",
|
||||
"请通过 Steam 管理此应用。": "Manage this application through Steam.",
|
||||
"共享容器的备份需手动恢复;独立侧载容器支持一键恢复。": "Shared container backups require manual restoration. Independent sideloaded containers support one-click restoration.",
|
||||
"安装APK": "Install APK",
|
||||
"下载": "Downloads",
|
||||
"主目录": "Home",
|
||||
"桌面": "Desktop",
|
||||
"文档": "Documents",
|
||||
"临时目录": "Temporary files",
|
||||
"选择目录": "Choose folder",
|
||||
"选择文件": "Choose file",
|
||||
"浏览 Frame 上的目录": "Browse folders on Frame",
|
||||
"浏览 Frame 上的文件": "Browse files on Frame",
|
||||
"支持的文件类型": "Supported file types",
|
||||
"文件位置": "Locations",
|
||||
"返回上一个目录": "Previous folder",
|
||||
"上级目录": "Parent folder",
|
||||
"目录路径": "Folder path",
|
||||
"前往": "Go",
|
||||
"搜索当前目录": "Search this folder",
|
||||
"显示隐藏文件": "Show hidden files",
|
||||
"仅显示支持的文件": "Supported files only",
|
||||
"正在读取目录…": "Loading folder…",
|
||||
"文件夹": "Folder",
|
||||
"当前目录没有匹配的文件": "No matching files in this folder",
|
||||
"上一页": "Previous page",
|
||||
"下一页": "Next page",
|
||||
"部分项目无法访问,已跳过。": "Some inaccessible items were skipped.",
|
||||
"已选择 {0} 个文件": "{0} files selected",
|
||||
"请选择要导入的文件": "Select a file to import",
|
||||
"文件或目录不存在,或无权访问": "The file or folder does not exist, or access is denied.",
|
||||
"只能选择用户目录、临时目录或已挂载存储中的文件": "Choose a file in your home folder, temporary folder, or mounted storage.",
|
||||
"没有可访问的目录": "No accessible folders.",
|
||||
"请选择目录": "Select a folder.",
|
||||
"请选择文件": "Select a file.",
|
||||
"搜索内容过长": "Search text is too long.",
|
||||
"无法读取目录": "Unable to read this folder.",
|
||||
"目录文件过多,请选择更具体的目录": "Too many items. Choose a more specific folder.",
|
||||
"选择的文件过多": "Too many files selected.",
|
||||
"无法读取所选文件": "Unable to read the selected file.",
|
||||
"显示选项": "View options",
|
||||
"{0} 个项目": "{0} items",
|
||||
"修改时间": "Modified",
|
||||
"1 个项目": "1 item",
|
||||
"文件夹树": "Folders",
|
||||
"收起 {0}": "Collapse {0}",
|
||||
"展开 {0}": "Expand {0}",
|
||||
"加载更多目录": "Load more folders"
|
||||
}
|
||||
}
|
||||
+146
-1
@@ -483,6 +483,151 @@
|
||||
"启动方式与菜单": "启动方式与菜单",
|
||||
"当前已是此版本,无需重复更新。": "当前已是此版本,无需重复更新。",
|
||||
"快捷操作": "快捷操作",
|
||||
"操作等待超时;请确认应用是否已打开后再重试。": "操作等待超时;请确认应用是否已打开后再重试。"
|
||||
"APK 管理": "APK 管理",
|
||||
"安装和管理 Lepton 侧载应用。": "安装和管理 Lepton 侧载应用。",
|
||||
"安装应用": "安装应用",
|
||||
"安装 APK": "安装 APK",
|
||||
"容器": "容器",
|
||||
"数据清理": "数据清理",
|
||||
"第一版仅支持单个 APK 文件。": "第一版仅支持单个 APK 文件。",
|
||||
"APK 已安装。应用数据和图标排序会保留。": "APK 已安装。应用数据和图标排序会保留。",
|
||||
"已释放 {0},失败 {1} 项。": "已释放 {0},失败 {1} 项。",
|
||||
"备份已恢复,请启动应用验证存档;恢复前状态已另行备份。": "备份已恢复,请启动应用验证存档;恢复前状态已另行备份。",
|
||||
"操作完成": "操作完成",
|
||||
"清除保留数据": "清除保留数据",
|
||||
"卸载应用": "卸载应用",
|
||||
"默认保留存档和设置。此操作只影响该应用。": "默认保留存档和设置。此操作只影响该应用。",
|
||||
"将永久删除该应用保留的存档和设置,无法撤销。": "将永久删除该应用保留的存档和设置,无法撤销。",
|
||||
"此操作影响以下应用:": "此操作影响以下应用:",
|
||||
"容器内全部数据将永久删除。": "容器内全部数据将永久删除。",
|
||||
"未检测到 Lepton,请先通过 Steam 安装。": "未检测到 Lepton,请先通过 Steam 安装。",
|
||||
"搜索 APK": "搜索 APK",
|
||||
"搜索名称、包名或容器": "搜索名称、包名或容器",
|
||||
"应用筛选": "应用筛选",
|
||||
"已卸载但保留数据": "已卸载但保留数据",
|
||||
"容器运行中": "容器运行中",
|
||||
"未发现启动入口": "未发现启动入口",
|
||||
"没有匹配的 APK 应用。": "没有匹配的 APK 应用。",
|
||||
"Steam 管理": "Steam 管理",
|
||||
"Framely 管理": "Framely 管理",
|
||||
"已有侧载容器": "已有侧载容器",
|
||||
"停止容器": "停止容器",
|
||||
"启动容器": "启动容器",
|
||||
"删除容器": "删除容器",
|
||||
"其他数据位置": "其他数据位置",
|
||||
"添加其他工具使用的 baked 数据目录,不移动原有应用。": "添加其他工具使用的 baked 数据目录,不移动原有应用。",
|
||||
"Lepton 数据目录": "Lepton 数据目录",
|
||||
"原容器名称(可选)": "原容器名称(可选)",
|
||||
"移除扫描位置": "移除扫描位置",
|
||||
"存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。": "存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。",
|
||||
"确认数据清理": "确认数据清理",
|
||||
"选中的文件将永久删除,无法撤销。": "选中的文件将永久删除,无法撤销。",
|
||||
"清理所选": "清理所选",
|
||||
"保留的应用数据": "保留的应用数据",
|
||||
"恢复备份": "恢复备份",
|
||||
"过期上传文件": "过期上传文件",
|
||||
"旧 APK": "旧 APK",
|
||||
"未知归属的数据": "未知归属的数据",
|
||||
"无法确认归属,不提供自动删除。": "无法确认归属,不提供自动删除。",
|
||||
"删除后不可恢复": "删除后不可恢复",
|
||||
"恢复容器备份": "恢复容器备份",
|
||||
"恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。": "恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。",
|
||||
"没有可清理的数据。": "没有可清理的数据。",
|
||||
"部分容器无法读取": "部分容器无法读取",
|
||||
"空间占用": "空间占用",
|
||||
"文件大小": "文件大小",
|
||||
"最低 Android SDK": "最低 Android SDK",
|
||||
"未声明": "未声明",
|
||||
"安装新版 APK": "安装新版 APK",
|
||||
"关闭应用": "关闭应用",
|
||||
"清除应用数据": "清除应用数据",
|
||||
"将永久删除该应用的存档和设置,无法撤销。": "将永久删除该应用的存档和设置,无法撤销。",
|
||||
"重新安装 APK": "重新安装 APK",
|
||||
"高级设置": "高级设置",
|
||||
"默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。": "默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。",
|
||||
"自动处理窗口显示": "自动处理窗口显示",
|
||||
"显示安卓窗口": "显示安卓窗口",
|
||||
"窗口设置需要重启容器后生效。": "窗口设置需要重启容器后生效。",
|
||||
"启动入口": "启动入口",
|
||||
"自动选择": "自动选择",
|
||||
"确认更新 APK": "确认更新 APK",
|
||||
"确认安装 APK": "确认安装 APK",
|
||||
"目标容器": "目标容器",
|
||||
"独立容器(默认)": "独立容器(默认)",
|
||||
"操作可能需要停止目标容器,影响其中正在运行的应用。": "操作可能需要停止目标容器,影响其中正在运行的应用。",
|
||||
"同包名更新保留数据;签名不匹配或降级不会自动卸载重装。": "同包名更新保留数据;签名不匹配或降级不会自动卸载重装。",
|
||||
"共享或外部容器的备份不提供一键恢复,避免覆盖其他应用数据。": "共享或外部容器的备份不提供一键恢复,避免覆盖其他应用数据。",
|
||||
"删除应用数据": "删除应用数据",
|
||||
"关闭时保留存档和设置。": "关闭时保留存档和设置。",
|
||||
"永久删除的数据无法撤销。": "永久删除的数据无法撤销。",
|
||||
"正在处理 APK,请等待实际结果。": "正在处理 APK,请等待实际结果。",
|
||||
"安装已提交,等待 Android 完成;此阶段不能安全取消。": "安装已提交,等待 Android 完成;此阶段不能安全取消。",
|
||||
"APK 操作进度": "APK 操作进度",
|
||||
"APK 日志": "APK 日志",
|
||||
"核对实际安装状态": "核对实际安装状态",
|
||||
"管理应用": "管理应用",
|
||||
"正在读取": "正在读取",
|
||||
"准备中": "准备中",
|
||||
"上传 APK": "上传 APK",
|
||||
"校验 APK": "校验 APK",
|
||||
"核对安装结果": "核对安装结果",
|
||||
"备份应用数据": "备份应用数据",
|
||||
"选择": "选择",
|
||||
"添加": "添加",
|
||||
"恢复": "恢复",
|
||||
"日志": "日志",
|
||||
"切换管理面板": "切换管理面板",
|
||||
"插件管理": "插件管理",
|
||||
"Lepton 侧载管理": "Lepton 侧载管理",
|
||||
"操作等待超时;请确认应用是否已打开后再重试。": "操作等待超时;请确认应用是否已打开后再重试。",
|
||||
"未发现已安装的应用,启动容器不会安装 APK。": "未发现已安装的应用,启动容器不会安装 APK。",
|
||||
"安装状态未知": "安装状态未知",
|
||||
"请通过 Steam 管理此应用。": "请通过 Steam 管理此应用。",
|
||||
"共享容器的备份需手动恢复;独立侧载容器支持一键恢复。": "共享容器的备份需手动恢复;独立侧载容器支持一键恢复。",
|
||||
"安装APK": "安装APK",
|
||||
"下载": "下载",
|
||||
"主目录": "主目录",
|
||||
"桌面": "桌面",
|
||||
"文档": "文档",
|
||||
"临时目录": "临时目录",
|
||||
"选择目录": "选择目录",
|
||||
"选择文件": "选择文件",
|
||||
"浏览 Frame 上的目录": "浏览 Frame 上的目录",
|
||||
"浏览 Frame 上的文件": "浏览 Frame 上的文件",
|
||||
"支持的文件类型": "支持的文件类型",
|
||||
"文件位置": "文件位置",
|
||||
"返回上一个目录": "返回上一个目录",
|
||||
"上级目录": "上级目录",
|
||||
"目录路径": "目录路径",
|
||||
"前往": "前往",
|
||||
"搜索当前目录": "搜索当前目录",
|
||||
"显示隐藏文件": "显示隐藏文件",
|
||||
"仅显示支持的文件": "仅显示支持的文件",
|
||||
"正在读取目录…": "正在读取目录…",
|
||||
"文件夹": "文件夹",
|
||||
"当前目录没有匹配的文件": "当前目录没有匹配的文件",
|
||||
"上一页": "上一页",
|
||||
"下一页": "下一页",
|
||||
"部分项目无法访问,已跳过。": "部分项目无法访问,已跳过。",
|
||||
"已选择 {0} 个文件": "已选择 {0} 个文件",
|
||||
"请选择要导入的文件": "请选择要导入的文件",
|
||||
"文件或目录不存在,或无权访问": "文件或目录不存在,或无权访问",
|
||||
"只能选择用户目录、临时目录或已挂载存储中的文件": "只能选择用户目录、临时目录或已挂载存储中的文件",
|
||||
"没有可访问的目录": "没有可访问的目录",
|
||||
"请选择目录": "请选择目录",
|
||||
"请选择文件": "请选择文件",
|
||||
"搜索内容过长": "搜索内容过长",
|
||||
"无法读取目录": "无法读取目录",
|
||||
"目录文件过多,请选择更具体的目录": "目录文件过多,请选择更具体的目录",
|
||||
"选择的文件过多": "选择的文件过多",
|
||||
"无法读取所选文件": "无法读取所选文件",
|
||||
"显示选项": "显示选项",
|
||||
"{0} 个项目": "{0} 个项目",
|
||||
"修改时间": "修改时间",
|
||||
"1 个项目": "1 个项目",
|
||||
"文件夹树": "文件夹树",
|
||||
"收起 {0}": "收起 {0}",
|
||||
"展开 {0}": "展开 {0}",
|
||||
"加载更多目录": "加载更多目录"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.apk-manager{width:100%;min-width:0}.apk-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px}.apk-tabs{display:flex;gap:8px;border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:22px}.apk-tabs button{background:transparent;border-color:transparent;color:var(--muted)}.apk-tabs button.active{background:#30343a;color:var(--text)}.apk-filters{display:flex;gap:12px;width:100%;margin-bottom:20px}.apk-filters input{flex:1;min-width:0;margin:0}.apk-filters select{width:240px;margin:0}.apk-row,.apk-container,.apk-cleanup-row{display:flex;align-items:center;gap:20px;padding:22px 0;border-bottom:1px solid var(--line);width:100%;min-width:0}.apk-copy{flex:1;min-width:0}.apk-copy h2{font-size:23px;display:flex;align-items:center;flex-wrap:wrap;gap:14px;margin:0 0 6px}.apk-copy h2 small{font-size:16px;font-weight:400;color:var(--muted)}.apk-copy code,.apk-location code,.apk-container code{font-size:16px;overflow-wrap:anywhere}.apk-copy p{color:var(--muted);font-size:16px;margin:7px 0 0}.apk-copy .error{color:#f7a7a7}.apk-notice{padding:14px 18px;background:#19282f;border-left:3px solid var(--blue)}.apk-error{display:flex;justify-content:space-between;gap:12px;white-space:pre-wrap;overflow-wrap:anywhere}.apk-error button{flex:none}.apk-locations{margin-top:30px;width:100%}.apk-locations>p,.apk-cleanup-heading p{color:var(--muted);font-size:17px}.apk-location{display:flex;gap:14px;align-items:center;justify-content:space-between;margin:12px 0}.apk-cleanup-heading{display:flex;align-items:center;justify-content:space-between;gap:20px}.apk-cleanup-row>small,.apk-copy>small{color:#d5b889}.apk-warnings{margin-top:24px;overflow-wrap:anywhere}.apk-dialog{position:fixed;z-index:40}.apk-dialog .apk-dialog-heading{display:flex;align-items:center;gap:18px;margin:20px 0}.apk-dialog-heading h2,.apk-dialog-heading h3{margin:0 0 8px}.apk-dialog-heading code{background:transparent;padding:0}.apk-meta{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:12px 24px;margin:24px 0}.apk-meta dt{color:var(--muted)}.apk-meta dd{margin:0;overflow-wrap:anywhere}.apk-dialog label{display:block;width:100%}.apk-dialog label select{width:100%;margin:12px 0}.apk-dialog footer{display:flex;justify-content:flex-end;gap:12px;border-top:1px solid var(--line);padding-top:20px;margin-top:24px}.apk-dialog footer button{margin:0}.apk-dialog .apk-actions{margin:20px 0}.apk-dialog .apk-actions button{margin:0}.apk-advanced{margin-top:24px}.apk-advanced p{font-size:17px;color:var(--muted)}.apk-confirm-copy{white-space:pre-wrap}.apk-dialog pre{max-height:50vh;overflow:auto}.apk-dialog progress{margin:18px 0}.apk-dialog .switch-row{width:100%}@media(max-width:700px){.apk-row,.apk-container{flex-wrap:wrap;gap:14px}.apk-row>.apk-actions,.apk-container>.apk-actions{width:100%}.apk-filters{flex-wrap:wrap}.apk-filters select{width:100%}.apk-cleanup-heading{flex-wrap:wrap}.apk-meta{grid-template-columns:minmax(0,1fr)}.apk-meta dd{margin-bottom:8px}.apk-location{flex-wrap:wrap}}
|
||||
|
||||
.apk-modal-backdrop{position:fixed;inset:0;z-index:40;background:#0009}.apk-modal-backdrop .modal{box-shadow:0 20px 80px #0008}
|
||||
|
||||
.apk-filters{align-items:center}.apk-filters input:not([type=checkbox]):not([type=file]){margin:0;flex:1;width:auto;min-width:0}.apk-filters>.framely-select{flex:0 0 260px;min-width:0}.apk-filters>.framely-select>button{min-height:56px}.apk-filters>button{flex:none}.apk-dialog label .framely-select{margin:12px 0}@media(max-width:700px){.apk-filters>.framely-select{flex:1 1 100%}}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {IconBrandAndroid,IconDownload,IconRefresh,IconTrash,IconPlayerPlay,IconPlayerStop,IconFolder,IconFileText,IconX} from '@tabler/icons-react';
|
||||
import {api,uploadChunk} from './api';
|
||||
import {uploadFile} from './upload';
|
||||
import {Job,PluginImage,runJob} from './store';
|
||||
import {Select} from './localized-select';
|
||||
import {Switch,SwitchRow} from './switch';
|
||||
import {t} from './i18n';
|
||||
import './apk-manager.css';
|
||||
type Metadata={package:string;name:string;version:string;versionCode:number;minSdk:number;activities:string[];declaredActivities:string[];icon?:string;abis:string[];vr:boolean};
|
||||
type App={stateKnown?:boolean;id:string;context:string;metadata:Metadata;installed:boolean;running:boolean;managed:boolean;steam:boolean;size:number;pending?:string;activity?:string;showWindow?:boolean|null};
|
||||
type Container={acceptsAdditionalApps?:boolean;id:string;name:string;baked:string;running:boolean;managed:boolean;steam:boolean};
|
||||
type Data={apps:App[];containers:Container[];warnings:string[];roots:string[];available:boolean};
|
||||
type Cleanup={id:string;kind:string;name:string;bytes:number;sensitive:boolean;app?:string;context?:string;restorable?:boolean;unknown?:boolean};
|
||||
type Review={ticket:string;metadata:Metadata;bytes:number};
|
||||
type Confirm={operation:string;app?:App;container?:Container;backup?:string;items?:string[];title:string;description:string;purge?:boolean};
|
||||
function bytes(n:number){return n<1024?`${n} B`:n<1024*1024?`${(n/1024).toFixed(1)} KiB`:n<1024**3?`${(n/1024**2).toFixed(1)} MiB`:`${(n/1024**3).toFixed(2)} GiB`;}
|
||||
const phases:Record<string,string>={loading:'正在读取',preparing:'准备中',uploading:'上传 APK',inspecting:'校验 APK',starting:'启动容器',installing:'安装 APK',verifying:'核对安装结果','backing-up':'备份应用数据',uninstalling:'卸载应用',done:'完成',failed:'失败',cancelled:'操作已取消'};
|
||||
export function ApkManager({view:externalView,onViewChange}:{view?:string;onViewChange?:(view:string)=>void}={}){
|
||||
const[data,setData]=useState<Data>({apps:[],containers:[],warnings:[],roots:[],available:true}),[loading,setLoading]=useState(true),[localView,setLocalView]=useState('apps'),[filter,setFilter]=useState('installed'),[query,setQuery]=useState(''),[error,setError]=useState(''),[review,setReview]=useState<Review|null>(null),[target,setTarget]=useState(''),[updating,setUpdating]=useState<App|null>(null),[details,setDetails]=useState<App|null>(null),[confirm,setConfirm]=useState<Confirm|null>(null),[job,setJob]=useState<(Job&{cancellable?:boolean})|null>(null),[busy,setBusy]=useState(false),[cleanup,setCleanup]=useState<Cleanup[]>([]),[selected,setSelected]=useState<string[]>([]),[log,setLog]=useState<{name:string;text:string}|null>(null),[path,setPath]=useState(''),[contextName,setContextName]=useState(''),[notice,setNotice]=useState('');
|
||||
const view=externalView??localView;const setView=(value:string)=>{setLocalView(value);onViewChange?.(value);};
|
||||
useEffect(()=>{if(view==='cleanup')void loadCleanup();},[view]);
|
||||
const input=useRef<HTMLInputElement>(null),controller=useRef<AbortController|null>(null),selection=useRef<{app:string;uninstall:boolean}|null>(null),alive=useRef(true),busyRef=useRef(false);
|
||||
const updateJob=(v:Job)=>{if(alive.current)setJob(v);};
|
||||
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');if(!active.job||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean}>('job.status',{job:active.job});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(value.phase==='failed')setError(value.error??t('操作失败'));else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(String(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}}
|
||||
async function refresh(){setLoading(true);try{const r=await runJob('apk.list.start');if(alive.current){setData(r.result);setDetails(old=>old?r.result.apps.find((a:App)=>a.id===old.id)??null:null);}}catch(e){if(alive.current)setError(String(e));}finally{if(alive.current)setLoading(false);}}
|
||||
useEffect(()=>{alive.current=true;void refresh();void resumeOperation();const select=(e:Event)=>{const v=(e as CustomEvent).detail;if(v?.kind==='apk.selection'){selection.current={app:v.app,uninstall:!!v.uninstall};void refresh();}};window.addEventListener('framely.event',select);return()=>{alive.current=false;window.removeEventListener('framely.event',select);controller.current?.abort();};},[]);
|
||||
useEffect(()=>{if(selection.current){const a=data.apps.find(a=>a.id===selection.current!.app);if(a){if(a.steam){setError(t('请通过 Steam 管理此应用。'));selection.current=null;return;}if(selection.current.uninstall)askUninstall(a);else setDetails(a);selection.current=null;}}},[data]);
|
||||
useEffect(()=>{const close=(e:Event)=>{if(e instanceof KeyboardEvent&&e.key!=='Escape')return;if(busyRef.current)return;if(confirm)setConfirm(null);else if(log)setLog(null);else if(review)void discard();else setDetails(null);};window.addEventListener('keydown',close);window.addEventListener('framely.back',close);return()=>{window.removeEventListener('keydown',close);window.removeEventListener('framely.back',close);};},[confirm,log,review]);
|
||||
useEffect(()=>{const dialog=document.querySelector<HTMLElement>('.apk-dialog');if(!dialog)return;const prev=document.activeElement as HTMLElement;const controls=()=>Array.from(dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input,select,a[href]')).filter(e=>e.getClientRects().length);controls()[0]?.focus();const key=(e:KeyboardEvent)=>{if(e.key!=='Tab')return;const a=controls();if(!a.length)return;if(e.shiftKey&&(document.activeElement===a[0]||!dialog.contains(document.activeElement))){e.preventDefault();a.at(-1)?.focus();}else if(!e.shiftKey&&(document.activeElement===a.at(-1)||!dialog.contains(document.activeElement))){e.preventDefault();a[0].focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);prev?.focus();};},[review,details,confirm,log,job?.id]);
|
||||
async function discard(){if(review)await api('apk.review.drop',{ticket:review.ticket}).catch(()=>{});setReview(null);setUpdating(null);}
|
||||
async function inspect(file:File){if(!file.name.toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob({id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});const result=await runJob('apk.inspect.start',{upload},updateJob);if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setReview(result.result);setTarget(updating?.context??'');setJob(null);}catch(e){setJob(null);setError(String(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}}
|
||||
function choose(a?:App){setUpdating(a??null);if(input.current){input.current.value='';input.current.click();}}
|
||||
async function perform(operation:string,params:Record<string,unknown>={}){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(String(e));await refresh();}finally{setBusy(false);busyRef.current=false;}}
|
||||
function askUninstall(a:App){setConfirm({operation:a.installed?'uninstall':'purge',app:a,title:a.installed?t('卸载应用'):t('清除保留数据'),description:a.installed?t('默认保留存档和设置。此操作只影响该应用。'):t('将永久删除该应用保留的存档和设置,无法撤销。'),purge:!a.installed});}
|
||||
async function loadCleanup(){try{const items=await api<Cleanup[]>('apk.cleanup.list');setCleanup(items);setSelected([]);}catch(e){setError(String(e));}}
|
||||
async function showLog(a:App|string){try{setLog(await api('apk.logs',{app:typeof a==='string'?a:a.id}));}catch(e){setError(String(e));}}
|
||||
function downloadLog(){if(!log)return;const url=URL.createObjectURL(new Blob([log.text],{type:'text/plain;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download=log.name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
||||
const listed=data.apps.filter(a=>(filter==='all'||(filter==='installed'?(a.installed||a.stateKnown===false):(!a.installed&&a.stateKnown!==false)))&&!a.steam&&`${a.metadata.name} ${a.metadata.package} ${a.context}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const contextFor=(id:string)=>data.containers.find(c=>c.id===id);
|
||||
function containerAsk(c:Container,operation:string){setConfirm({operation,container:c,title:t(operation==='container.delete'?'删除容器':operation==='container.stop'?'停止容器':'启动容器'),description:`${t('此操作影响以下应用:')} ${data.apps.filter(a=>a.context===c.id).map(a=>a.metadata.name).join(', ')||c.name}${operation==='container.delete'?`\n${t('容器内全部数据将永久删除。')}`:''}`});}
|
||||
const deletesData=!!confirm&&(['purge','clear','cleanup','container.delete'].includes(confirm.operation)||(confirm.operation==='uninstall'&&!!confirm.purge));
|
||||
const dialog=(children:React.ReactNode,label:string)=>createPortal(<div className="apk-modal-backdrop"><div className="modal apk-dialog" role="dialog" aria-modal="true" aria-label={label}>{error&&!review&&!confirm&&<p className="error" role="alert">{error}</p>}{children}</div></div>,document.body);
|
||||
return <section className="apk-manager">
|
||||
<div className="list-heading"><div><h1>{t('APK 管理')}</h1><p>{t('安装和管理 Lepton 侧载应用。')}</p></div><div className="apk-actions"><button disabled={busy||loading} onClick={()=>void refresh()}><IconRefresh size={22}/>{t('刷新')}</button><button className="primary" disabled={busy||!data.available} onClick={()=>choose()}><IconDownload size={22}/>{t('安装 APK')}</button></div></div>
|
||||
<input ref={input} type="file" accept=".apk,application/vnd.android.package-archive" hidden onChange={e=>{if(e.target.files?.[0])void inspect(e.target.files[0]);}}/>
|
||||
{!data.available&&<p className="banner">{t('未检测到 Lepton,请先通过 Steam 安装。')}</p>}
|
||||
{notice&&<p className="apk-notice" role="status">{notice}</p>}{error&&<div className="error apk-error" role="alert">{error}<button aria-label={t('关闭')} onClick={()=>setError('')}><IconX size={20}/></button></div>}
|
||||
{!externalView&&<div className="apk-tabs" role="tablist">{[['apps','应用'],['containers','容器'],['cleanup','数据清理']].map(([key,label])=><button key={key} role="tab" aria-selected={view===key} className={view===key?'active':''} disabled={busy} onClick={()=>{setView(key);if(key==='cleanup')void loadCleanup();}}>{t(label)}</button>)}</div>}
|
||||
{view==='apps'&&<><div className="apk-filters"><input type="search" aria-label={t('搜索 APK')} placeholder={t('搜索名称、包名或容器')} value={query} onChange={e=>setQuery(e.target.value)}/><Select label={t('应用筛选')} value={filter} onChange={setFilter} options={[{value:'installed',label:t('已安装')},{value:'retained',label:t('已卸载但保留数据')},{value:'all',label:t('全部')}]}/></div><div className="apk-list">{listed.map(a=><article className="apk-row" key={a.id}><PluginImage src={a.metadata.icon} name={a.metadata.name} size={64}/><div className="apk-copy"><h2>{a.metadata.name}<small>{a.metadata.version}</small></h2><code>{a.metadata.package}</code><p>{contextFor(a.context)?.name??a.context} · {bytes(a.size)} · {t(a.stateKnown===false?'安装状态未知':a.installed?(a.running?'容器运行中':'已安装'):'已卸载但保留数据')}</p>{a.pending&&<p className="error">{t(a.pending)}</p>}{a.installed&&!a.metadata.activities.length&&!a.activity&&<p>{t('未发现启动入口')}</p>}</div><div className="apk-actions">{a.installed&&a.stateKnown!==false&&<button disabled={busy||!a.metadata.activities.length&&!a.activity} onClick={()=>void perform('launch',{app:a.id})}><IconPlayerPlay size={20}/>{t('打开')}</button>}<button disabled={busy} onClick={()=>setDetails(a)}>{t('管理')}</button></div></article>)}</div>{!listed.length&&<div className="empty"><IconBrandAndroid size={40}/><p>{loading?t('正在读取'):t('没有匹配的 APK 应用。')}</p></div>}</>}
|
||||
{view==='containers'&&<><div className="apk-list">{data.containers.map(c=><article className="apk-container" key={c.id}><div className="apk-copy"><h2><IconFolder size={24}/>{c.name}</h2><code>{c.baked}</code><p>{t(c.steam?'Steam 管理':c.managed?'Framely 管理':'已有侧载容器')} · {t(c.running?'运行中':'已停止')}</p><p>{data.apps.filter(a=>a.context===c.id&&a.installed).map(a=>a.metadata.name).join(', ')||t('未发现已安装的应用,启动容器不会安装 APK。')}</p></div><div className="apk-actions"><button disabled={busy} onClick={()=>void showLog(c.id)}><IconFileText size={20}/>{t('日志')}</button>{!c.steam&&<><button disabled={busy} onClick={()=>containerAsk(c,c.running?'container.stop':'container.start')}>{t(c.running?'停止容器':'启动容器')}</button><button className="danger" disabled={busy} onClick={()=>containerAsk(c,'container.delete')}>{t('删除容器')}</button></>}</div></article>)}</div><section className="apk-locations"><h2>{t('其他数据位置')}</h2><p>{t('添加其他工具使用的 baked 数据目录,不移动原有应用。')}</p><div className="apk-filters"><input value={path} placeholder="/path/to/compatdata/baked" aria-label={t('Lepton 数据目录')} onChange={e=>setPath(e.target.value)}/><input value={contextName} placeholder={t('原容器名称(可选)')} aria-label={t('原容器名称(可选)')} onChange={e=>setContextName(e.target.value)}/><button disabled={busy||!path.trim()} onClick={()=>void perform('root.add',{path,context:contextName}).then(()=>setPath(''))}>{t('添加')}</button></div>{data.roots.map(p=><div className="apk-location" key={p}><code>{p}</code><button disabled={busy} onClick={()=>void perform('root.remove',{path:p})}>{t('移除扫描位置')}</button></div>)}</section></>}
|
||||
{view==='cleanup'&&<><div className="apk-cleanup-heading"><div><h2>{t('数据清理')}</h2><p>{t('存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。')}</p></div><button className="danger" disabled={busy||!selected.length} onClick={()=>setConfirm({operation:'cleanup',items:selected,title:t('确认数据清理'),description:cleanup.filter(i=>selected.includes(i.id)).map(i=>`${i.name} · ${bytes(i.bytes)}`).join('\n')+`\n${t('选中的文件将永久删除,无法撤销。')}`})}><IconTrash size={22}/>{t('清理所选')}</button></div>{cleanup.map(i=><article className="apk-cleanup-row" key={i.id}><Switch checked={selected.includes(i.id)} label={`${t('选择')} ${i.name}`} disabled={busy||i.unknown} onChange={checked=>setSelected(s=>checked?[...s,i.id]:s.filter(v=>v!==i.id))}/><div className="apk-copy"><b>{i.name}</b><p>{t(i.kind==='unknown'?'未知归属的数据':i.kind==='data'?'保留的应用数据':i.kind==='backups'?'恢复备份':(i.kind==='reviews'||i.kind==='uploads')?'过期上传文件':'旧 APK')} · {bytes(i.bytes)}</p>{i.unknown?<small>{t('无法确认归属,不提供自动删除。')}</small>:i.sensitive&&<small>{t('删除后不可恢复')}</small>}</div>{i.restorable&&<button disabled={busy} onClick={()=>setConfirm({operation:'restore',container:contextFor(i.context!),backup:i.name,title:t('恢复容器备份'),description:t('恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。')})}>{t('恢复')}</button>}</article>)}{!cleanup.length&&<p className="empty">{t('没有可清理的数据。')}</p>}</>}
|
||||
{!!data.warnings.length&&<details className="apk-warnings"><summary>{t('部分容器无法读取')}</summary>{data.warnings.map((w,i)=><p key={i}>{w}</p>)}</details>}
|
||||
{details&&!confirm&&!review&&!log&&!job&&dialog(<><header className="apk-dialog-heading"><PluginImage name={details.metadata.name} src={details.metadata.icon} size={64}/><div><h2>{details.metadata.name}</h2><code>{details.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{details.metadata.version} ({details.metadata.versionCode})</dd><dt>{t('容器')}</dt><dd>{contextFor(details.context)?.name??details.context}</dd><dt>{t('空间占用')}</dt><dd>{bytes(details.size)}</dd></dl>{details.pending&&<><p className="error">{t(details.pending)}</p><button disabled={busy} onClick={()=>void perform('reconcile',{app:details.id})}>{t('核对实际安装状态')}</button></>}<div className="apk-actions">{details.installed&&details.stateKnown!==false&&!details.steam&&<><button disabled={busy||!details.metadata.activities.length&&!details.activity} onClick={()=>void perform('launch',{app:details.id})}>{t('打开')}</button><button disabled={busy} onClick={()=>void perform('close',{app:details.id})}><IconPlayerStop size={20}/>{t('关闭应用')}</button><button disabled={busy} onClick={()=>choose(details)}>{t('安装新版 APK')}</button><button className="danger" disabled={busy} onClick={()=>setConfirm({operation:'clear',app:details,title:t('清除应用数据'),description:t('将永久删除该应用的存档和设置,无法撤销。')})}>{t('清除应用数据')}</button></>}{!details.installed&&details.stateKnown!==false&&!details.steam&&<button disabled={busy} onClick={()=>choose(details)}>{t('重新安装 APK')}</button>}<button disabled={busy} onClick={()=>void showLog(details)}><IconFileText size={20}/>{t('日志')}</button><button className="danger" disabled={busy||!!details.pending||details.stateKnown===false||details.steam} onClick={()=>askUninstall(details)}>{t(details.installed?'卸载应用':'清除保留数据')}</button></div>{details.installed&&details.stateKnown!==false&&!details.steam&&<details className="apk-advanced"><summary>{t('高级设置')}</summary><p>{t('默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。')}</p><SwitchRow label={t('自动处理窗口显示')} checked={details.showWindow==null} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked?null:true})}/>{details.showWindow!=null&&<SwitchRow label={t('显示安卓窗口')} checked={details.showWindow} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked})}/>}<p>{t('窗口设置需要重启容器后生效。')}</p><label>{t('启动入口')}<Select label={t('启动入口')} value={details.activity??''} disabled={busy} onChange={activity=>void perform('settings',{app:details.id,activity})} options={[{value:'',label:t('自动选择')},...details.metadata.declaredActivities.map(a=>({value:a,label:a}))]}/></label></details>}<footer><button onClick={()=>setDetails(null)}>{t('完成')}</button></footer></>,t('管理应用'))}
|
||||
{review&&!job&&dialog(<><h2>{t(updating?'确认更新 APK':'确认安装 APK')}</h2><header className="apk-dialog-heading"><PluginImage name={review.metadata.name} src={review.metadata.icon} size={64}/><div><h3>{review.metadata.name}</h3><code>{review.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{updating&&`${updating.metadata.version} → `}{review.metadata.version} ({review.metadata.versionCode})</dd><dt>{t('文件大小')}</dt><dd>{bytes(review.bytes)}</dd><dt>{t('最低 Android SDK')}</dt><dd>{review.metadata.minSdk||t('未声明')}</dd><dt>{t('启动入口')}</dt><dd>{review.metadata.activities.length||t('未发现启动入口')}</dd></dl><label>{t('目标容器')}<Select label={t('目标容器')} value={target} disabled={busy||!!updating} onChange={setTarget} options={[{value:'',label:t('独立容器(默认)')},...data.containers.filter(c=>!c.steam&&(c.id===updating?.context||c.acceptsAdditionalApps!==false||(data.apps.some(a=>a.context===c.id&&a.installed&&a.stateKnown!==false&&a.metadata.package===review.metadata.package)&&!data.apps.some(a=>a.context===c.id&&a.installed&&a.metadata.package!==review.metadata.package)))).map(c=>({value:c.id,label:c.name}))]}/></label>{target&&<p className="banner">{t('操作可能需要停止目标容器,影响其中正在运行的应用。')}</p>}{updating&&<p>{t('同包名更新保留数据;签名不匹配或降级不会自动卸载重装。')}</p>}{updating&&<p className="banner">{t('共享容器的备份需手动恢复;独立侧载容器支持一键恢复。')}</p>}{error&&<p className="error" role="alert">{error}</p>}<footer><button disabled={busy} onClick={()=>void discard()}>{t('取消')}</button><button className="primary" disabled={busy||!!updating&&updating.metadata.package!==review.metadata.package} onClick={()=>void perform('install',{ticket:review.ticket,context:target,app:updating?.id,approve:true})}>{t('确认安装')}</button></footer></>,t('安装 APK'))}
|
||||
{confirm&&!job&&dialog(<><h2>{confirm.title}</h2><p className="apk-confirm-copy">{confirm.description}</p>{confirm.app&&<p><b>{confirm.app.metadata.name}</b><br/><code>{confirm.app.metadata.package}</code></p>}{confirm.operation==='uninstall'&&<SwitchRow label={t('删除应用数据')} description={t('关闭时保留存档和设置。')} checked={!!confirm.purge} disabled={busy} onChange={purge=>setConfirm(c=>c?{...c,purge}:c)}/>}{deletesData&&<p>{t('永久删除的数据无法撤销。')}</p>}{error&&<p className="error">{error}</p>}<footer><button disabled={busy} onClick={()=>setConfirm(null)}>{t('取消')}</button><button className={deletesData||confirm.operation==='uninstall'?'danger':'primary'} disabled={busy} onClick={()=>void perform(confirm.operation,{app:confirm.app?.id,context:confirm.container?.id,backup:confirm.backup,items:confirm.items,purge:confirm.purge,approve:true})}>{t('确认')}</button></footer></>,confirm.title)}
|
||||
{job&&dialog(<><h2>{t(phases[job.phase]??job.phase)}</h2><p>{t('正在处理 APK,请等待实际结果。')}</p><progress value={job.phase==='uploading'?job.received:undefined} max={job.phase==='uploading'?job.total:undefined}/>{job.phase==='uploading'&&<p>{bytes(job.received)} / {bytes(job.total??0)}</p>}{job.cancellable===false?<p>{t('安装已提交,等待 Android 完成;此阶段不能安全取消。')}</p>:<button onClick={()=>{if(job.id==='upload')controller.current?.abort();else void api('job.cancel',{job:job.id}).catch(e=>setError(String(e)));}}>{t('取消')}</button>}</>,t('APK 操作进度'))}
|
||||
{log&&!job&&dialog(<><h2>{t('APK 日志')}</h2><pre>{log.text||t('暂无日志')}</pre><footer><button onClick={()=>setLog(null)}>{t('关闭')}</button><button onClick={downloadLog}>{t('导出日志')}</button></footer></>,t('APK 日志'))}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Framely's existing dark interface, with a quieter file-browser hierarchy. */
|
||||
.frame-files-backdrop{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;padding:28px;background:#090d14b8}
|
||||
.frame-files{--files-muted:#a3adb9;--files-line:#ffffff12;--files-accent:#a8d5f4;width:min(1160px,100%);height:min(690px,calc(100dvh - 56px));display:flex;flex-direction:column;background:#1a2028;color:#edf2f7;border:1px solid #ffffff24;border-radius:18px;box-shadow:0 32px 100px #0008;overflow:hidden;font:18px/1.4 system-ui,-apple-system,sans-serif}
|
||||
.frame-files button{font:inherit;min-height:48px;margin:0;border-radius:10px;white-space:nowrap;transition:background 120ms,color 120ms;border-color:transparent}
|
||||
.frame-files button:focus-visible,.frame-files input:focus-visible{outline:2px solid var(--files-accent);outline-offset:2px}
|
||||
.frame-files button:disabled{opacity:.38}
|
||||
.frame-files header{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:14px 22px;background:#1d242d;border-bottom:1px solid var(--files-line);flex:none}
|
||||
.frame-files-heading{display:flex;align-items:center;gap:14px}.frame-files-heading-icon{display:grid;place-items:center;width:48px;height:48px;border-radius:12px;background:#2a3a49;color:var(--files-accent)}
|
||||
.frame-files header h2{font-size:23px;letter-spacing:-.35px;font-weight:600;line-height:1.2;margin:0 0 5px}.frame-files header small{font-size:15px;color:var(--files-muted)}
|
||||
.frame-files .frame-files-icon-button{width:52px;padding:0;background:transparent;color:#b7c3ce;flex:none}.frame-files .frame-files-icon-button:hover:not(:disabled){background:#ffffff0c;color:#f1f6fb}.frame-files .frame-files-icon-button.active{background:#304456;color:var(--files-accent)}
|
||||
.frame-files-body{display:grid;grid-template-columns:242px minmax(0,1fr);flex:1;min-height:0}
|
||||
.frame-files aside{padding:20px 12px;background:#161c23;border-right:1px solid var(--files-line);overflow:auto}
|
||||
.frame-files-sidebar-label{font-size:14px;font-weight:500;color:var(--files-muted);padding:0 16px;margin:0 0 14px}
|
||||
.frame-files aside button{width:100%;padding:14px 16px;display:flex;align-items:center;justify-content:flex-start;text-align:left;gap:13px;min-height:58px;margin-bottom:5px;background:transparent;color:#b7c1cb;font-size:18px}
|
||||
.frame-files aside button span{min-width:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.frame-files aside button.active{background:#293b4c;color:#c1e4fc}.frame-files aside button:hover:not(:disabled){background:#ffffff09}.frame-files aside button.active:hover:not(:disabled){background:#304456}
|
||||
.frame-files section{min-width:0;min-height:0;padding:16px 22px 10px;display:flex;flex-direction:column;gap:10px}
|
||||
.frame-files-location{display:flex;align-items:center;gap:8px}.frame-files-navigation{display:flex;flex:none;gap:2px}.frame-files-path{display:flex;align-items:center;gap:10px;flex:1;min-width:0;padding:0 8px 0 12px;background:#141a21;border:1px solid #ffffff0f;border-radius:10px;color:var(--files-muted)}
|
||||
.frame-files .frame-files-path input:not([type=checkbox]):not([type=file]){margin:0;min-height:48px;padding:9px 0;flex:1;min-width:0;width:auto;border:0;border-radius:0;background:transparent;color:#ccd6df;font-size:16px;outline-offset:0}
|
||||
.frame-files .frame-files-go{padding:0 9px;min-height:44px;min-width:48px;background:#26303b;color:#c1ccd7;font-size:15px;border-radius:7px}.frame-files .frame-files-go:hover:not(:disabled){background:#364350}
|
||||
.frame-files-toolbar{display:flex;gap:10px;align-items:center}.frame-files-search{display:flex;align-items:center;gap:12px;flex:1;min-width:0;padding:0 15px;background:#141a21;border:1px solid #ffffff12;border-radius:10px;color:#9aabba}
|
||||
.frame-files .frame-files-search input:not([type=checkbox]):not([type=file]){margin:0;padding:10px 0;min-height:48px;min-width:0;border:0;background:transparent;font-size:18px}.frame-files .frame-files-search input::placeholder{color:#9aa9b7}.frame-files-search:focus-within{border-color:#a8d5f477}
|
||||
.frame-files-options-anchor{position:relative}.frame-files-options{position:absolute;right:0;top:calc(100% + 8px);z-index:5;width:330px;padding:12px 18px;background:#252e39;border:1px solid #ffffff24;border-radius:12px;box-shadow:0 12px 35px #0006}.frame-files-options label{display:flex;justify-content:space-between;align-items:center;gap:20px;font-size:17px;margin:0;padding:12px 0}.frame-files-options .ui-switch{min-height:44px}
|
||||
.frame-files-directory{display:flex;justify-content:space-between;align-items:center;padding:8px 4px 2px;min-height:38px}.frame-files-directory h3{margin:0;font-size:21px;font-weight:550;letter-spacing:-.3px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-files-directory small{flex:none;font-size:15px;color:var(--files-muted);margin-left:14px}
|
||||
.frame-files-columns{display:grid;grid-template-columns:minmax(0,1fr) 100px 118px 24px;gap:16px;padding:0 16px 9px;color:var(--files-muted);font-size:14px}.frame-files-columns span:first-child{padding-left:60px}
|
||||
.frame-files-list{flex:1;min-height:0;overflow:auto;padding:2px 2px 4px;scrollbar-gutter:stable}
|
||||
.frame-files .frame-file-row{width:100%;min-height:68px;display:grid;grid-template-columns:44px minmax(0,1fr) 100px 118px 24px;align-items:center;text-align:left;gap:16px;padding:8px 14px;background:transparent;border:1px solid transparent;margin-bottom:3px;border-radius:10px;color:#e2e9ef;white-space:normal}
|
||||
.frame-files .frame-file-row:hover:not(:disabled){background:#ffffff07}.frame-files .frame-file-row.selected{background:#2b4053;border-color:#9bcff42e}.frame-files .frame-file-row.selected:hover:not(:disabled){background:#314a60}
|
||||
.frame-file-symbol{display:grid;place-items:center;width:44px;height:44px;border-radius:10px;background:#252f3a;color:#afbecb}.frame-file-symbol.folder{color:#aecbe0;background:#243442}.frame-file-symbol.apk{color:#b8dcf7;background:#293e52}
|
||||
.frame-file-name{display:flex;flex-direction:column;gap:4px;min-width:0}.frame-file-name b{font-size:18px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-file-name small{font-size:13px;letter-spacing:.2px;color:var(--files-muted)}.frame-file-size,.frame-file-modified{font-size:15px;color:#aab7c3;font-variant-numeric:tabular-nums}.frame-file-check{display:grid;place-items:center;width:24px;height:24px;color:#8ba1b3}.selected .frame-file-check{border-radius:50%;background:var(--files-accent);color:#172635}
|
||||
.frame-files-loading,.frame-files-empty{min-height:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;color:var(--files-muted)}.frame-files-loading p,.frame-files-empty .empty{padding:0;margin:0;font-size:18px}
|
||||
.frame-files footer{border-top:1px solid var(--files-line);background:#1d242d;padding:12px 22px;display:flex;align-items:center;gap:12px;flex:none}
|
||||
.frame-files-selection{flex:1;display:flex;align-items:center;gap:12px;min-width:0;color:#aab7c3;font-size:16px}.frame-files-selection>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-files-selection small{display:block;font-size:13px;color:var(--files-muted);margin-top:3px}.frame-files-selection-icon{width:38px;height:38px;display:grid;place-items:center;background:#27313c;border-radius:10px;flex:none}.frame-files-selection.has-selection{color:#e3edf5}.has-selection .frame-files-selection-icon{background:#2b4053;color:var(--files-accent)}
|
||||
.frame-files .frame-files-cancel{padding:10px 23px;background:#2a333e;color:#d6e1e9}.frame-files .frame-files-cancel:hover:not(:disabled){background:#36424f}.frame-files .frame-files-confirm{min-width:112px;padding:10px 25px;background:var(--files-accent);color:#132637;font-weight:600}.frame-files .frame-files-confirm:hover:not(:disabled){background:#c7e8ff}
|
||||
.frame-files-pages{display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--files-muted)}.frame-files-pages button{font-size:16px;background:#27313c;padding:8px 16px}.frame-files .error{margin:0;display:flex;align-items:center;gap:12px;justify-content:space-between;padding:10px 12px;background:#3b262d;border-radius:8px;font-size:16px}
|
||||
@media(prefers-reduced-motion:no-preference){.frame-files{animation:frame-files-enter 160ms ease-out}@keyframes frame-files-enter{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}}
|
||||
@media(prefers-reduced-motion:reduce){.frame-files button{transition:none}}
|
||||
@media(max-width:1024px){.frame-files-body{grid-template-columns:190px minmax(0,1fr)}.frame-files section{padding:16px}.frame-files-columns{grid-template-columns:minmax(0,1fr) 86px 24px}.frame-files-columns span:nth-child(3){display:none}.frame-files .frame-file-row{grid-template-columns:44px minmax(0,1fr) 86px 24px}.frame-file-modified{display:none}.frame-files aside button{padding:14px 12px;font-size:17px}}
|
||||
@media(max-width:768px){.frame-files-backdrop{padding:12px}.frame-files{height:calc(100dvh - 24px);border-radius:12px}.frame-files-body{grid-template-columns:150px minmax(0,1fr)}.frame-files-navigation{display:none}.frame-files aside{padding:18px 8px}.frame-files aside button{gap:8px;font-size:16px}.frame-files aside button>svg:last-child:not(:first-child){display:none}.frame-files header{padding:16px}.frame-files footer{padding:14px 16px}.frame-files-selection-icon{display:none}.frame-files-options{width:290px}.frame-files .frame-file-row{grid-template-columns:36px minmax(0,1fr) 24px;gap:10px}.frame-file-symbol{width:36px;height:44px}.frame-file-size{display:none}.frame-files-columns{grid-template-columns:minmax(0,1fr) 24px}.frame-files-columns span:nth-child(2){display:none}.frame-files-columns span:first-child{padding-left:46px}}
|
||||
.frame-folder-tree{width:100%;min-width:0}.frame-folder-row{display:flex;align-items:center;min-height:48px;border-radius:8px;margin-bottom:3px;padding-right:8px;color:#b8c6d1}.frame-folder-row.active{background:#293f52;color:#c9e9ff}.frame-files .frame-folder-row>button{margin:0;padding:0;background:transparent;color:inherit;min-height:48px;border:0;border-radius:6px}.frame-files .frame-folder-toggle{width:30px;flex:none;justify-content:center}.frame-files .frame-folder-name{flex:1;min-width:0;display:flex;align-items:center;justify-content:flex-start;gap:10px;text-align:left;font-size:17px}.frame-folder-name span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-files .frame-folder-row>button:hover:not(:disabled){background:#ffffff08}.frame-folder-toggle svg{transition:transform 120ms}.frame-folder-status{display:flex;align-items:center;gap:8px;min-height:36px;font-size:14px;color:var(--files-muted)}.frame-files aside .frame-folder-retry,.frame-files aside .frame-folder-more{width:auto;max-width:100%;font-size:14px;min-height:44px;margin:2px 0 6px 28px;padding:6px 10px;white-space:normal}.frame-files aside .frame-folder-name.active{background:transparent}@media(prefers-reduced-motion:reduce){.frame-folder-toggle svg{transition:none}}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {IconArrowLeft,IconArrowUp,IconFolder,IconFile,IconRefresh,IconSearch,IconX,IconCheck,IconFolderOpen,IconAdjustmentsHorizontal,IconPackage,IconFileCode,IconChevronRight} from '@tabler/icons-react';
|
||||
import {t} from './i18n';
|
||||
import {Switch} from './switch';
|
||||
import {FolderTree} from './folder-tree';
|
||||
import './file-picker.css';
|
||||
type Request={id:string;accept:string[];multiple:boolean;directory:boolean;defaultPath?:string};
|
||||
type Entry={name:string;path:string;directory:boolean;size:number;modified?:number};
|
||||
type Listing={path:string;parent:string|null;roots:{name:string;path:string}[];entries:Entry[];total:number;offset:number;skipped:number};
|
||||
async function files<T>(operation:string,params:unknown,signal?:AbortSignal):Promise<T>{
|
||||
const response=await fetch(`/host/files/${operation}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(params),signal});
|
||||
if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));
|
||||
const data=await response.json();if(data.error)throw new Error(t(data.error));return data.result;
|
||||
}
|
||||
function extensions(accept:string[]){const mapped:Record<string,string[]>={'application/json':['.json'],'application/vnd.android.package-archive':['.apk']};return [...new Set(accept.flatMap(a=>a.split(/[;,]/)).flatMap(a=>a.startsWith('.')?[a.toLowerCase()]:mapped[a]??[]))];}
|
||||
function size(bytes:number){return bytes<1024?`${bytes} B`:bytes<1048576?`${(bytes/1024).toFixed(1)} KiB`:`${(bytes/1048576).toFixed(1)} MiB`;}
|
||||
export function FrameFilePicker(){
|
||||
const [request,setRequest]=useState<Request|null>(null);
|
||||
useEffect(()=>{const open=(e:Event)=>{const detail=(e as CustomEvent<Request>).detail;if(detail?.id&&Array.isArray(detail.accept))setRequest(detail);};window.addEventListener('framely.fileDialog',open);return()=>window.removeEventListener('framely.fileDialog',open);},[]);
|
||||
return request?createPortal(<FileDialog key={request.id} request={request} close={()=>setRequest(null)}/>,document.body):null;
|
||||
}
|
||||
function FileDialog({request,close}:{request:Request;close:()=>void}){
|
||||
const [path,setPath]=useState(()=>{try{return sessionStorage.getItem('framely.filePicker.path')??'';}catch{return '';}}),[address,setAddress]=useState(''),[query,setQuery]=useState(''),[search,setSearch]=useState(''),[hidden,setHidden]=useState(false),[onlySupported,setOnlySupported]=useState(true),[optionsOpen,setOptionsOpen]=useState(false),[offset,setOffset]=useState(0),[listing,setListing]=useState<Listing|null>(null),[selected,setSelected]=useState<Entry[]>([]),[loading,setLoading]=useState(true),[busy,setBusy]=useState(false),[error,setError]=useState(''),[revision,setRevision]=useState(0),[treeRevision,setTreeRevision]=useState(0);
|
||||
const initial=useRef(true),history=useRef<string[]>([]),dialog=useRef<HTMLDivElement>(null),previousFocus=useRef<HTMLElement|null>(null),done=useRef(false),completing=useRef(false),requestSequence=useRef(0),cancelRef=useRef(()=>{});
|
||||
const allowed=extensions(request.accept),supported=(entry:Entry)=>!request.directory&&(!allowed.length||allowed.some(ext=>entry.name.toLowerCase().endsWith(ext)));
|
||||
const entries=listing?.entries.filter(e=>e.directory||!onlySupported||supported(e))??[];
|
||||
async function complete(paths:string[]){if(completing.current)return;completing.current=true;setBusy(true);setError('');try{await files('complete',{id:request.id,paths,directory:request.directory});done.current=true;if(paths.length&&listing){try{sessionStorage.setItem('framely.filePicker.path',listing.path);}catch{}}close();}catch(e){completing.current=false;setError(String(e));setBusy(false);}}
|
||||
cancelRef.current=()=>{void complete([]);};
|
||||
useEffect(()=>{previousFocus.current=document.activeElement as HTMLElement;const root=document.getElementById('root');const wasInert=root?.inert??false;if(root)root.inert=true;dialog.current?.querySelector<HTMLButtonElement>('button')?.focus();const key=(e:Event)=>{if(e instanceof KeyboardEvent){if(e.key==='Escape'){e.preventDefault();e.stopImmediatePropagation();cancelRef.current();}else if(e.key==='Tab'){const nodes=Array.from(dialog.current?.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),[tabindex="0"]')??[]).filter(n=>n.getClientRects().length);if(!nodes.length)return;if(e.shiftKey&&document.activeElement===nodes[0]){e.preventDefault();nodes.at(-1)?.focus();}else if(!e.shiftKey&&document.activeElement===nodes.at(-1)){e.preventDefault();nodes[0].focus();}}}else{e.stopImmediatePropagation();cancelRef.current();}};window.addEventListener('keydown',key,true);window.addEventListener('framely.back',key,true);return()=>{window.removeEventListener('keydown',key,true);window.removeEventListener('framely.back',key,true);if(root)root.inert=wasInert;previousFocus.current?.focus();if(!done.current)void files('complete',{id:request.id,paths:[]}).catch(()=>{});};},[]);
|
||||
useEffect(()=>{const timer=setTimeout(()=>{setQuery(search);setOffset(0);},180);return()=>clearTimeout(timer);},[search]);
|
||||
useEffect(()=>{const abort=new AbortController(),sequence=++requestSequence.current;setLoading(true);setError('');void files<Listing>('list',{path,query,hidden,offset,extensions:onlySupported&&!request.directory?allowed:[],directoryOnly:request.directory},abort.signal).then(data=>{if(sequence!==requestSequence.current)return;initial.current=false;setListing(data);setAddress(data.path);setSelected([]);}).catch(e=>{if(!abort.signal.aborted){if(initial.current&&path){initial.current=false;setPath('');}else setError(String(e));}}).finally(()=>{if(sequence===requestSequence.current&&!abort.signal.aborted)setLoading(false);});return()=>abort.abort();},[path,query,hidden,offset,revision,onlySupported]);
|
||||
function navigate(next:string,back=false){if(listing&&!back&&listing.path!==next)history.current.push(listing.path);setPath(next);setOffset(0);setSearch('');setQuery('');setSelected([]);setRevision(r=>r+1);}
|
||||
function choose(entry:Entry){if(entry.directory){navigate(entry.path);return;}if(!supported(entry))return;setSelected(s=>request.multiple?(s.some(e=>e.path===entry.path)?s.filter(e=>e.path!==entry.path):s.length<64?[...s,entry]:s):[entry]);}
|
||||
const canSelect=request.directory?!!listing&&!loading&&!error:!!selected.length&&!loading&&!error;
|
||||
const title=listing?.path.split('/').filter(Boolean).at(-1)??t('选择文件');
|
||||
return <div className="frame-files-backdrop" onPointerDown={e=>{if(e.target===e.currentTarget)cancelRef.current();}}><div ref={dialog} className="frame-files" role="dialog" aria-modal="true" aria-label={t(request.directory?'选择目录':'选择文件')}>
|
||||
<header><div className="frame-files-heading"><span className="frame-files-heading-icon"><IconFolderOpen size={26} stroke={1.6}/></span><div><h2>{t(request.directory?'选择目录':'选择文件')}</h2><small>{request.directory?t('浏览 Frame 上的目录'):allowed.length?`${t('支持的文件类型')} ${allowed.join(' · ')}`:t('浏览 Frame 上的文件')}</small></div></div><button className="frame-files-icon-button" aria-label={t('关闭')} disabled={busy} onClick={()=>cancelRef.current()}><IconX size={23} stroke={1.6}/></button></header>
|
||||
<div className="frame-files-body"><aside aria-label={t('文件夹树')}><p className="frame-files-sidebar-label">{t('文件夹树')}</p><FolderTree roots={listing?.roots??[]} path={listing?.path??''} hidden={hidden} refresh={treeRevision} onNavigate={navigate} read={(path,offset,hidden,signal)=>files<Listing>('list',{path,offset,hidden,directoryOnly:true},signal)}/></aside><section>
|
||||
<div className="frame-files-location"><div className="frame-files-navigation"><button className="frame-files-icon-button" aria-label={t('返回上一个目录')} disabled={busy||!history.current.length} onClick={()=>navigate(history.current.pop()!,true)}><IconArrowLeft size={22} stroke={1.7}/></button><button className="frame-files-icon-button" aria-label={t('上级目录')} disabled={busy||!listing?.parent} onClick={()=>navigate(listing!.parent!)}><IconArrowUp size={22} stroke={1.7}/></button></div><div className="frame-files-path"><IconFolder size={20} stroke={1.6}/><input aria-label={t('目录路径')} value={address} disabled={busy} onChange={e=>setAddress(e.target.value)} onKeyDown={e=>{if(e.key==='Enter')navigate(address);}}/><button className="frame-files-go" disabled={busy||!address.trim()} onClick={()=>navigate(address)}>{t('前往')}</button></div><button className="frame-files-icon-button" aria-label={t('刷新')} disabled={busy} onClick={()=>{setRevision(r=>r+1);setTreeRevision(r=>r+1);}}><IconRefresh size={22} stroke={1.7}/></button></div>
|
||||
<div className="frame-files-toolbar"><div className="frame-files-search"><IconSearch size={22} stroke={1.6}/><input type="search" aria-label={t('搜索当前目录')} placeholder={t('搜索当前目录')} value={search} disabled={busy} onChange={e=>setSearch(e.target.value)}/></div><div className="frame-files-options-anchor"><button className={`frame-files-icon-button ${optionsOpen?'active':''}`} aria-label={t('显示选项')} aria-expanded={optionsOpen} disabled={busy} onClick={()=>setOptionsOpen(v=>!v)}><IconAdjustmentsHorizontal size={24} stroke={1.6}/></button>{optionsOpen&&<div className="frame-files-options"><label><span>{t('显示隐藏文件')}</span><Switch label={t('显示隐藏文件')} checked={hidden} disabled={busy} onChange={v=>{setOffset(0);setHidden(v);}}/></label>{!!allowed.length&&!request.directory&&<label><span>{t('仅显示支持的文件')}</span><Switch label={t('仅显示支持的文件')} checked={onlySupported} disabled={busy} onChange={v=>{setOffset(0);setOnlySupported(v);}}/></label>}</div>}</div></div>
|
||||
<div className="frame-files-directory"><h3>{title}</h3><small>{loading?t('正在读取目录…'):listing?.total===1?t('1 个项目'):t('{0} 个项目',{0:listing?.total??0})}</small></div>
|
||||
{error&&<p role="alert" className="error">{error}<button onClick={()=>setRevision(r=>r+1)} disabled={busy}>{t('重试')}</button></p>}
|
||||
<div className="frame-files-columns" aria-hidden="true"><span>{t('名称')}</span><span>{t('文件大小')}</span><span>{t('修改时间')}</span><span/></div>
|
||||
<div className="frame-files-list" aria-busy={loading}>{loading?<div className="frame-files-loading" role="status"><IconRefresh size={28}/><p>{t('正在读取目录…')}</p></div>:entries.map(entry=>{const active=selected.some(s=>s.path===entry.path),extension=entry.name.toLowerCase().split('.').at(-1),FileIcon=entry.directory?IconFolder:extension==='apk'?IconPackage:extension==='json'?IconFileCode:IconFile;return <button key={entry.path} title={entry.name} className={`frame-file-row ${active?'selected':''}`} aria-pressed={!entry.directory?active:undefined} disabled={busy||(!entry.directory&&!supported(entry))} onClick={()=>{setOptionsOpen(false);choose(entry);}} onDoubleClick={()=>{if(!entry.directory&&supported(entry))void complete([entry.path]);}}><span className={`frame-file-symbol ${entry.directory?'folder':extension==='apk'?'apk':''}`}><FileIcon size={28} stroke={1.55}/></span><span className="frame-file-name"><b>{entry.name}</b><small>{entry.directory?t('文件夹'):extension==='apk'?'APK':extension?.toUpperCase()}</small></span><span className="frame-file-size">{entry.directory?'':size(entry.size)}</span><span className="frame-file-modified">{entry.modified?new Date(entry.modified*1000).toLocaleDateString(document.documentElement.lang||undefined):''}</span><span className="frame-file-check">{entry.directory?<IconChevronRight size={19} stroke={1.6}/>:active?<IconCheck size={21} stroke={2}/>:null}</span></button>})}{!loading&&!entries.length&&!error&&<div className="frame-files-empty"><IconSearch size={36} stroke={1.4}/><p className="empty">{t('当前目录没有匹配的文件')}</p></div>}</div>
|
||||
{!!listing&&listing.total>200&&<div className="frame-files-pages"><button disabled={busy||loading||offset===0} onClick={()=>setOffset(v=>Math.max(0,v-200))}>{t('上一页')}</button><span>{offset+1}-{Math.min(offset+200,listing.total)} / {listing.total}</span><button disabled={busy||loading||offset+200>=listing.total} onClick={()=>setOffset(v=>v+200)}>{t('下一页')}</button></div>}
|
||||
{!!listing?.skipped&&<small>{t('部分项目无法访问,已跳过。')}</small>}
|
||||
</section></div><footer><div className={`frame-files-selection ${canSelect?'has-selection':''}`}><span className="frame-files-selection-icon">{canSelect?<IconCheck size={22}/>:<IconFile size={22} stroke={1.5}/>}</span><span>{request.directory?listing?.path:selected.length===1?selected[0].name:selected.length?t('已选择 {0} 个文件',{0:selected.length}):t('请选择要导入的文件')}{selected.length===1&&<small>{size(selected[0].size)}</small>}</span></div><button className="frame-files-cancel" disabled={busy} onClick={()=>cancelRef.current()}>{t('取消')}</button><button className="frame-files-confirm" disabled={busy||!canSelect} onClick={()=>void complete(request.directory?[listing!.path]:selected.map(e=>e.path))}>{busy?t('正在处理…'):t('选择')}</button></footer>
|
||||
</div></div>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React,{useEffect,useMemo,useRef,useState} from 'react';
|
||||
import {IconChevronRight,IconFolder,IconFolderOpen,IconHome,IconDeviceSdCard,IconRefresh} from '@tabler/icons-react';
|
||||
import {t} from './i18n';
|
||||
type Folder={name:string;path:string;directory:boolean};
|
||||
type Page={entries:Folder[];total:number};
|
||||
type NodeState={children:Folder[];total:number;loading:boolean;error?:string;loaded:boolean};
|
||||
type Root={name:string;path:string};
|
||||
export function FolderTree({roots,path,hidden,refresh,onNavigate,read}:{roots:Root[];path:string;hidden:boolean;refresh:number;onNavigate:(path:string)=>void;read:(path:string,offset:number,hidden:boolean,signal:AbortSignal)=>Promise<Page>}){
|
||||
const rootKey=JSON.stringify(roots),topRoots=useMemo(()=>roots.filter((root,index)=>!roots.some((other,j)=>other.path===root.path?j<index:root.path.startsWith(other.path+'/'))),[rootKey]);
|
||||
const [nodes,setNodes]=useState<Record<string,NodeState>>({}),[expanded,setExpanded]=useState<Set<string>>(new Set());
|
||||
const cache=useRef<Record<string,NodeState>>({}),requests=useRef(new Map<string,AbortController>()),reader=useRef(read),generation=useRef(0);reader.current=read;
|
||||
const update=(key:string,value:NodeState)=>{cache.current={...cache.current,[key]:value};setNodes(cache.current);};
|
||||
async function load(key:string,more=false){const previous=cache.current[key];if(previous?.loading||previous?.loaded&&!more&&!previous.error)return;const controller=new AbortController();requests.current.set(key,controller);const currentGeneration=generation.current,children=more?previous?.children??[]:[];update(key,{children,total:previous?.total??0,loading:true,loaded:previous?.loaded??false});try{const result=await reader.current(key,children.length,hidden,controller.signal);if(controller.signal.aborted||generation.current!==currentGeneration)return;const existing=new Set(children.map(c=>c.path));update(key,{children:[...children,...result.entries.filter(e=>e.directory&&!existing.has(e.path))],total:result.total,loading:false,loaded:true});}catch(e){if(!controller.signal.aborted&&generation.current===currentGeneration)update(key,{children,total:previous?.total??0,loading:false,loaded:false,error:String(e)});}finally{if(requests.current.get(key)===controller)requests.current.delete(key);}}
|
||||
useEffect(()=>{generation.current++;for(const controller of requests.current.values())controller.abort();requests.current.clear();cache.current={};setNodes({});},[hidden,rootKey,refresh]);
|
||||
useEffect(()=>{const root=topRoots.find(root=>path===root.path||path.startsWith(root.path+'/'));if(!root)return;const ancestors=[root.path];let next=root.path;for(const segment of path.slice(root.path.length).split('/').filter(Boolean)){next+='/'+segment;ancestors.push(next);}setExpanded(current=>new Set([...current,...ancestors]));for(const ancestor of new Set([...expanded,...ancestors])){if(topRoots.some(root=>ancestor===root.path||ancestor.startsWith(root.path+'/')))void load(ancestor);}},[path,hidden,rootKey,refresh]);
|
||||
useEffect(()=>()=>{generation.current++;for(const controller of requests.current.values())controller.abort();},[]);
|
||||
function toggle(key:string){const open=!expanded.has(key);setExpanded(current=>{const next=new Set(current);if(open)next.add(key);else next.delete(key);return next;});if(open)void load(key);}
|
||||
function branch(folder:Root,depth:number):React.ReactNode{const label=depth===0?t(folder.name):folder.name,state=nodes[folder.path],open=expanded.has(folder.path),selected=folder.path===path,hasChildren=!state?.loaded||!!state.children.length||path.startsWith(folder.path+'/'),FolderIcon=depth===0?(folder.name==='主目录'?IconHome:folder.path.startsWith('/run/media/')?IconDeviceSdCard:IconFolder):open?IconFolderOpen:IconFolder;
|
||||
const children=[...(state?.children??[])];if(path.startsWith(folder.path+'/')){const name=path.slice(folder.path.length+1).split('/')[0],childPath=folder.path+'/'+name;if(!children.some(child=>child.path===childPath))children.push({name,path:childPath,directory:true});}
|
||||
return <div key={folder.path} role="treeitem" aria-label={label} aria-selected={selected} aria-expanded={hasChildren?open:undefined} aria-level={depth+1}><div className={`frame-folder-row ${selected?'active':''}`} style={{paddingLeft:8+depth*18}}><button className="frame-folder-toggle" aria-label={t(open?'收起 {0}':'展开 {0}',{0:label})} disabled={!hasChildren} onClick={()=>toggle(folder.path)}><IconChevronRight size={17} stroke={1.8} style={{transform:open?'rotate(90deg)':'none',visibility:hasChildren?'visible':'hidden'}}/></button><button className="frame-folder-name" title={label} onClick={()=>onNavigate(folder.path)}><FolderIcon size={21} stroke={1.6}/><span>{label}</span></button></div>{open&&<div role="group">{state?.loading&&!state.children.length&&<div className="frame-folder-status" style={{paddingLeft:30+depth*18}}><IconRefresh size={16}/><span>{t('正在读取')}</span></div>}{children.map(child=>branch(child,depth+1))}{state?.error&&<button className="frame-folder-retry" title={state.error} onClick={()=>void load(folder.path)}>{t('无法读取目录')} · {t('重试')}</button>}{state?.loaded&&state.children.length<state.total&&<button className="frame-folder-more" disabled={state.loading} onClick={()=>void load(folder.path,true)}>{state.loading?t('正在读取'):t('加载更多目录')}</button>}</div>}</div>;
|
||||
}
|
||||
return <div className="frame-folder-tree" role="tree" aria-label={t('文件夹树')}>{topRoots.map(root=>branch(root,0))}</div>;
|
||||
}
|
||||
+16
-3
@@ -42,7 +42,7 @@
|
||||
.launcher-shell button:disabled{opacity:.4;cursor:default}.launcher-shell button:not(.launch-icon):focus-visible{outline:2px solid var(--launch-blue);outline-offset:4px}
|
||||
.launcher-shell .modal-backdrop{position:absolute;inset:0;background:#101d2b65;z-index:5;display:grid;place-items:center}.launcher-shell .modal{position:relative;transform:none;inset:auto;width:560px;max-width:90%;margin:0;border-radius:22px}
|
||||
@keyframes launcher-menu-in{from{opacity:0;translate:0 6px}to{opacity:1;translate:0 0}}
|
||||
@media(max-width:900px){.launcher-shell{padding:65px 22px 80px 96px}.launcher-grid{width:calc(100% - 176px)}.launcher-page,.launcher-adjacent-page{gap:8px}.launch-icon{width:92px;height:92px;min-height:92px}.launch-disc{width:82px;height:82px}.launch-name{font-size:15px;height:39px}.launch-monogram{font-size:26px}.launcher-error,.launcher-shell>.banner{left:96px;right:22px}.launcher-pages{left:50%}.launcher-drag-edge.right{right:88px}}
|
||||
@media(max-width:900px){.launcher-shell{padding:65px 22px 80px 96px}.launcher-grid{width:calc(100% - 176px)}.launcher-page,.launcher-adjacent-page{gap:8px}.launch-icon{width:92px;height:92px;min-height:92px}.launch-disc{width:82px;height:82px}.launch-name{font-size:15px;height:39px}.launch-monogram{font-size:26px}.launcher-error,.launcher-shell>.banner{left:96px;right:22px}.launcher-pages{left:50%}}
|
||||
@media(prefers-reduced-transparency:reduce){.launch-disc{background:#273644}.launcher-tools{background:#273644}.launch-menu{background:#20303d}}
|
||||
@media(prefers-reduced-motion:reduce){.launch-disc,.launch-favorite,.launcher-category-label,:is(.launcher-filters,.launcher-shortcuts) button,.launcher-category-active,.launcher-pages button span{transition:none}.launch-menu,.launch-item{animation:none}}
|
||||
.launcher-settings label{display:flex;align-items:center;justify-content:space-between;padding:16px 0;width:100%;gap:24px;border-bottom:1px solid #ffffff10}.launcher-settings label small{display:block;color:#a1adb7;margin-top:6px}.launcher-settings input[type=checkbox]{width:26px;height:26px}.launcher-settings input[type=number]{width:100px;padding:10px;background:#111a22;color:#fff;border:1px solid #44515c;border-radius:8px;font:inherit}
|
||||
@@ -51,13 +51,13 @@
|
||||
.launcher-edit-toolbar span,.launcher-edit-toolbar button{display:flex;align-items:center;gap:10px}.launcher-edit-toolbar button{border-radius:32px;padding:10px 20px;background:#a7bac72b;border:1px solid #e8f4ff30;color:#d9e3ec;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b}
|
||||
.launcher-editing .launch-disc{animation:launcher-wiggle .32s ease-in-out infinite alternate}.launcher-editing .launch-item:nth-child(2n) .launch-disc{animation-direction:alternate-reverse}
|
||||
.launch-item-dragged .launch-disc{opacity:.22;animation:none}.launcher-drag-ghost{position:fixed;transform:translate(-50%,-50%) scale(1.1);pointer-events:none;z-index:7;filter:drop-shadow(0 12px 18px #06132066)}
|
||||
.launcher-drag-edge{position:absolute;top:150px;bottom:150px;width:8px;border-radius:8px;background:#b4dbf477;pointer-events:none;z-index:6}.launcher-drag-edge.left{left:max(45px,96px)}.launcher-drag-edge.right{right:45px}
|
||||
.launcher-drag-edge{pointer-events:none;z-index:6}
|
||||
@keyframes launcher-wiggle{from{rotate:-1.4deg}to{rotate:1.4deg}}
|
||||
@media(prefers-reduced-motion:reduce){.launcher-editing .launch-disc{animation:none}}
|
||||
|
||||
.launcher-edit-toolbar button:hover:not(:disabled){background:#e3f1ff26;color:#f3f8fc}
|
||||
|
||||
@media(max-width:900px){.launcher-drag-edge.right{right:88px}}
|
||||
|
||||
|
||||
.launcher-panning{overflow:hidden}.launcher-adjacent-page{pointer-events:none}.launcher-adjacent-page .launch-icon{display:block}.launcher-panning .launcher-page{will-change:translate}
|
||||
/* Search shares the category capsule material without moving the icon canvas. */
|
||||
@@ -76,3 +76,16 @@
|
||||
|
||||
.launcher-shortcuts{left:auto;right:0}
|
||||
.launcher-shortcuts .launcher-category-label{left:auto;right:calc(100% + 20px)}
|
||||
|
||||
/* These viewport boundaries also drive icon clamping and dwell paging. */
|
||||
/* Apple-inspired spatial edge shading, not Apple's native material.
|
||||
The invisible 8px boundary remains exact; feathered shading faces the grid. */
|
||||
.launcher-shell .launcher-drag-edge{position:fixed;right:auto;bottom:auto;width:8px;background:transparent;border:0;box-shadow:none}
|
||||
.launcher-drag-edge::before,.launcher-drag-edge::after{content:"";position:absolute;top:8%;bottom:8%;left:-4px;width:76px;pointer-events:none;opacity:.35;transform-origin:left center;transition:opacity 220ms,transform 220ms}
|
||||
.launcher-drag-edge::before{background:radial-gradient(ellipse at 0% 50%,#d9eaf744 0%,#c6e0f021 30%,#b4dbf408 58%,transparent 78%)}
|
||||
.launcher-drag-edge::after{left:-12px;width:62px;background:radial-gradient(ellipse at 20% 50%,#10213252 0%,#10213219 45%,transparent 75%)}
|
||||
.launcher-drag-edge.right::before{left:auto;right:-4px;transform-origin:right center;background:radial-gradient(ellipse at 100% 50%,#d9eaf744 0%,#c6e0f021 30%,#b4dbf408 58%,transparent 78%)}
|
||||
.launcher-drag-edge.right::after{left:auto;right:-12px;transform-origin:right center;background:radial-gradient(ellipse at 80% 50%,#10213252 0%,#10213219 45%,transparent 75%)}
|
||||
.launcher-drag-edge.active::before{opacity:1;transform:scaleX(1.18)}.launcher-drag-edge.active::after{opacity:.8;transform:scaleX(1.12)}
|
||||
@media(prefers-reduced-motion:reduce){.launcher-drag-edge::before,.launcher-drag-edge::after{transition:none}}
|
||||
@media(prefers-reduced-transparency:reduce){.launcher-drag-edge::before{opacity:.8}.launcher-drag-edge.active::before{opacity:1}}
|
||||
+28
-16
@@ -2,7 +2,7 @@ import {SwitchRow} from './switch';
|
||||
import React,{useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
|
||||
import {api} from './api';
|
||||
import {t} from './i18n';
|
||||
import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck,IconSearch,IconX,IconApps,IconSettings} from '@tabler/icons-react';
|
||||
import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck,IconSearch,IconX,IconApps,IconSettings,IconPackageImport} from '@tabler/icons-react';
|
||||
import {installedIcon} from './store';
|
||||
import {PluginFrame} from './plugin-frame';
|
||||
import type {Plugin} from './main';
|
||||
@@ -11,6 +11,10 @@ import {matchesSearch,type SearchIndex} from './launcher-search';
|
||||
export type LauncherConfig={primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
|
||||
const defaults:LauncherConfig={primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
|
||||
type SteamApp={id:number;name:string;icon?:string;iconFit?:string;kind?:string};
|
||||
function launcherCells(total:number){
|
||||
const counts=[Math.min(Math.max(total-5,0),4),Math.min(total,5),Math.max(total-9,0)];
|
||||
return counts.flatMap((count,row)=>Array.from({length:count},(_,col)=>({row,column:(6-(total<=5?count:row===1?5:4))*2+1+col*4})));
|
||||
}
|
||||
function LauncherArtwork({src,name,kind,fit}:{src?:string|null;name:string;kind:string;fit?:string}){
|
||||
const [failed,setFailed]=useState(false);
|
||||
useEffect(()=>setFailed(false),[src]);
|
||||
@@ -39,7 +43,7 @@ async function launcherRequest<T=any>(method:string,params:unknown={}):Promise<T
|
||||
export function Launcher({plugins,safeMode,config=defaults,favorites,localFavorites=emptyOrder,order=emptyOrder,refresh}:{plugins:Record<string,Plugin>;safeMode:boolean;config?:LauncherConfig;favorites:number[];localFavorites?:string[];order?:string[];refresh:()=>Promise<void>}){
|
||||
const remembered=useRef<string|null>(null);
|
||||
const[query,setQuery]=useState(''),[indexes,setIndexes]=useState<Record<string,SearchIndex>>({}),keyboard=useRef(false),opening=useRef(true),props=useRef({plugins,favorites,localFavorites});props.current={plugins,favorites,localFavorites};
|
||||
const[steam,setSteam]=useState<SteamApp[]>([]),[local,setLocal]=useState<LocalApp[]>([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[page,setPage]=useState(0),[menu,setMenu]=useState<string|null>(null),[busyKeys,setBusyKeys]=useState<Set<string>>(new Set()),[uninstall,setUninstall]=useState<(Plugin&{dependents:string[];fingerprint:string})|null>(null),[frontends,setFrontends]=useState<Record<string,{plugin:string;action:string;context:any;ticket:number}>>({}),[outside,setOutside]=useState(false),[editing,setEditing]=useState(false),[manual,setManual]=useState(order),[dragging,setDragging]=useState<string|null>(null),[ghost,setGhost]=useState<{item:LaunchItem;x:number;y:number}|null>(null),[edge,setEdge]=useState(0),[panView,setPanView]=useState<{offset:number;direction:number;width:number;settling:boolean}|null>(null);
|
||||
const[steam,setSteam]=useState<SteamApp[]>([]),[local,setLocal]=useState<LocalApp[]>([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[page,setPage]=useState(0),[menu,setMenu]=useState<string|null>(null),[busyKeys,setBusyKeys]=useState<Set<string>>(new Set()),[uninstall,setUninstall]=useState<(Plugin&{dependents:string[];fingerprint:string})|null>(null),[frontends,setFrontends]=useState<Record<string,{plugin:string;action:string;context:any;ticket:number}>>({}),[outside,setOutside]=useState(false),[editing,setEditing]=useState(false),[manual,setManual]=useState(order),[dragging,setDragging]=useState<string|null>(null),[ghost,setGhost]=useState<{item:LaunchItem;x:number;y:number;left:number;right:number;top:number;bottom:number}|null>(null),[edge,setEdge]=useState(0),[panView,setPanView]=useState<{offset:number;direction:number;width:number;settling:boolean}|null>(null);
|
||||
const panTimer=useRef<ReturnType<typeof setTimeout>|null>(null),skipPageAnimation=useRef(false),grid=useRef<HTMLDivElement>(null),pageContent=useRef<HTMLDivElement>(null),swipe=useRef<{x:number;y:number;active?:boolean;dx?:number}|null>(null),shell=useRef<HTMLDivElement>(null),hold=useRef<ReturnType<typeof setTimeout>|null>(null),editHold=useRef<ReturnType<typeof setTimeout>|null>(null),edgeTimer=useRef<ReturnType<typeof setTimeout>|null>(null),long=useRef(false),point=useRef({x:0,y:0}),holdTarget=useRef<string|null>(null),mounted=useRef(true),operations=useRef(new Map<string,{started:number;expire:()=>void}>()),actionSequence=useRef(0),menuRef=useRef<HTMLDivElement>(null),drag=useRef<{key:string;before:string[];order:string[];moved:boolean;over:string|null;edge:number;lastX:number;lastY:number;startedInEdit:boolean}|null>(null),state=useRef({editing:false,current:0,pages:1,filter:'all',panning:false,items:[] as LaunchItem[],all:[] as LaunchItem[]});
|
||||
useEffect(()=>{if(!drag.current)setManual(order);},[order]);
|
||||
function selectCategory(key:string){remembered.current=key;opening.current=false;setFilter(key);}
|
||||
@@ -53,7 +57,12 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
|
||||
function close(){if(keyboard.current)return;cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void launcherRequest('host.launcher.close');}
|
||||
function settleSwipe(gesture:{dx?:number;active?:boolean}){if(!gesture.active)return;const s=state.current,width=grid.current?.clientWidth??1300,dx=gesture.dx??0,direction=dx<0?1:-1,next=s.current+direction,valid=next>=0&&next<s.pages,advance=valid&&Math.abs(dx)>=70;setPanView({offset:advance?-direction*width:0,direction,width,settling:true});long.current=true;panTimer.current=setTimeout(()=>{panTimer.current=null;if(advance){skipPageAnimation.current=true;setPage(next);}setPanView(null);},240);}
|
||||
|
||||
useEffect(()=>{const back=()=>close();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')close();};window.addEventListener('framely.back',back);window.addEventListener('keydown',key);const navigate=(e:Event)=>{if(keyboard.current||drag.current||state.current.panning)return;const direction=(e as CustomEvent<number>).detail;const s=state.current;setMenu(null);if(Math.abs(direction)===1)setPage(Math.max(0,Math.min(s.pages-1,s.current+direction)));else if(Math.abs(direction)===2){const index=categories.findIndex(c=>c.key===s.filter);selectCategory(categories[Math.max(0,Math.min(categories.length-1,index+(direction>0?-1:1)))].key);setPage(0);}};window.addEventListener('framely.launcher.navigate',navigate);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',key);window.removeEventListener('framely.launcher.navigate',navigate);};},[]);
|
||||
useEffect(()=>{const back=()=>close();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')close();};window.addEventListener('framely.back',back);window.addEventListener('keydown',key);const navigate=(e:Event)=>{if(keyboard.current||state.current.panning)return;const direction=(e as CustomEvent<number>).detail;const s=state.current;if(drag.current){if(Math.abs(direction)===1){stopEdge();moveDraggedPage(direction);}return;}setMenu(null);if(Math.abs(direction)===1)setPage(Math.max(0,Math.min(s.pages-1,s.current+direction)));else if(Math.abs(direction)===2){const index=categories.findIndex(c=>c.key===s.filter);selectCategory(categories[Math.max(0,Math.min(categories.length-1,index+(direction>0?-1:1)))].key);setPage(0);}};window.addEventListener('framely.launcher.navigate',navigate);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',key);window.removeEventListener('framely.launcher.navigate',navigate);};},[]);
|
||||
useEffect(()=>{const preference=window.matchMedia('(prefers-reduced-motion: reduce)'),report=()=>console.log(`FRAMELY_LAUNCHER_REDUCED_MOTION=${preference.matches?1:0}`);report();preference.addEventListener('change',report);return()=>preference.removeEventListener('change',report);},[]);
|
||||
const surfaceAnimation=useRef<Animation|null>(null);
|
||||
function animateSurface(closing=false){const node=shell.current;if(!node)return;surfaceAnimation.current?.cancel();const reduced=window.matchMedia('(prefers-reduced-motion: reduce)').matches;surfaceAnimation.current=node.animate(closing?[{opacity:1,transform:'translateY(0) scale(1)'},{opacity:0,transform:'translateY(32px) scale(.88)'}]:[{opacity:0,transform:'translateY(44px) scale(.86)'},{opacity:1,transform:'translateY(0) scale(1)'}],{duration:reduced?0:closing?440:580,easing:'cubic-bezier(.4,0,.2,1)',fill:'both'});node.style.pointerEvents=closing?'none':'';}
|
||||
useEffect(()=>{if(!loading)animateSurface();},[loading]);
|
||||
useEffect(()=>{const closing=()=>animateSurface(true);window.addEventListener('framely.launcher.closing',closing);return()=>{window.removeEventListener('framely.launcher.closing',closing);surfaceAnimation.current?.cancel();};},[]);
|
||||
const all=useMemo(()=>{const entries:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:localFavorites.includes('framely')},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:localFavorites.includes(`${a.kind}:${a.id}`)}))];
|
||||
const rank=new Map(manual.map((key,index)=>[key,index]));entries.sort((a,b)=>(rank.get(a.key)??Infinity)-(rank.get(b.key)??Infinity)||a.name.localeCompare(b.name)||a.key.localeCompare(b.key));return entries;},[plugins,steam,local,favorites,localFavorites,manual]);
|
||||
const indexKey=useMemo(()=>JSON.stringify(all.map(a=>a.name).sort()),[all]);
|
||||
@@ -61,8 +70,7 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
|
||||
useEffect(()=>{const changed=(e:Event)=>{keyboard.current=!!(e as CustomEvent<boolean>).detail;if(keyboard.current){cancelPan();cancelHold();setMenu(null);}};window.addEventListener('framely.keyboard',changed);return()=>window.removeEventListener('framely.keyboard',changed);},[]);
|
||||
const items=useMemo(()=>all.filter(a=>(filter==='favorites'?a.favorite:filter==='all'?(config.allCategories??defaults.allCategories!).includes(a.kind==='framely'?'plugin':a.kind):a.kind===filter||(filter==='plugin'&&a.kind==='framely'))&&matchesSearch(a.name,query,indexes[a.name])),[all,filter,config.allCategories,query,indexes]);
|
||||
const pageSize=13,columns=6,pages=Math.max(1,Math.ceil(items.length/pageSize)),current=Math.min(page,pages-1),selected=menu?all.find(a=>a.key===menu):null,selectedPlugin=selected?.kind==='plugin'?plugins[selected.id]:null,visible=items.slice(current*pageSize,current*pageSize+pageSize);
|
||||
const counts=[Math.min(visible.length,4),Math.min(Math.max(visible.length-4,0),5),Math.max(visible.length-9,0)].filter(count=>count>0),rows=Math.max(1,counts.length);
|
||||
const cells=counts.flatMap((count,row)=>Array.from({length:count},(_,col)=>({row,column:(columns-(rows===1?count:row===1?5:4))*2+1+col*4})));
|
||||
const cells=launcherCells(visible.length);
|
||||
state.current={editing,current,pages,filter,panning:!!panView,items,all};
|
||||
const pageTransition=useRef({current,filter}),pageAnimation=useRef<Animation|null>(null);
|
||||
useLayoutEffect(()=>{const previous=pageTransition.current;pageTransition.current={current,filter};if(previous.current===current&&previous.filter===filter)return;pageAnimation.current?.cancel();if(skipPageAnimation.current){skipPageAnimation.current=false;return;}if(window.matchMedia('(prefers-reduced-motion: reduce)').matches||!pageContent.current)return;const category=previous.filter!==filter,direction=category?Math.sign(categories.findIndex(c=>c.key===filter)-categories.findIndex(c=>c.key===previous.filter)):Math.sign(current-previous.current);pageAnimation.current=pageContent.current.animate([{opacity:0,translate:category?`0 ${direction*110}px`:`${direction*110}px 0`,scale:1},{opacity:1,translate:'0 0',scale:1}],{duration:category?360:300,easing:'cubic-bezier(.22,.65,.24,1)'});},[current,filter]);
|
||||
@@ -80,31 +88,35 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
|
||||
catch(e){if(active()){done(key,String(e));setError(String(e));}}
|
||||
finally{if(timer)clearTimeout(timer);if(operations.current.get(key)===entry){operations.current.delete(key);if(mounted.current)setBusyKeys(keys=>{const next=new Set(keys);next.delete(key);return next;});}}
|
||||
}
|
||||
function startDrag(item:LaunchItem,x:number,y:number){const before=state.current.all.map(a=>a.key);drag.current={key:item.key,before,order:before,moved:false,over:null,edge:0,lastX:x,lastY:y,startedInEdit:state.current.editing};setDragging(item.key);setGhost({item,x,y});setMenu(null);}
|
||||
// Use the same boundaries for feedback, ghost clamping and edge dwell.
|
||||
function dragBounds(){const box=grid.current!.getBoundingClientRect(),leftRail=shell.current?.querySelector('.launcher-filters')?.getBoundingClientRect(),rightRail=shell.current?.querySelector('.launcher-shortcuts')?.getBoundingClientRect();return {left:Math.max(box.left,(leftRail?.right??box.left)+24),right:Math.min(box.right,(rightRail?.left??box.right)-24),top:box.top,bottom:box.bottom};}
|
||||
function dragPosition(x:number,y:number){const bounds=dragBounds(),radius=(shell.current?.querySelector('.launch-disc')?.getBoundingClientRect().width??96)*.55+4;return {...bounds,x:Math.max(bounds.left+radius,Math.min(bounds.right-radius,x)),y};}
|
||||
function startDrag(item:LaunchItem,x:number,y:number){const before=state.current.all.map(a=>a.key);drag.current={key:item.key,before,order:before,moved:false,over:null,edge:0,lastX:x,lastY:y,startedInEdit:state.current.editing};setDragging(item.key);setGhost({item,...dragPosition(x,y)});setMenu(null);}
|
||||
function down(e:React.PointerEvent,item:LaunchItem){if(e.button!==0||operations.current.has(item.key)||(state.current.editing&&operations.current.has('order'))||state.current.panning)return;cancelHold();long.current=false;point.current={x:e.clientX,y:e.clientY};holdTarget.current=item.key;if(!editing)swipe.current={x:e.clientX,y:e.clientY};try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}if(editing){try{shell.current?.setPointerCapture(e.pointerId);}catch{}long.current=true;startDrag(item,e.clientX,e.clientY);return;}hold.current=setTimeout(()=>{long.current=true;setMenu(item.key);setOutside(false);hold.current=null;},400);editHold.current=setTimeout(()=>{long.current=true;setEditing(true);swipe.current=null;try{shell.current?.setPointerCapture(e.pointerId);}catch{}startDrag(item,point.current.x,point.current.y);editHold.current=null;},800);}
|
||||
function reorder(target:string){const d=drag.current;if(!d||target===d.key||target===d.over)return;const from=d.order.indexOf(d.key),to=d.order.indexOf(target);if(from<0||to<0)return;const next=[...d.order];next.splice(from,1);next.splice(to,0,d.key);d.order=next;d.over=target;d.moved=true;setManual(next);}
|
||||
function turnEdge(direction:number){const d=drag.current;if(!d||d.edge===direction)return;stopEdge();if(!direction)return;d.edge=direction;setEdge(direction);const tick=()=>{const s=state.current,next=s.current+direction;if(!drag.current||next<0||next>=s.pages){stopEdge();return;}const target=s.items[next*pageSize+(direction>0?0:Math.min(pageSize-1,s.items.length-next*pageSize-1))];if(target)reorder(target.key);setPage(next);edgeTimer.current=setTimeout(tick,900);};edgeTimer.current=setTimeout(tick,650);}
|
||||
useEffect(()=>{const move=(e:PointerEvent)=>{const d=drag.current;if(d){setGhost(g=>g?{...g,x:e.clientX,y:e.clientY}:g);const moved=Math.hypot(e.clientX-point.current.x,e.clientY-point.current.y)>36;if(!d.moved&&!moved)return;d.moved=true;const box=grid.current?.getBoundingClientRect(),categories=shell.current?.querySelector('.launcher-filters')?.getBoundingClientRect();const left=box?Math.max(box.left,(categories?.right??box.left)+24):0;const inHeight=!!box&&e.clientY>=box.top&&e.clientY<=box.bottom;const direction=box&&inHeight?(e.clientX>=left&&e.clientX<left+50?-1:e.clientX<=box.right&&e.clientX>box.right-50?1:0):0;turnEdge(direction);if(direction)return;const target=document.elementFromPoint(e.clientX,e.clientY)?.closest<HTMLElement>('[data-launch-key]');if(target&&Math.hypot(e.clientX-d.lastX,e.clientY-d.lastY)>12){reorder(target.dataset.launchKey!);d.lastX=e.clientX;d.lastY=e.clientY;}else if(!target)d.over=null;
|
||||
function moveDraggedPage(direction:number){const d=drag.current,s=state.current,next=s.current+direction;if(!d||next<0||next>=s.pages)return false;const target=s.items[next*pageSize+(direction>0?0:Math.min(pageSize-1,s.items.length-next*pageSize-1))];if(target)reorder(target.key);d.over=null;setPage(next);return true;}
|
||||
function turnEdge(direction:number){const d=drag.current;if(!d||d.edge===direction)return;stopEdge();if(!direction)return;d.edge=direction;setEdge(direction);const tick=()=>{if(!moveDraggedPage(direction)){stopEdge();return;}edgeTimer.current=setTimeout(tick,900);};edgeTimer.current=setTimeout(tick,650);}
|
||||
useEffect(()=>{const move=(e:PointerEvent)=>{const d=drag.current;if(d){const bounds=dragPosition(e.clientX,e.clientY);setGhost(g=>g?{...g,...bounds}:g);const moved=Math.hypot(e.clientX-point.current.x,e.clientY-point.current.y)>36;if(!d.moved&&!moved)return;d.moved=true;const inHeight=e.clientY>=bounds.top&&e.clientY<=bounds.bottom;const direction=inHeight?(e.clientX<=bounds.left?-1:e.clientX>=bounds.right?1:0):0;turnEdge(direction);if(direction)return;const target=document.elementFromPoint(e.clientX,e.clientY)?.closest<HTMLElement>('[data-launch-key]');if(target&&Math.hypot(e.clientX-d.lastX,e.clientY-d.lastY)>12){reorder(target.dataset.launchKey!);d.lastX=e.clientX;d.lastY=e.clientY;}else if(!target)d.over=null;
|
||||
}else if(swipe.current){const gesture=swipe.current,dx=e.clientX-gesture.x,dy=e.clientY-gesture.y;if(gesture.active||(Math.abs(dx)>36&&Math.abs(dx)>Math.abs(dy)*1.25)){gesture.active=true;gesture.dx=dx;cancelHold();setMenu(null);long.current=true;pageAnimation.current?.cancel();const width=grid.current?.clientWidth??1300,direction=dx<0?1:-1,next=state.current.current+direction,valid=next>=0&&next<state.current.pages;setPanView({offset:valid?Math.max(-width,Math.min(width,dx)):dx*.22,direction,width,settling:false});}else if(holdTarget.current&&Math.hypot(dx,dy)>36){cancelHold();long.current=true;}}};
|
||||
const up=(e:PointerEvent)=>{if(swipe.current){const gesture=swipe.current;swipe.current=null;settleSwipe(gesture);}cancelHold();stopEdge();const d=drag.current;drag.current=null;setDragging(null);setGhost(null);if(d){long.current=true;if(!d.moved&&d.startedInEdit)setEditing(false);if(d.moved)void run('order',async()=>{try{await launcherRequest('launcher.order.save',{order:d.order});}catch(e){setManual(d.before);throw e;}});}};
|
||||
const up=(e:PointerEvent)=>{if(swipe.current){const gesture=swipe.current;swipe.current=null;settleSwipe(gesture);}cancelHold();stopEdge();const d=drag.current;drag.current=null;setDragging(null);setGhost(null);if(d){long.current=true;if(d.moved)void run('order',async()=>{try{await launcherRequest('launcher.order.save',{order:d.order});}catch(e){setManual(d.before);throw e;}});}};
|
||||
const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up);window.addEventListener('pointercancel',cancel);return()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up);window.removeEventListener('pointercancel',cancel);};},[]);
|
||||
function launch(a:LaunchItem){return a.kind==='framely'?launcherRequest('host.manager.open'):a.kind==='plugin'?launcherRequest('plugin.launch',{plugin:a.id,source:'launcher'}):a.key.startsWith('steam:')?launcherRequest('steam.launch',{app:+a.id}):launcherRequest('desktop.launch',{app:a.key});}
|
||||
async function custom(id:string,action:string){const key=`plugin:${id}`;await run(key,async active=>{const v=await launcherRequest('plugin.launcher.action',{plugin:id,action});if(!active())return;if(v.frontend){await new Promise<void>((resolve,reject)=>{const ticket=++actionSequence.current;const timer=setTimeout(()=>done(key,'Launcher action timed out',ticket),90000);pending.current.set(key,{resolve,reject,timer,ticket});setFrontends(items=>({...items,[key]:{plugin:id,action,context:v.context,ticket}}));});}if(active()&&v.opened)setMenu(current=>current===key?null:current);});}
|
||||
const pending=useRef(new Map<string,{resolve:()=>void;reject:(e:Error)=>void;timer:ReturnType<typeof setTimeout>;ticket:number}>());
|
||||
function done(key:string,error?:string,ticket?:number){const p=pending.current.get(key);if(!p||(ticket!==undefined&&p.ticket!==ticket))return;clearTimeout(p.timer);pending.current.delete(key);if(mounted.current)setFrontends(items=>{const next={...items};delete next[key];return next;});if(error)p.reject(new Error(error));else p.resolve();}
|
||||
return <div ref={shell} onPointerDown={e=>{if(e.button===0&&!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error')){if(!keyboard.current&&!editing&&!state.current.panning)swipe.current={x:e.clientX,y:e.clientY};long.current=false;try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}}}} className={`launcher-shell ${editing?'launcher-editing':''} ${loading&&opening.current?'launcher-initializing':''}`} onClick={e=>{if(long.current){long.current=false;return;}if(!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error'))close();}}>
|
||||
return <div ref={shell} onPointerDown={e=>{if(e.button===0&&!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error')){if(!keyboard.current&&!state.current.panning)swipe.current={x:e.clientX,y:e.clientY};long.current=false;try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}}}} className={`launcher-shell ${editing?'launcher-editing':''} ${loading&&opening.current?'launcher-initializing':''}`} onClick={e=>{if(long.current){long.current=false;return;}if(!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error'))setMenu(null);}}>
|
||||
<div className="launcher-search"><IconSearch size={22} stroke={1.65}/><input type="search" aria-label={t('搜索应用')} placeholder={t('搜索应用')} value={query} disabled={editing} onChange={e=>{cancelPan();cancelHold();setMenu(null);setQuery(e.target.value);setPage(0);}}/>{query&&<button aria-label={t('清除搜索')} onClick={()=>{setQuery('');setPage(0);}}><IconX size={20}/></button>}</div>
|
||||
<nav className="launcher-tools launcher-filters" aria-label={t('启动台')}><span className="launcher-category-active" aria-hidden="true" style={{transform:`translateY(${Math.max(0,categories.findIndex(c=>c.key===filter))*61}px)`}}/>{categories.map(({key,label,icon:CategoryIcon})=><button key={key} aria-label={t(label)} title={t(label)} aria-pressed={filter===key} disabled={!!dragging||!!panView} className={filter===key?'active':''} onClick={()=>{if(editing){close();return;}selectCategory(key);setPage(0);}}><CategoryIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
|
||||
<nav className="launcher-tools launcher-shortcuts" aria-label={t('快捷操作')}>{[{page:'catalog',label:'插件库',icon:IconApps},{page:'settings',label:'设置',icon:IconSettings}].map(({page,label,icon:ActionIcon})=><button key={page} aria-label={t(label)} title={t(label)} disabled={busyKeys.has('framely')||editing||!!dragging||!!panView} onClick={()=>{cancelHold();setMenu(null);void run('framely',()=>launcherRequest('host.manager.open',{page}));}}><ActionIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
|
||||
<nav className="launcher-tools launcher-shortcuts" aria-label={t('快捷操作')}>{[{page:'apk',label:'安装APK',icon:IconPackageImport},{page:'catalog',label:'插件库',icon:IconApps},{page:'settings',label:'设置',icon:IconSettings}].map(({page,label,icon:ActionIcon})=><button key={page} aria-label={t(label)} title={t(label)} disabled={busyKeys.has('framely')||editing||!!dragging||!!panView} onClick={()=>{cancelHold();setMenu(null);void run('framely',()=>launcherRequest('host.manager.open',{page}));}}><ActionIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
|
||||
{editing&&<div className="launcher-edit-toolbar"><span><IconGridDots size={22}/>{t('拖动图标排列,移到两侧翻页')}</span><button disabled={busyKeys.has('order')||!!dragging} onClick={()=>setEditing(false)}><IconCheck size={22}/>{t('完成')}</button></div>}
|
||||
{safeMode&&<p className="banner">{t('安全模式:第三方插件暂停运行。')}</p>}{error&&<div className="launcher-error" role="alert">{error}<button onClick={()=>setError('')}>{t('关闭')}</button></div>}
|
||||
<div ref={grid} className={`launcher-grid ${panView?'launcher-panning':''}`}><div ref={pageContent} className="launcher-page" style={panView?{translate:`${panView.offset}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}:undefined}>{visible.map((a,index)=><div key={a.key} data-launch-key={a.key} style={{gridColumn:`${cells[index].column} / span 4`,gridRow:`${1+cells[index].row*2} / span 2`}} className={`launch-item ${menu===a.key?'launch-item-expanded':''} ${dragging===a.key?'launch-item-dragged':''}`} onPointerEnter={()=>{if(menu===a.key)setOutside(false);}} onPointerLeave={()=>{if(menu===a.key)setOutside(true);}}>
|
||||
<button className="launch-icon" aria-busy={busyKeys.has(a.key)} disabled={busyKeys.has(a.key)||(editing&&busyKeys.has('order'))||(a.kind==='plugin'&&safeMode&&!editing)} aria-label={a.name} aria-haspopup="menu" aria-expanded={menu===a.key} onPointerDown={e=>down(e,a)} onClick={()=>{if(long.current){long.current=false;return;}if(editing)return;setMenu(null);void run(a.key,()=>launch(a));}} onContextMenu={e=>{e.preventDefault();setMenu(a.key);setOutside(false);}}><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/></button><span className="launch-name" title={a.name}>{a.name}</span>
|
||||
{!editing&&<button className={`launch-favorite ${a.favorite?'selected':''}`} aria-label={t(a.favorite?'取消收藏':'收藏')} aria-pressed={a.favorite} title={t(a.favorite?'取消收藏':'收藏')} disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('launcher.favorite',{key:a.key,favorite:!a.favorite}))}><IconStar size={16} stroke={1.7} fill={a.favorite?'currentColor':'none'}/></button>}
|
||||
{menu===a.key&&<div ref={menuRef} role="menu" className={`launch-menu ${cells[index].column>=(columns-2)*4?'align-right':''} ${rows>1&&cells[index].row===rows-1?'above':''}`} onPointerEnter={()=>setOutside(false)} onPointerLeave={()=>setOutside(true)}>
|
||||
{selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=><button role="menuitem" key={action.id} disabled={busyKeys.has(a.key)||safeMode} onClick={()=>void custom(a.id,action.id)}>{action.label}</button>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&<hr/>}<button role="menuitem" disabled={busyKeys.has(a.key)||safeMode||!selectedPlugin.manifest.ui.quickPage} title={!selectedPlugin.manifest.ui.quickPage?t('插件没有快捷页面'):undefined} onClick={()=>void run(a.key,()=>launcherRequest('plugin.launch',{plugin:a.id,source:'launcher',quickPage:true}))}><IconLayoutGrid size={22} stroke={1.7}/>{t('快捷面板')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.plugin.manage',{plugin:a.id}))}><IconAdjustments size={22} stroke={1.7}/>{t('管理插件')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} className="danger" onClick={()=>void run(a.key,async()=>{const plan=await launcherRequest('plugin.disable.preview',{plugin:a.id});setUninstall({...selectedPlugin,dependents:plan.dependents,fingerprint:plan.fingerprint});})}><IconTrash size={22} stroke={1.7}/>{t('卸载插件')}</button></>:<><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launch(a))}><IconLayoutGrid size={22}/>{t('打开')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>{setMenu(null);setEditing(true);}}><IconGridDots size={22}/>{t('整理图标')}</button></>}
|
||||
{busyKeys.has(a.key)&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&¤t+panView.direction>=0&¤t+panView.direction<pages&&<div className="launcher-adjacent-page" aria-hidden="true" style={{translate:`${panView.offset+panView.direction*panView.width}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}}>{items.slice((current+panView.direction)*pageSize,(current+panView.direction+1)*pageSize).map((a,index)=>{const count=Math.min(pageSize,items.length-(current+panView.direction)*pageSize),row=index<4?0:index<9?1:2,column=index-(row===0?0:row===1?4:9),slot=(columns-(count<=4?count:row===1?5:4))*2+1+column*4;return <div key={a.key} className="launch-item" style={{gridColumn:`${slot} / span 4`,gridRow:`${1+row*2} / span 2`}}><span className="launch-icon"><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/></span><span className="launch-name">{a.name}</span></div>;})}</div>}</div>
|
||||
{ghost&&<div className="launcher-drag-ghost" style={{left:ghost.x,top:ghost.y}}><LauncherArtwork src={ghost.item.icon} name={ghost.item.name} kind={ghost.item.kind} fit={ghost.item.fit}/></div>}{!!edge&&<div className={`launcher-drag-edge ${edge<0?'left':'right'}`} aria-hidden="true"/>}
|
||||
{!loading&&!items.length&&<p className="launcher-empty">{t(query?'没有匹配的应用':filter==='favorites'?'还没有收藏的应用':'没有可启动的应用')}</p>}<nav className="launcher-pages" aria-label={t('分页')}>{Array.from({length:pages},(_,n)=><button key={n} aria-label={t('第 {0} 页',{0:n+1})} aria-current={current===n?'page':undefined} disabled={!!dragging||!!panView} onClick={()=>{if(editing){close();return;}setPage(n);}}><span/></button>)}</nav>{loading&&<span className="launcher-loading" role="status">{t('正在读取本机应用…')}</span>}
|
||||
{menu===a.key&&<div ref={menuRef} role="menu" className={`launch-menu ${cells[index].column>=(columns-2)*4?'align-right':''} ${cells[index].row===2?'above':''}`} onPointerEnter={()=>setOutside(false)} onPointerLeave={()=>setOutside(true)}>
|
||||
{selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=><button role="menuitem" key={action.id} disabled={busyKeys.has(a.key)||safeMode} onClick={()=>void custom(a.id,action.id)}>{action.label}</button>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&<hr/>}<button role="menuitem" disabled={busyKeys.has(a.key)||safeMode||!selectedPlugin.manifest.ui.quickPage} title={!selectedPlugin.manifest.ui.quickPage?t('插件没有快捷页面'):undefined} onClick={()=>void run(a.key,()=>launcherRequest('plugin.launch',{plugin:a.id,source:'launcher',quickPage:true}))}><IconLayoutGrid size={22} stroke={1.7}/>{t('快捷面板')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.plugin.manage',{plugin:a.id}))}><IconAdjustments size={22} stroke={1.7}/>{t('管理插件')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} className="danger" onClick={()=>void run(a.key,async()=>{const plan=await launcherRequest('plugin.disable.preview',{plugin:a.id});setUninstall({...selectedPlugin,dependents:plan.dependents,fingerprint:plan.fingerprint});})}><IconTrash size={22} stroke={1.7}/>{t('卸载插件')}</button></>:<><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launch(a))}><IconLayoutGrid size={22}/>{t('打开')}</button>{a.kind==='lepton'&&!a.key.startsWith('steam:')&&<><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.manager.open',{page:'apk'}).then(()=>launcherRequest('apk.selection',{app:a.id})))}><IconAdjustments size={22}/>{t('管理应用')}</button><button role="menuitem" className="danger" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.manager.open',{page:'apk'}).then(()=>launcherRequest('apk.selection',{app:a.id,uninstall:true})))}><IconTrash size={22}/>{t('卸载应用')}</button></>}<button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>{setMenu(null);setEditing(true);}}><IconGridDots size={22}/>{t('整理图标')}</button></>}
|
||||
{busyKeys.has(a.key)&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&¤t+panView.direction>=0&¤t+panView.direction<pages&&<div className="launcher-adjacent-page" aria-hidden="true" style={{translate:`${panView.offset+panView.direction*panView.width}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}}>{items.slice((current+panView.direction)*pageSize,(current+panView.direction+1)*pageSize).map((a,index)=>{const count=Math.min(pageSize,items.length-(current+panView.direction)*pageSize),{row,column:slot}=launcherCells(count)[index];return <div key={a.key} className="launch-item" style={{gridColumn:`${slot} / span 4`,gridRow:`${1+row*2} / span 2`}}><span className="launch-icon"><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/></span><span className="launch-name">{a.name}</span></div>;})}</div>}</div>
|
||||
{ghost&&<div className="launcher-drag-ghost" style={{left:ghost.x,top:ghost.y}}><LauncherArtwork src={ghost.item.icon} name={ghost.item.name} kind={ghost.item.kind} fit={ghost.item.fit}/></div>}{ghost&&[-1,1].map(direction=><div key={direction} className={`launcher-drag-edge ${direction<0?'left':'right'} ${edge===direction?'active':''}`} style={{left:(direction<0?ghost.left:ghost.right)-4,top:ghost.top,height:ghost.bottom-ghost.top}} aria-hidden="true"/>)}
|
||||
{!loading&&!items.length&&<p className="launcher-empty">{t(query?'没有匹配的应用':filter==='favorites'?'还没有收藏的应用':'没有可启动的应用')}</p>}<nav className="launcher-pages" aria-label={t('分页')}>{Array.from({length:pages},(_,n)=><button key={n} aria-label={t('第 {0} 页',{0:n+1})} aria-current={current===n?'page':undefined} disabled={!!dragging||!!panView} onClick={()=>setPage(n)}><span/></button>)}</nav>{loading&&<span className="launcher-loading" role="status">{t('正在读取本机应用…')}</span>}
|
||||
{uninstall&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true"><h2>{t('卸载 {0}?',{0:uninstall.manifest.name})}</h2><p>{t('依赖此插件的应用也会停用;插件数据会保留。')}</p>{!!uninstall.dependents.length&&<p>{t('停用:')}{uninstall.dependents.map(id=>plugins[id]?.manifest.name??id).join(', ')}</p>}<button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} className="danger" onClick={()=>void run(`plugin:${uninstall.manifest.id}`,async()=>{await launcherRequest('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{Object.entries(frontends).map(([key,frontend])=><div key={`${key}:${frontend.ticket}`} className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={error=>done(key,error,frontend.ticket)}/></div>)}</div>;
|
||||
}
|
||||
+11
-5
@@ -1,3 +1,6 @@
|
||||
import {FrameFilePicker} from './file-picker';
|
||||
import {ManagerSwitcher} from './manager-switcher';
|
||||
import {ApkManager} from './apk-manager';
|
||||
import {Switch,SwitchRow} from './switch';
|
||||
import {PluginFrame,pluginLanguagePreference,setPluginLanguagePreference} from './plugin-frame';
|
||||
import {Launcher,LauncherSettings,type LauncherConfig} from './launcher';
|
||||
@@ -26,10 +29,12 @@ const managerMode=location.pathname==='/manager';
|
||||
const empty:Status={version:'0.4.0',database:{plugins:{},sources:[],safeMode:false},notifications:[],running:[]};
|
||||
|
||||
function App(){
|
||||
const[catalogTarget,setCatalogTarget]=useState<{plugin:string;source?:string}|null>(null);const runtimeLabels:Record<string,string>={starting:t("初始化中"),running:t("运行中"),stopping:t("停止中"),stopped:t("已停止"),recovering:t("等待恢复"),failed:t("失败"),crashed:t("异常退出"),ready:t("已就绪"),installed:t("已安装"),'waiting-dependency':t("等待依赖")};const [status,setStatus]=useState<Status>(empty),[error,setError]=useState(''),[tab,setTab]=useState(managerMode?(location.hash==='#catalog'?'catalog':location.hash==='#settings'?'settings':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':location.hash==='#launcher-settings'?'launcher-settings':'installed'):'favorites'),[page,setPage]=useState<string|null>(!managerMode&&location.hash.startsWith('#plugin:')?location.hash.slice(8):null),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[manage,setManage]=useState<Plugin|null>(null),[install,setInstall]=useState<any>(null),[confirmRunAs,setConfirmRunAs]=useState(false),[logs,setLogs]=useState<string|null>(null),[importing,setImporting]=useState(false),[transfer,setTransfer]=useState<Job|null>(null),[downloadRequest,setDownloadRequest]=useState<any>(null),[relationAction,setRelationAction]=useState<any>(null),[reviewAgreement,setReviewAgreement]=useState(false),[agreementDeclined,setAgreementDeclined]=useState(false),[languageFiles,setLanguageFiles]=useState<string[]>([]),[logExport,setLogExport]=useState(''),[exportingLogs,setExportingLogs]=useState(false);
|
||||
const[catalogTarget,setCatalogTarget]=useState<{plugin:string;source?:string}|null>(null);const runtimeLabels:Record<string,string>={starting:t("初始化中"),running:t("运行中"),stopping:t("停止中"),stopped:t("已停止"),recovering:t("等待恢复"),failed:t("失败"),crashed:t("异常退出"),ready:t("已就绪"),installed:t("已安装"),'waiting-dependency':t("等待依赖")};const [status,setStatus]=useState<Status>(empty),[error,setError]=useState(''),[tab,setTab]=useState(managerMode?(location.hash.startsWith('#apk')?location.hash.slice(1):location.hash==='#catalog'?'catalog':location.hash==='#settings'?'settings':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':location.hash==='#launcher-settings'?'launcher-settings':'installed'):'favorites'),[page,setPage]=useState<string|null>(!managerMode&&location.hash.startsWith('#plugin:')?location.hash.slice(8):null),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[manage,setManage]=useState<Plugin|null>(null),[install,setInstall]=useState<any>(null),[confirmRunAs,setConfirmRunAs]=useState(false),[logs,setLogs]=useState<string|null>(null),[importing,setImporting]=useState(false),[transfer,setTransfer]=useState<Job|null>(null),[downloadRequest,setDownloadRequest]=useState<any>(null),[relationAction,setRelationAction]=useState<any>(null),[reviewAgreement,setReviewAgreement]=useState(false),[agreementDeclined,setAgreementDeclined]=useState(false),[languageFiles,setLanguageFiles]=useState<string[]>([]),[logExport,setLogExport]=useState(''),[exportingLogs,setExportingLogs]=useState(false);
|
||||
useEffect(()=>{const ticket=install?.ticket;return()=>{if(ticket)void api('job.cancel',{job:ticket}).catch(()=>{});};},[install?.ticket]);
|
||||
const localePacks=useRef<LanguagePack[]|null>(null);const localePreference=useRef<string|null>(null);
|
||||
const readError=useReadInbox(!managerMode&&tab==='notifications'&&!page&&!!status.agreement?.accepted,status.inbox??[],refresh);
|
||||
const apkPanel=managerMode&&tab.startsWith('apk');
|
||||
function switchManager(apk:boolean){setPage(null);setManage(null);setTab(apk?'apk':'installed');history.replaceState(null,'',location.pathname+location.search+(apk?'#apk':''));}
|
||||
async function exportLogs(){
|
||||
setExportingLogs(true);setLogExport('');setError('');
|
||||
try{const v=await api<{name:string;path?:string;data?:string}>('diagnostics.export');
|
||||
@@ -61,7 +66,7 @@ function App(){
|
||||
else{const plan=await api('plugin.disable.preview',{plugin:id});if(plan.dependents.length){setRelationAction({title:kind==='uninstall'?t("确认卸载及依赖停用"):t("确认停用计划"),enable:[],disable:plan.dependents,method:kind==='uninstall'?'plugin.uninstall':'plugin.enable',params:{plugin:id,enabled:false,purge:false,...extra,approveDependents:true,fingerprint:plan.fingerprint}});return;}await api(kind==='uninstall'?'plugin.uninstall':'plugin.enable',{plugin:id,enabled:false,purge:false,...extra});}
|
||||
setManage(null);
|
||||
}
|
||||
useEffect(()=>{if(!managerMode)return;const navigate=()=>{if(['#catalog','#settings','#updates','#notification-settings','#launcher-settings'].includes(location.hash)){setPage(null);setCatalogTarget(null);setTab(location.hash==='#settings'?'settings':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':location.hash==='#launcher-settings'?'launcher-settings':'catalog');}};window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[]);
|
||||
useEffect(()=>{if(!managerMode)return;const navigate=()=>{if(['#catalog','#settings','#updates','#notification-settings','#launcher-settings','#apk','#apk-containers','#apk-cleanup'].includes(location.hash)){setPage(null);setCatalogTarget(null);setTab(location.hash.startsWith('#apk')?location.hash.slice(1):location.hash==='#settings'?'settings':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':location.hash==='#launcher-settings'?'launcher-settings':'catalog');}};window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[]);
|
||||
useEffect(()=>{const navigate=()=>{if(location.hash.startsWith('#plugin:')){const id=decodeURIComponent(location.hash.slice(8));if(managerMode){setPage(null);setTab('installed');const plugin=status.database.plugins[id];if(plugin){setManage(plugin);history.replaceState(null,'',location.pathname+location.search);}}else{history.replaceState(null,'',location.pathname+location.search);setPage(id);}}};navigate();window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[status.database.plugins]);
|
||||
async function launchPlugin(p:Plugin){const remote=managerMode&&!!document.querySelector('meta[name=framely-remote]');const result=await api('plugin.launch',{plugin:p.manifest.id,source:managerMode?'manager':'quickPanel',navigate:!remote});if((remote||!managerMode)&&result.target.type==='quickPage')setPage(p.manifest.id);else if(remote&&result.target.type==='window')location.assign(`/window/${p.manifest.id}/${result.target.window}`);}
|
||||
async function loadCatalog(){setCatalogTarget(null);setTab('catalog');}
|
||||
@@ -81,8 +86,8 @@ function App(){
|
||||
if(parts[1]==='launcher')return <Launcher plugins={status.database.plugins} safeMode={status.database.safeMode} config={status.database.launcher} favorites={status.database.steamFavorites??[]} localFavorites={status.database.launcherFavorites??[]} order={status.database.launcherOrder??[]} refresh={refresh}/>;
|
||||
if(parts[1]==='notifications')return <NotificationCenter items={status.notifications} plugins={status.database.plugins} safeMode={status.database.safeMode} refresh={refresh}/>;
|
||||
if(windowPlugin){const p=status.database.plugins[windowPlugin];return <div className="app plugin-window">{p?.enabled&&!status.database.safeMode?<PluginFrame plugin={windowPlugin} entry={parts[3]}/>:<div className="empty"><p>{t("插件已停用或正在启动。")}</p><button onClick={()=>void action(()=>api('window.close',{plugin:windowPlugin,window:parts[3]}))}>{t("关闭")}</button></div>}{error&&<p className="error">{error}</p>}</div>;}
|
||||
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong>{managerMode?<><button className="primary" onClick={()=>setImporting(true)}><Icon name="download"/>{t("安装插件")}</button><button className="icon-button" aria-label={t("关闭管理窗口")} onClick={()=>void api('host.manager.close')}><Icon name="close"/></button></>:<><button className="icon-button quick-library-button" aria-label={t("插件库")} onClick={()=>void action(()=>api('host.manager.open',{page:'catalog'}))}><Icon name="store"/><span>{t("插件库")}</span></button><button className="icon-button" aria-label={t("关闭菜单")} onClick={()=>void api('host.menu.close')}><Icon name="close"/></button></>}</header>{error&&<p className="error" role="alert">{error}<button onClick={()=>setError('')}>×</button></p>}{status.database.safeMode&&<p className="banner">{t("安全模式:第三方插件暂停运行。")}</p>}
|
||||
{selected?<><div className="page-title"><button onClick={()=>setPage(null)}>{t("‹ 返回")}</button><strong>{selected.manifest.name}</strong><button onClick={()=>void action(()=>api('plugin.favorite',{plugin:page,favorite:!selected.favorite}))}>{selected.favorite?'★':'☆'}</button>{managerMode&&<button onClick={()=>setManage(selected)}>{t("管理")}</button>}</div>{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?<PluginFrame plugin={page!}/>:<p className="empty">{t("插件已停用、处于安全模式或没有快捷页面。")}</p>}</>:<div className="workspace"><nav aria-label={t("主导航")}>{(managerMode?[['installed',t("已安装"),'plugins'],['catalog',t("插件库"),'download'],['sources',t("插件源导航"),'link'],['settings',t("设置"),'settings'],['launcher-settings',t("启动台"),'launcher'],['notification-settings',t("通知设置导航"),'bell'],['about',t("关于"),'info']]:[['favorites',t("常用"),'star'],['installed',t("已安装"),'plugins'],['notifications',t("通知"),'bell'],['settings',t("设置"),'settings']]).map(([key,label,icon])=><button className={tab===key?'active':''} aria-current={tab===key?'page':undefined} key={key} onClick={()=>key==='catalog'?void loadCatalog():setTab(key)}>{icon==='bell'?<IconBell size={24} stroke={1.8}/>:icon==='launcher'?<IconLayoutGrid size={24} stroke={1.8}/>:<Icon name={icon as any}/>}<span>{label}{key==='notifications'&&!!status.notificationBadge&&<small className="notification-count">{status.notificationBadge!>99?'99+':status.notificationBadge}</small>}</span></button>)}{managerMode&&<div className="nav-foot"><span>{plugins.length}{t("个已安装插件")}</span><small>{status.version}</small></div>}</nav><main className="content">
|
||||
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong>{managerMode?<><ManagerSwitcher apk={apkPanel} onChange={switchManager}/>{!apkPanel&&<button className="primary" onClick={()=>setImporting(true)}><Icon name="download"/>{t("安装插件")}</button>}<button className="icon-button" aria-label={t("关闭管理窗口")} onClick={()=>void api('host.manager.close')}><Icon name="close"/></button></>:<><button className="icon-button quick-library-button" aria-label={t("插件库")} onClick={()=>void action(()=>api('host.manager.open',{page:'catalog'}))}><Icon name="store"/><span>{t("插件库")}</span></button><button className="icon-button" aria-label={t("关闭菜单")} onClick={()=>void api('host.menu.close')}><Icon name="close"/></button></>}</header>{error&&<p className="error" role="alert">{error}<button onClick={()=>setError('')}>×</button></p>}{status.database.safeMode&&<p className="banner">{t("安全模式:第三方插件暂停运行。")}</p>}
|
||||
{selected?<><div className="page-title"><button onClick={()=>setPage(null)}>{t("‹ 返回")}</button><strong>{selected.manifest.name}</strong><button onClick={()=>void action(()=>api('plugin.favorite',{plugin:page,favorite:!selected.favorite}))}>{selected.favorite?'★':'☆'}</button>{managerMode&&<button onClick={()=>setManage(selected)}>{t("管理")}</button>}</div>{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?<PluginFrame plugin={page!}/>:<p className="empty">{t("插件已停用、处于安全模式或没有快捷页面。")}</p>}</>:<div className="workspace"><nav aria-label={t("主导航")}>{(managerMode?(apkPanel?[['apk',t('应用'),'download'],['apk-containers',t('容器'),'plugins'],['apk-cleanup',t('数据清理'),'settings']]:[['installed',t("已安装"),'plugins'],['catalog',t("插件库"),'download'],['sources',t("插件源导航"),'link'],['settings',t("设置"),'settings'],['launcher-settings',t("启动台"),'launcher'],['notification-settings',t("通知设置导航"),'bell'],['about',t("关于"),'info']]):[['favorites',t("常用"),'star'],['installed',t("已安装"),'plugins'],['notifications',t("通知"),'bell'],['settings',t("设置"),'settings']]).map(([key,label,icon])=><button className={tab===key?'active':''} aria-current={tab===key?'page':undefined} key={key} onClick={()=>key==='catalog'?void loadCatalog():(setTab(key),history.replaceState(null,'',location.pathname+location.search+(key.startsWith('apk')?'#'+key:'')))}>{icon==='bell'?<IconBell size={24} stroke={1.8}/>:icon==='launcher'?<IconLayoutGrid size={24} stroke={1.8}/>:<Icon name={icon as any}/>}<span>{label}{key==='notifications'&&!!status.notificationBadge&&<small className="notification-count">{status.notificationBadge!>99?'99+':status.notificationBadge}</small>}</span></button>)}{managerMode&&<div className="nav-foot"><span>{apkPanel?t("Lepton 侧载管理"):<>{plugins.length}{t("个已安装插件")}</>}</span><small>{status.version}</small></div>}</nav><main className="content">
|
||||
{(tab==='favorites'||tab==='installed')&&<>
|
||||
{managerMode&&<div className="list-heading"><div><h1>{t("已安装")}</h1><p>{plugins.length}{t("个插件,")}{plugins.filter(p=>p.enabled).length}{t("个已启用")}</p></div><div className="search-field"><Icon name="search"/><input aria-label={t("搜索已安装插件")} placeholder={t("搜索插件")} value={query} onChange={e=>setQuery(e.target.value)}/></div></div>}
|
||||
{managerMode&&<div className="list-columns"><span>{t("插件")}</span><span>{t("状态")}</span><span>{t("版本")}</span><span>{t("操作")}</span></div>}<div className="plugin-list">{listedPlugins.map(p=><article className={`plugin-row ${!p.enabled?'disabled':''}`} key={p.manifest.id}>
|
||||
@@ -93,6 +98,7 @@ function App(){
|
||||
{listedPlugins.length===0&&<div className="empty-state"><span className="empty-symbol"><Icon name={tab==='favorites'?'star':'plugins'} size={46}/></span><h2>{query?t("未找到插件"):tab==='favorites'?t("没有收藏的插件"):onlyDisabledInstalled?t("没有已启用的插件"):t("还没有安装插件")}</h2><p>{query?t("换一个关键词再试。"):tab==='favorites'?t("点击插件旁的星标,将它加入常用。"):onlyDisabledInstalled?t("打开插件管理以启用插件。"):t("打开插件管理以安装插件。")}</p><button className="primary" onClick={()=>query?setQuery(''):tab==='favorites'?setTab('installed'):managerMode?setImporting(true):void api('host.manager.open')}>{query?t("清除搜索"):tab==='favorites'?t("查看已安装"):managerMode?t("安装插件"):t("打开插件管理")}</button></div>}
|
||||
</>}
|
||||
{tab==='notifications'&&!managerMode&&<><NotificationCenter inbox items={status.inbox??[]} plugins={status.database.plugins} safeMode={status.database.safeMode} refresh={refresh} controls={<PopupPermission config={status.database.notificationSettings??defaultNotificationConfig} refresh={refresh}/>}/>{readError&&<p className="error" role="alert">{readError}</p>}</>}
|
||||
{apkPanel&&<ApkManager view={tab==='apk-containers'?'containers':tab==='apk-cleanup'?'cleanup':'apps'} onViewChange={view=>setTab(view==='apps'?'apk':'apk-'+view)}/>}
|
||||
{tab==='launcher-settings'&&managerMode&&<><div className="list-heading"><div><h1>{t('启动台')}</h1><p>{t('启动方式、菜单与分类。')}</p></div></div><LauncherSettings config={status.database.launcher} refresh={refresh}/></>}
|
||||
{tab==='notification-settings'&&managerMode&&<NotificationSettings config={status.database.notificationSettings??defaultNotificationConfig} plugins={status.database.plugins} refresh={refresh}/>}
|
||||
{tab==='sources'&&managerMode&&<><div className="list-heading"><div><h1>{t("插件源")}</h1><p>{t("管理插件目录及订阅。")}</p></div></div><section className="settings-card"><Sources origins={status.database.sourceOrigins} sources={status.database.sources} save={async s=>{await api('sources.save',{sources:s});await refresh();}}/></section><Subscriptions subscriptions={status.database.subscriptions??[]} refresh={refresh}/></>}
|
||||
@@ -111,4 +117,4 @@ function App(){
|
||||
}
|
||||
function InstallInput({inspect,busy}:{inspect:(p:any)=>Promise<void>;busy:boolean}){const[url,setUrl]=useState(''),[http,setHttp]=useState(false),[fileError,setFileError]=useState(''),[uploadProgress,setUploadProgress]=useState<number|null>(null);const abort=useRef<AbortController|null>(null);async function upload(file:File){const controller=new AbortController();abort.current=controller;let ticket='';setFileError('');setUploadProgress(0);try{ticket=await uploadFile(file,controller.signal,setUploadProgress,{start:size=>api('upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});setUploadProgress(null);await inspect({upload:ticket});}catch(e){if(!controller.signal.aborted)setFileError(String(e));}finally{if(ticket)await api('upload.abort',{upload:ticket}).catch(()=>{});abort.current=null;setUploadProgress(null);}}useEffect(()=>()=>abort.current?.abort(),[]);return <section><h3>{t("导入插件包")}</h3>{fileError&&<p className="error">{fileError}</p>}<input type="file" accept=".framely" disabled={busy||uploadProgress!==null} onChange={e=>{const f=e.target.files?.[0];e.target.value='';if(f)void upload(f);}}/>{uploadProgress!==null&&<p role="status">{t("正在上传…")} {uploadProgress}% <button onClick={()=>abort.current?.abort()}>{t("取消")}</button></p>}<input placeholder={t("插件包下载 URL")} value={url} onChange={e=>setUrl(e.target.value)}/><SwitchRow checked={http} label={t("允许测试 HTTP 地址")} disabled={busy} onChange={setHttp}/><button disabled={busy||uploadProgress!==null||!url} onClick={()=>void inspect({url,allowHttp:http})}>{t("检查下载包")}</button><small>{t("在头显里不方便选择文件时,可使用 framely install 命令导入。")}</small></section>}
|
||||
class Boundary extends React.Component<React.PropsWithChildren,{error:string|null}>{state={error:null as string|null};static getDerivedStateFromError(e:Error){return {error:e.message}}render(){return this.state.error?<div className="app"><h1>{t("界面发生错误")}</h1><p>{this.state.error}</p><button onClick={()=>location.reload()}>{t("重新加载")}</button></div>:this.props.children;}}
|
||||
createRoot(document.getElementById('root')!).render(<Boundary><App/></Boundary>);console.log('FRAMELY_VIEW_READY');
|
||||
createRoot(document.getElementById('root')!).render(<Boundary><App/><FrameFilePicker/></Boundary>);console.log('FRAMELY_VIEW_READY');
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import {t} from './i18n';
|
||||
export function ManagerSwitcher({apk,onChange}:{apk:boolean;onChange:(apk:boolean)=>void}) {
|
||||
return <div className="manager-switcher" role="group" aria-label={t('切换管理面板')}>
|
||||
<button className={!apk?'active':''} aria-pressed={!apk} onClick={()=>onChange(false)}>{t('插件管理')}</button>
|
||||
<button className={apk?'active':''} aria-pressed={apk} onClick={()=>onChange(true)}>{t('APK 管理')}</button>
|
||||
</div>;
|
||||
}
|
||||
@@ -189,3 +189,4 @@ button{overflow-wrap:anywhere}
|
||||
.manager .launcher-settings input[type=number]{width:100%;margin:0}.manager .source-row:has(.switch-row){flex-direction:column}.manager .source-row:has(.switch-row)>div{width:100%;max-width:none}.manager .source-row:has(.switch-row)>.row{gap:8px}
|
||||
.manager .source-row .switch-row{flex-basis:100%}.manager .notification-plugin-list,.manager .notification-global-options{width:100%}
|
||||
.manager .network-port{flex-direction:column;align-items:stretch}.manager .network-port>label{flex:auto;width:100%}.manager .network-port>button{align-self:flex-start}
|
||||
.manager-switcher{display:flex;gap:4px;padding:4px;border:1px solid var(--line);border-radius:8px;background:#121416}.manager-switcher button{background:transparent;border:0;min-height:44px;padding:8px 16px;color:var(--muted);font-size:18px;white-space:nowrap}.manager-switcher button.active{background:#30343a;color:var(--text);border-radius:5px}@media(max-width:700px){.manager .shell-header{flex-wrap:wrap}.manager .shell-header .manager-switcher{order:3;width:100%}.manager-switcher button{flex:1}.manager .brand-wordmark{width:156px}}
|
||||
Reference in new issue
Block a user