Compare commits

...
43 changed files with 1189 additions and 173 deletions

No files matched your search

Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.4.5-preview.1"
version = "0.4.5-preview.2"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.4.5-preview.1"
version = "0.4.5-preview.2"
edition = "2021"
license = "AGPL-3.0-only"
+3 -3
View File
@@ -21,7 +21,7 @@ The target device is a Linux ARM64 Steam Frame. Enable developer mode and SSH on
### Desktop installer
Download the package for your computer from the [Framely Installer release](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.9). Linux packages are `.tar.gz`; Windows and macOS packages are `.zip`. Extract and launch the installer, enter the Frame IP and SSH port, verify the SSH fingerprint, and connect with the `steamos` account. Select the Framely version, then confirm installation. Enable testing releases to install a Preview version. Installer and device versions are independent.
Download the package for your computer from the [Framely Installer release](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.10). Linux packages are `.tar.gz`; Windows and macOS packages are `.zip`. Extract and launch the installer, enter the Frame IP and SSH port, verify the SSH fingerprint, and connect with the `steamos` account. Select the Framely version, then confirm installation. Enable testing releases to install a Preview version. Installer and device versions are independent.
### Command installation
@@ -34,12 +34,12 @@ 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.5-preview.1
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.5-preview.2
```
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.
First installation uses the complete offline package with CEF; updates use the smaller core package and reuse CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Press the main trigger on the Framely Dock icon in the SteamVR Dashboard to open the launcher, or the upper trigger to open the quick menu. Open the manager from the launcher’s Settings button. The trigger actions can be swapped in Launcher settings.
First installation uses the complete offline package with CEF; updates use the smaller core package and reuse CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Click the Framely Dock icon in the SteamVR Dashboard to open the launcher, or hold it for 600 ms to open the quick panel; a border progress indicator appears while holding. Open the manager from the launcher’s Settings button.
The network panel uses port `15915` by default. Visit `http://DEVICE_IP:15915` on your phone or computer. When no password is configured, the first visit requires setting and confirming a password before login. The device password and panel password are independent.
+3 -3
View File
@@ -21,7 +21,7 @@ Steam Frame 的 React 插件管理器:Rust 核心服务、独立 CEF/OpenVR
### 安装器安装
从 [Framely Installer 发行下载页](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.9) 下载适合电脑平台的安装器。Linux 提供 `.tar.gz`,Windows 和 macOS 提供 `.zip`。解压并启动安装器,填写 Frame 的 IP 和 SSH 端口,核对 SSH 指纹后使用 `steamos` 账户连接。选择 Framely 版本,再确认安装。安装 Preview 版本时需启用测试版本;安装器与 Frame 本体的版本号相互独立。
从 [Framely Installer 发行下载页](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.10) 下载适合电脑平台的安装器。Linux 提供 `.tar.gz`,Windows 和 macOS 提供 `.zip`。解压并启动安装器,填写 Frame 的 IP 和 SSH 端口,核对 SSH 指纹后使用 `steamos` 账户连接。选择 Framely 版本,再确认安装。安装 Preview 版本时需启用测试版本;安装器与 Frame 本体的版本号相互独立。
### 命令安装
@@ -34,12 +34,12 @@ 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.5-preview.1
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.5-preview.2
```
将示例标签替换为需要安装的已发布版本,并在提示时输入 sudo 密码。更新、修复和卸载步骤见[安装指南](docs/zh-CN/user-guide/installation.md)。
首次安装使用包含 CEF 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 CEF 在线下载。设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。在 SteamVR Dashboard 中指向 Framely Dock 图标,按主扳机打开启动台,按上扳机打开快捷面板;点击启动台右侧的设置按钮打开管理面板。可在“启动台”设置中互换扳机行为。
首次安装使用包含 CEF 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 CEF 在线下载。设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。在 SteamVR Dashboard 中点击 Framely Dock 图标打开启动台,长按 600 毫秒打开快捷面板,按住期间显示边框进度;点击启动台右侧的设置按钮打开管理面板。
网络面板默认端口 `15915`,手机/电脑访问 `http://设备IP:15915`。未配置密码时首次访问必须设置并确认密码,保存后登录。设备密码与面板密码相互独立。
+7 -1
View File
@@ -2,7 +2,7 @@
[简体中文](zh-CN/launcher.md)
Point at the Framely Dock icon: by default, use the primary trigger for the launcher and the upper trigger for the quick panel. **Manager → Launcher → Open launcher with the primary trigger** is enabled by default; turning it off swaps these shortcuts immediately. Existing saved trigger preferences are preserved. Other controllers keep the ordinary click entry.
Click the Framely Dock icon with any supported click input to open the launcher. Hold it for **600 milliseconds** to open the quick panel; a border progress indicator shows the hold duration. Releasing after a long press does not also open the launcher. This replaces the primary/upper-trigger split and the trigger-swap setting.
The launcher is a native Dock dashboard panel, 3.6 meters wide, with the previous 2-meter height retained. Steam places and curves it and switches from the current Dock panel when it opens. It closes when the Dock is hidden. UI drawing is capped at 60 FPS independently of the headset refresh rate to limit pixel readback and texture upload load. This is a drawing limit, not a guaranteed measured frame rate. The launcher uses a 4320 × 2400 texture (3× the logical resolution), retains mipmap filtering for edge quality and uploads only changed regions. App ordering and search results are cached during drag animations. The icon area always reserves space for six columns and three rows, regardless of the number of apps. Each page holds up to 13 icons. Fill the first row with four icons, the second with five, then the third with four. The second row starts half a slot to the left; the third aligns with the first. Partial rows retain those starting positions: nine icons are 4 / 5, ten are 4 / 5 / 1. Collections of up to four icons use one centered row. The Done button uses the same translucent surface, border and highlights as the category selector. The visible gap beside the category selector is wider than the gap between adjacent icon artwork. The icon area is centered independently of the category selector, with a fixed 570-pixel height. All three row positions stay fixed, including when only one or two rows are populated. Button and joystick page changes slide and fade over 300 ms. Category changes slide vertically and fade in over 360 ms, with the category selector’s active surface sliding to the selected item; reduced-motion mode skips these transitions. Switching to another Dock panel replaces the launcher normally. Framely does not change other windows’ transparency or input settings. Closing the launcher returns to Steam’s Dock panel. Steam uses local cover artwork first and falls back to logos or initials. The right capsule opens the plugin library or management settings. Use the left category selector for Favorites, All, Plugins, Steam, Lepton or Desktop apps, the bottom dots to change pages, and the star on hover to favorite apps. Favorited apps show a filled blue star on hover; click it again to remove the favorite without changing the icon order. The transparent surface curves around you; only icons, names and categories are normally visible. Disabled plugins are hidden; safe mode prevents third-party plugin launch. Steam lists installed apps from the Frame's local libraries, not account-only or remote-streaming apps.
@@ -49,3 +49,9 @@ Paired directions are averaged in short time slices before fitting; each target
The result shows RMS target bias and the worst target bias in degrees. Each target also shows its 95th percentile frame error and uncorrected median error, separating systematic bias from sensor noise. Calibration summaries and paired training blocks are recorded in the session log for diagnosis. **Save calibration** persists a correction of head-relative yaw and pitch. At runtime, the corrected direction is rotated into world space with the current headset pose, preserving the eye origin before intersecting the Dock surface. It does not change laser input or the system tracker. Validation compares against highlighted target directions, not against native gaze as a reference. Failed validation cannot overwrite the previous calibration. Old viewport-coordinate calibrations are ignored; run calibration again after this update. The correction handles systematic angular bias and gain; nonlinear tracking errors may remain. OpenVR does not expose the eye camera's exact exposure timestamp, so fast motion may still have timing error.
After substantial changes to headset fit or the window distance, run calibration again.
Press feedback appears only after holding for 200 ms. The Dock entry uses a blue border indicator and opens the quick panel at 600 ms. Launcher icons show a translucent white circular progress border: the halfway tick opens the menu at 400 ms; continuing to 800 ms enters icon editing. Release or cancellation removes the indicator.
### Remote Steam games
The Steam category also shows apps installed on a connected Remote Play computer. A small Remote Play badge appears at the bottom right of their icons. The list refreshes in the background; disconnected hosts disappear, and each App ID has one icon. If both local and remote installations are available, clicking it opens a choice between launching locally and Remote Play. Favorites and manual ordering remain shared for that game. Clicking a remote icon asks Steam to stream from its current host and checks availability again before launching. Artwork uses the local Steam cache, with a placeholder when unavailable. The integration uses the running Steam client UI; if that interface is unavailable, local games remain usable.
+6
View File
@@ -150,3 +150,9 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
Container list headings show installed application names; the data path identifies the container. An APK update stops its container before backing up data. If stopping fails or a native Steam launch still owns it, the installation dialog offers **Close container** with confirmation. Closing interrupts running apps and retains their saves. The reviewed APK remains available so installation can be retried after closing.
### Steam entry retention
**APK → Settings → Keep installed APKs in Steam** defaults to on. Turning it off removes idle Framely-owned entries and disables automatic registration. Launching an app whose **Launch through Steam** option is enabled registers its entry on demand; after the native launch process and container finish, the entry and its temporary Devkit copy are removed. Installed APKs, containers, saves and downloaded game data are retained. Running entries are protected. Failed or interrupted starts are cleaned up after a startup grace period, with retries when Steam is unavailable. Turning retention back on resumes automatic registration.
+7 -1
View File
@@ -2,7 +2,7 @@
[English](../launcher.md)
激光指向 Framely Dock 图标:默认主扳机打开启动台,上扳机打开快捷面板。在 **管理面板 → 启动台 → 主扳机打开启动台** 开关默认开启,关闭后两个入口立即互换。已保存的扳机偏好会继续生效。其他手柄保留普通点击入口。
使用任意支持的点击输入点击 Framely Dock 图标,打开启动台。长按 **600 毫秒** 打开快捷面板,按住期间显示边框进度;长按完成后松手不会再打开启动台。此操作替代原来的主扳机、上扳机分工及扳机互换设置。
启动台是 Dock 内宽 3.6 米、高度保留为 2 米的原生面板,由 Steam 管理位置和曲面;打开时通过 Dock 的面板切换机制替代当前面板,Dock 隐藏时关闭。界面绘制上限固定为 60 FPS,不跟随头显刷新率升高,以限制像素读回与纹理上传负担。这是绘制上限,并不保证实测达到该帧率。启动台使用 4320×2400 的纹理(逻辑分辨率的 3 倍),保留多级纹理过滤以平滑图标边缘,只上传发生变化的区域;拖动动画期间缓存应用排序和搜索结果。图标区域始终保留六列三行的空间,不随应用数量变化;每页最多 13 个图标,按第一排 4 个、第二排 5 个、第三排 4 个的顺序填充。第二排向左错开半格,第三排与第一排对齐;未填满的第二、三排也从相同槽位开始,不按数量重新居中分配。9 个为 4/5,10 个为 4/5/1;1–4 个使用一排居中排列。图标区域独立于分类器居中,高度固定为 570 个界面像素;只有一排或两排图标时,每排的槽位位置仍保持不变。“完成”按钮使用与分类器相同的半透明背景、边框和高光,分类器与图标之间的可见留白大于图标之间的水平留白。按钮与摇杆翻页采用 300 毫秒横向滑动与淡入动效,分类切换采用 360 毫秒纵向滑动与淡入动效,分类器的激活底块同步滑动到选中项,系统启用减少动态效果时跳过这些过渡动画。切换到其他 Dock 面板会正常替代启动台,Framely 不修改其他窗口的透明度或输入设置。关闭启动台后返回 Steam 的 Dock 面板。Steam 优先使用本地封面,缺图时回退到 Logo 或首字母。透明界面沿用户面前弯曲,显示应用图标、名称、分类器和顶部搜索框。右侧胶囊按钮打开管理面板的插件库或设置页。左侧切换收藏、全部、插件、Steam、Lepton 和桌面程序,底部圆点翻页,悬停图标显示收藏按钮,已收藏时显示蓝色实心星标,再次点击可取消收藏。停用插件不显示,安全模式阻止第三方插件启动。Steam 仅展示 Frame 本机各库已安装应用,不包含仅在账号库或串流电脑中的应用。
@@ -51,3 +51,9 @@ Steam 启动成功表示请求已交给 Steam,并不代表游戏加载完成
结果显示各目标偏差的均方根和最差目标偏差,单位为度。各目标还显示 95% 帧误差以及未修正时的中位误差,区分持续偏差和追踪噪声。校准摘要与训练时间片数据会写入会话日志,便于定位问题。“保存校准”持久保存头显坐标系内偏航、俯仰角的修正。运行时使用当前头显姿态把修正方向转换到空间坐标,保留眼睛起点,再与 Dock 曲面求交。不会改变激光输入或系统眼追。误差参照高亮目标方向,不以原生眼追作为正确答案。验证失败不会覆盖原有校准;旧版窗口坐标校准不再应用,更新后请重新校准。此修正处理稳定角度偏移与增益,非线性追踪误差仍可能存在。OpenVR 未公开眼睛摄像头的准确曝光时间,快速移动时仍可能有时序误差。
明显调整头显佩戴方式或窗口距离后,请重新校准。
按住 200 毫秒后才显示进度反馈。Dock 入口使用蓝色边框进度,600 毫秒打开快捷面板。启动台图标使用半透明白色圆形边框进度:中点刻度对应 400 毫秒打开菜单,继续按至 800 毫秒进入图标编辑;松手或取消按压后移除进度。
### Steam 远程畅玩
Steam 分类也显示已连接远程畅玩电脑上安装的应用,图标右下角标有远程图标。列表后台刷新,断连的电脑不再展示;相同 App ID 只显示一个图标;本机和远程都可用时,点击弹窗选择本机启动或远程畅玩,收藏和手动排序保持同一份。点击远程图标会先重新检查可用状态,再让 Steam 从对应电脑串流。图片使用本机 Steam 缓存,缺图显示占位图。此功能读取正在运行的 Steam 客户端界面;该接口不可用时,本机游戏仍可使用。
+6
View File
@@ -138,3 +138,9 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
容器列表标题显示已安装应用名称,数据路径用于识别容器。更新 APK 会先停止容器再备份数据。如果停止失败,或原生 Steam 启动进程尚未释放容器,安装确认中提供 **关闭容器** 按钮并要求确认。关闭会中断运行中的应用,保留存档;已检查的 APK 会保留,可关闭后重试安装。
### Steam 入口保留
**APK → 设置 → 保留安装的 APK 到 Steam** 默认开启。关闭后清理空闲的 Framely 自有入口,不再自动注册。应用启用 **通过 Steam 启动** 时,会在启动时临时注册入口;原生启动进程和容器退出后删除入口及临时 Devkit 副本。已安装 APK、容器、存档和下载的游戏数据都会保留,运行中的入口不会删除。失败或中断的启动在启动宽限期后清理;Steam 不可用时稍后重试。重新开启保留设置会恢复自动注册。
+1 -1
View File
@@ -1985,7 +1985,7 @@ dependencies = [
[[package]]
name = "framely-installer"
version = "0.4.1-preview.9"
version = "0.4.1-preview.10"
dependencies = [
"anyhow",
"base64 0.22.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely-installer"
version = "0.4.1-preview.9"
version = "0.4.1-preview.10"
edition = "2024"
license = "AGPL-3.0-only"
+5 -1
View File
@@ -64,7 +64,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
Preview requires an available GPU or software Vulkan renderer. Default release builds do not include preview support.
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.9`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.10`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
Actions builds Linux x64/ARM64, Windows x64, and macOS Intel/Apple Silicon. macOS produces an `.app` ZIP, Windows an EXE ZIP, and Linux a tar.gz. Apple notarization and Windows code signing are not configured; verify first-launch behavior on each system before publishing.
@@ -85,3 +85,7 @@ cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --
After connecting to Frame, click **Export logs** in the footer and choose a ZIP destination on your computer. You do not need to select a release or installation package. Collection runs directly over SSH with administrator access, independently of Framely’s web panel and daemon. It also works with stopped services or damaged state, and includes the installer’s current operation log. Export does not install, restart or change Framely.
The archive includes bounded recent service/plugin/CEF logs, GPU evidence, installation versions and a plugin status summary. Missing sources are listed in `report.json`. Plugin settings, inbox contents and password files are excluded; common credential patterns are masked. Review logs before sharing. Cancelling the save dialog leaves the installation untouched.
### Proxy preferences
The **Settings** tab controls downloads on the installer computer. System proxy discovery is enabled by default. An explicit HTTP/SOCKS proxy takes precedence; leave it empty to follow the system toggle. An optional HTTPS GitHub proxy prefix routes GitHub API and asset URLs as `prefix/original-URL`. Settings persist locally and apply to subsequent version checks and downloads; SSH and Frame settings are unchanged. Saving clears the release selection so the next version check uses the new settings. Package verification remains enabled.
+5 -1
View File
@@ -64,7 +64,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
预览依赖可用的 GPU 或软件 Vulkan 渲染器;默认发行构建不包含此预览功能。
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.9`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.10`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
Actions 构建 Linux x64/ARM64、Windows x64、macOS Intel/Apple Silicon。macOS 输出 `.app` ZIP,Windows 输出 EXE ZIP,Linux 输出 tar.gz。当前未配置 Apple 公证或 Windows 代码签名,发布前应分别验证系统的首次启动体验。
@@ -85,3 +85,7 @@ cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --
连接 Frame 后,点击底部的 **导出日志**,选择电脑上的 ZIP 保存位置,无需选择发行版本或安装包。安装器通过 SSH 和管理员权限独立收集,不依赖网络面板或本体服务;服务停止、状态文件损坏时仍能导出,并包含安装器当前的操作日志。导出不会安装、重启或修改 Framely。
日志包包含有大小限制的近期服务、插件和 CEF 日志、GPU 信息、安装版本及插件状态摘要。缺失来源记录在 `report.json` 中。不包含插件设置、收件箱内容和密码文件,并过滤常见凭据格式;分享前仍应检查日志。取消保存弹窗不会改变安装状态。
### 代理设置
**设置**页控制安装器电脑的下载。默认启用系统代理;填写 HTTP/SOCKS 代理后优先使用指定地址,留空则遵循系统代理开关。可选的 HTTPS GitHub 代理以 `代理前缀/原始 URL` 转发 GitHub API 和资源请求。设置在本机保存,后续检查版本和下载时生效,不更改 SSH 或 Frame 的设置。保存会清除版本选择,下次检查版本使用新配置;包校验继续启用。
+38 -1
View File
@@ -48,6 +48,9 @@ enum Event {
}
struct Installer {
page: usize,
system_proxy: bool,
http_proxy: Entity<InputState>,
github_proxy: Entity<InputState>,
chosen_action: String,
device_state: Option<Installation>,
preview: bool,
@@ -104,9 +107,18 @@ impl Installer {
.masked(masked)
})
};
let proxy_result = framely_installer::proxy::load();
let proxy_error = proxy_result
.as_ref()
.err()
.map(|_| "无法读取代理设置,请在设置页重新保存。".to_owned());
let proxies = proxy_result.unwrap_or_default();
let networks = discovery::networks();
let mut view = Self {
page: 0,
system_proxy: proxies.system,
http_proxy: input(&proxies.http, "http://127.0.0.1:7890", false),
github_proxy: input(&proxies.github, "https://your-github-proxy.example", false),
chosen_action: "install".into(),
device_state: None,
preview: cfg!(feature = "visual-test")
@@ -145,7 +157,7 @@ impl Installer {
progress: None,
logs: Vec::new(),
status: "开启 Frame 开发者模式并设置密码,将电脑和 Frame 连接到同一网络。".into(),
error: None,
error: proxy_error,
release_error: None,
connection_stage: None,
sender,
@@ -587,6 +599,30 @@ impl Installer {
self.queue_prompt(dialogs::Prompt::Operation(self.chosen_action.clone()));
cx.notify();
}
fn save_proxy(&mut self, cx: &mut Context<Self>) {
let settings = framely_installer::proxy::DownloadSettings {
system: self.system_proxy,
http: self.http_proxy.read(cx).value().trim().to_owned(),
github: self
.github_proxy
.read(cx)
.value()
.trim()
.trim_end_matches('/')
.to_owned(),
};
match framely_installer::proxy::save(settings) {
Ok(()) => {
self.error = None;
self.release_error = None;
self.releases.clear();
self.selected = None;
self.status = "代理设置已保存,下次下载及检查版本时生效。".into();
}
Err(error) => self.error = Some(format!("保存代理设置失败:{error}")),
}
cx.notify();
}
fn navigate(&mut self, page: usize, cx: &mut Context<Self>) {
if self.busy {
return;
@@ -801,6 +837,7 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
(0, "devices"),
(1, "versions"),
(2, "maintenance"),
(3, "settings"),
(1, "local"),
(0, "empty"),
(0, "connecting"),
+192
View File
@@ -3,6 +3,117 @@ use anyhow::{Result, bail, ensure};
use std::{collections::HashMap, net::IpAddr};
use url::Url;
/// Installer-only download preferences. Explicit HTTP proxy overrides automatic
/// discovery; GitHub routing rewrites only trusted GitHub source hosts.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct DownloadSettings {
pub system: bool,
pub http: String,
pub github: String,
}
impl Default for DownloadSettings {
fn default() -> Self {
Self {
system: true,
http: String::new(),
github: String::new(),
}
}
}
impl DownloadSettings {
pub fn validate(&self) -> Result<()> {
ensure!(
self.http.len() <= 2048 && self.github.len() <= 2048,
"代理地址过长"
);
if !self.http.is_empty() {
parse_proxy(&self.http)?;
}
if !self.github.is_empty() {
let url =
Url::parse(&self.github).map_err(|_| anyhow::anyhow!("GitHub 代理地址无效"))?;
ensure!(
url.scheme() == "https"
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none(),
"GitHub 代理必须是 HTTPS 地址,不能含账号、查询参数或片段"
);
}
Ok(())
}
pub fn route(&self, url: &Url) -> Url {
if !self.github.is_empty()
&& matches!(
url.host_str(),
Some(
"github.com"
| "api.github.com"
| "raw.githubusercontent.com"
| "objects.githubusercontent.com"
| "release-assets.githubusercontent.com"
)
)
&& !url
.as_str()
.starts_with(&format!("{}/", self.github.trim_end_matches('/')))
{
Url::parse(&format!("{}/{}", self.github.trim_end_matches('/'), url))
.expect("validated GitHub proxy")
} else {
url.clone()
}
}
pub fn proxy_for(&self, url: &Url) -> Result<Option<ureq::Proxy>> {
if !self.http.is_empty() {
return parse_proxy(&self.http).map(Some);
}
if self.system { for_url(url) } else { Ok(None) }
}
}
static DOWNLOAD_SETTINGS: std::sync::OnceLock<std::sync::RwLock<DownloadSettings>> =
std::sync::OnceLock::new();
fn active_settings() -> &'static std::sync::RwLock<DownloadSettings> {
DOWNLOAD_SETTINGS.get_or_init(|| std::sync::RwLock::new(DownloadSettings::default()))
}
pub fn current() -> DownloadSettings {
active_settings().read().unwrap().clone()
}
fn settings_path() -> Result<std::path::PathBuf> {
Ok(
directories::ProjectDirs::from("org", "Framely", "Installer")
.ok_or_else(|| anyhow::anyhow!("无法确定配置目录"))?
.config_dir()
.join("download-settings.json"),
)
}
pub fn load() -> Result<DownloadSettings> {
let path = settings_path()?;
let settings = if path.exists() {
serde_json::from_slice::<DownloadSettings>(&std::fs::read(path)?)?
} else {
DownloadSettings::default()
};
settings.validate()?;
*active_settings().write().unwrap() = settings.clone();
Ok(settings)
}
pub fn save(settings: DownloadSettings) -> Result<()> {
settings.validate()?;
let path = settings_path()?;
std::fs::create_dir_all(path.parent().unwrap())?;
let mut file = tempfile::NamedTempFile::new_in(path.parent().unwrap())?;
use std::io::Write;
file.write_all(&serde_json::to_vec_pretty(&settings)?)?;
file.as_file().sync_all()?;
file.persist(path)?;
*active_settings().write().unwrap() = settings;
Ok(())
}
#[derive(Default)]
struct Settings {
proxies: HashMap<String, String>,
@@ -558,3 +669,84 @@ mod tests {
);
}
}
#[cfg(test)]
mod download_settings_tests {
use super::*;
#[test]
fn default_preserves_system_discovery_and_old_preferences() {
assert!(DownloadSettings::default().system);
assert!(
serde_json::from_str::<DownloadSettings>("{}")
.unwrap()
.system
);
}
#[test]
fn disabled_system_and_explicit_override() {
let mut settings = DownloadSettings {
system: false,
..Default::default()
};
let target = Url::parse("https://github.com/a/b").unwrap();
assert!(settings.proxy_for(&target).unwrap().is_none());
settings.http = "http://127.0.0.1:7890".into();
settings.validate().unwrap();
assert!(settings.proxy_for(&target).unwrap().is_some());
}
#[test]
fn github_routes_only_exact_hosts_and_does_not_double_wrap_redirects() {
let settings = DownloadSettings {
github: "https://proxy.example/prefix/".into(),
..Default::default()
};
settings.validate().unwrap();
for host in [
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"objects.githubusercontent.com",
"release-assets.githubusercontent.com",
] {
let original = Url::parse(&format!("https://{host}/a?x=1")).unwrap();
let routed = settings.route(&original);
assert_eq!(
routed.as_str(),
format!("https://proxy.example/prefix/{original}")
);
assert_eq!(settings.route(&routed), routed);
}
for host in ["github.com.evil.example", "example.org", "proxy.example"] {
let url = Url::parse(&format!("https://{host}/a")).unwrap();
assert_eq!(settings.route(&url), url);
}
}
#[test]
fn invalid_preferences_are_rejected_before_saving() {
for github in [
"http://proxy.example",
"https://proxy.example?q=1",
"https://u:p@proxy.example",
"https://proxy.example/#x",
] {
assert!(
DownloadSettings {
github: github.into(),
..Default::default()
}
.validate()
.is_err()
);
}
for http in ["file:///tmp/x", "http://[::1]:1234"] {
assert!(
DownloadSettings {
http: http.into(),
..Default::default()
}
.validate()
.is_err()
);
}
}
}
+7 -5
View File
@@ -30,27 +30,29 @@ pub fn validate_repo(repo: &str) -> Result<()> {
);
Ok(())
}
fn agent(url: &url::Url) -> Result<ureq::Agent> {
fn agent(url: &url::Url, settings: &crate::proxy::DownloadSettings) -> Result<ureq::Agent> {
let mut builder = ureq::AgentBuilder::new()
.try_proxy_from_env(false)
.timeout(Duration::from_secs(120))
.redirects(0);
if let Some(proxy) = crate::proxy::for_url(url)? {
if let Some(proxy) = settings.proxy_for(url)? {
builder = builder.proxy(proxy);
}
Ok(builder.build())
}
fn get(url: &str) -> Result<ureq::Response> {
let settings = crate::proxy::current();
let mut url = url::Url::parse(url)?;
for _ in 0..=5 {
ensure!(url.scheme() == "https", "下载地址必须使用 HTTPS");
// Re-evaluate bypass rules for every redirect target.
let response = agent(&url)?
.get(url.as_str())
let routed = settings.route(&url);
let response = agent(&routed, &settings)?
.get(routed.as_str())
.set("User-Agent", "Framely-Installer")
.call()?;
if (300..400).contains(&response.status()) {
url = url.join(response.header("Location").context("重定向缺少地址")?)?;
url = routed.join(response.header("Location").context("重定向缺少地址")?)?;
} else {
return Ok(response);
}
+23 -3
View File
@@ -1,6 +1,6 @@
use super::*;
use gpui_kit::assets::IconName;
use gpui_kit::component::Icon;
use gpui_kit::component::{Icon, switch::Switch};
use gpui_kit::prelude::FluentBuilder;
pub(super) const BG: u32 = 0x15171b;
@@ -479,6 +479,24 @@ impl Installer {
)
.into_any_element()
}
fn settings_page(&self, cx: &mut Context<Self>) -> AnyElement {
panel()
.child(heading("代理设置", "仅影响安装器检查版本与下载,不改变电脑或 Frame 的系统设置。"))
.child(div().flex().items_center().justify_between()
.child(div().flex().flex_col().gap_2().child("使用系统代理")
.child(muted("读取环境变量和系统网络设置;填写 HTTP 代理后优先使用指定地址。")))
.child(Switch::new("system-proxy").checked(self.system_proxy)
.accessibility_label("使用系统代理").disabled(self.busy)
.on_change(cx.listener(|view, checked, _, cx| { view.system_proxy = *checked; cx.notify(); }))))
.child(field("HTTP 代理", &self.http_proxy, self.busy))
.child(muted("留空时使用系统代理开关的选择。支持 HTTP 和 SOCKS 代理。"))
.child(field("GitHub 代理", &self.github_proxy, self.busy))
.child(muted("留空直接访问 GitHub。填写 HTTPS 前缀地址后,GitHub 请求按“代理地址/原始 URL”转发;仍保留包校验。"))
.when(self.error.is_some(), |this| this.child(div().text_color(rgb(0xf7a7a7)).child(self.error.clone().unwrap())))
.child(div().flex().justify_end().child(Button::new("save-proxy").primary().label("保存")
.disabled(self.busy).on_click(cx.listener(|view, _, _, cx| view.save_proxy(cx)))))
.into_any_element()
}
fn release_page(&self, cx: &mut Context<Self>) -> AnyElement {
let mut list = div()
.id("release-list")
@@ -1037,7 +1055,8 @@ impl Render for Installer {
let body = match self.page {
0 => self.device_page(cx),
1 => self.release_page(cx),
_ => self.maintenance_page(cx),
2 => self.maintenance_page(cx),
_ => self.settings_page(cx),
};
let navigation = div()
.flex()
@@ -1053,6 +1072,7 @@ impl Render for Installer {
("连接设备", IconName::Monitor),
("选择版本", IconName::Package),
("安装与维护", IconName::Wrench),
("设置", IconName::Settings),
]
.into_iter()
.enumerate()
@@ -1161,7 +1181,7 @@ impl Render for Installer {
view.navigate(next, cx);
})),
);
} else {
} else if self.page == 2 {
if let Some(url) = self.manager_url() {
footer = footer.child(
Button::new("manager")
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <vector>
inline std::chrono::steady_clock::time_point entry_event_time(std::chrono::steady_clock::time_point received,float age){
if(!std::isfinite(age)||age<0)return received;
return received-std::chrono::duration_cast<std::chrono::steady_clock::duration>(std::chrono::duration<float>(age));
}
// Overlay mouse events are authoritative: never synthesize another click from
// controller actions. Keep the initiating device/button paired until release.
struct EntryHold {
using Clock=std::chrono::steady_clock;
enum class Action { Idle, Launcher, QuickPanel };
static constexpr auto quick_panel_delay=std::chrono::milliseconds(600);
static constexpr auto feedback_delay=std::chrono::milliseconds(200);
bool pressed=false,fired=false;
unsigned device=~0u,button=0;
Clock::time_point started{},armed_since{},last_inside{};
void cancel(){pressed=false;fired=false;}
Action tick(Clock::time_point now,bool available,bool inside){
if(!pressed)return Action::Idle;
if(!available){cancel();return Action::Idle;}
if(inside)last_inside=now;
// Brief pointer jitter is tolerated, but leaving cannot activate a hold.
if(!inside){if(now-last_inside>std::chrono::milliseconds(150))cancel();return Action::Idle;}
if(!fired&&now-armed_since>=quick_panel_delay){fired=true;return Action::QuickPanel;}
return Action::Idle;
}
Action event(bool down,unsigned source,unsigned mouse_button,bool available,bool inside,Clock::time_point now,Clock::time_point received={}){
if(down){
if(available&&inside&&(!pressed||(source==device&&mouse_button==button))){pressed=true;fired=false;device=source;button=mouse_button;started=now;armed_since=received==Clock::time_point{}?now:received;last_inside=armed_since;}
return Action::Idle;
}
// Steam may omit the device on release; known other devices still cannot release this hold.
if(!pressed||(source!=device&&source!=~0u&&device!=~0u)||mouse_button!=button)return Action::Idle;
auto action=Action::Idle;
if(!fired&&available&&inside)action=now-started>=quick_panel_delay?Action::QuickPanel:Action::Launcher;
cancel();return action;
}
// A confirmed physical release without a matching overlay release must not
// become a long press. Prefer short-click behavior over inventing its duration.
Action released(bool available,bool inside){
const auto action=pressed&&!fired&&available&&inside?Action::Launcher:Action::Idle;
cancel();return action;
}
float progress(Clock::time_point now)const{
if(!pressed||now-armed_since<feedback_delay)return 0.f;
const float elapsed=std::chrono::duration<float>(now-armed_since-feedback_delay).count();
const float remaining=std::chrono::duration<float>(quick_panel_delay-feedback_delay).count();
// A tiny nonzero value reveals the track at 100ms without jumping to 1/6 full.
return std::clamp(elapsed/remaining,.001f,1.f);
}
};
inline void entry_progress_border(std::vector<uint8_t>& pixels,float progress){
if(progress<=0||pixels.size()!=128*128*4)return;
constexpr float pi=3.14159265358979323846f,half=61.5f,radius=7.1f;
constexpr float extent=half-radius,arc=pi*.5f*radius,perimeter=8*extent+4*arc;
for(int y=0;y<128;y++)for(int x=0;x<128;x++){
float dx=x-63.5f,dy=y-63.5f,qx=std::abs(dx)-extent,qy=std::abs(dy)-extent;
const float distance=std::hypot(std::max(qx,0.f),std::max(qy,0.f))+std::min(std::max(qx,qy),0.f)-radius;
const float coverage=std::clamp(2.f-std::abs(distance),0.f,1.f);
if(coverage<=0)continue;
// Arc length along the inset rounded rectangle, starting at top center.
float along;
if(dx>=extent&&dy<=-extent)along=extent+(std::atan2(dy+extent,dx-extent)+pi*.5f)*radius;
else if(dx>=extent&&dy>=extent)along=3*extent+arc+std::atan2(dy-extent,dx-extent)*radius;
else if(dx<=-extent&&dy>=extent)along=5*extent+2*arc+(std::atan2(dy-extent,dx+extent)-pi*.5f)*radius;
else if(dx<=-extent&&dy<=-extent)along=7*extent+3*arc+(std::atan2(dy+extent,dx+extent)+pi)*radius;
else if(dy<-extent)along=dx>=0?dx:perimeter+dx;
else if(dx>extent)along=2*extent+arc+dy;
else if(dy>extent)along=4*extent+2*arc-dx;
else along=6*extent+3*arc-dy;
const float alpha=coverage*(along<=progress*perimeter?1.f:.12f);
const int blue[3]={147,197,237};const int i=(y*128+x)*4;
for(int c=0;c<3;c++)pixels[i+c]=uint8_t(pixels[i+c]+(blue[c]-pixels[i+c])*alpha);
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
#include <thread>
static volatile sig_atomic_t stopped=0;
static void stop(int){stopped=1;}
static const int keys[]={BTN_SOUTH,BTN_EAST,BTN_WEST,BTN_NORTH,BTN_TL,BTN_TR,BTN_SELECT,BTN_START,BTN_THUMBL,BTN_THUMBR};
#include "gamepad_mapping.h"
static const int axes[]={ABS_X,ABS_Y,ABS_RX,ABS_RY,ABS_Z,ABS_RZ,ABS_HAT0X,ABS_HAT0Y};
static bool event(int fd,int type,int code,int value){input_event e{};e.type=type;e.code=code;e.value=value;return write(fd,&e,sizeof e)==sizeof e;}
static bool send(int fd,const int* state){bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;}
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#include <linux/input-event-codes.h>
// Action order: A, B, X, Y, LB, RB, View, Menu, LS, RS.
// Xbox/Android maps X to Linux 307 and Y to 308. The positional
// aliases WEST/NORTH do not match these letter names.
inline constexpr int keys[]={BTN_A,BTN_B,BTN_X,BTN_Y,BTN_TL,BTN_TR,BTN_SELECT,BTN_START,BTN_THUMBL,BTN_THUMBR};
+87 -25
View File
@@ -9,6 +9,7 @@
#include "dock_uv_geometry.h"
#include "menu_geometry.h"
#include "menu_input.h"
#include "entry_hold.h"
#include "launcher_input.h"
#include "launcher_fade.h"
#include "launcher_priority.h"
@@ -75,7 +76,9 @@ static std::string launcher_previous_dashboard;
static std::set<std::string> dashboard_keys;
static bool launcher_restore_previous=true,launcher_previous_menu=false;
static std::chrono::steady_clock::time_point launcher_keyboard_back_until{};
static std::atomic<bool> launcher_primary{true};
static EntryHold entry_hold;
static uint64_t entry_source_buttons=0;
static std::map<unsigned,uint64_t> entry_previous_buttons;
static bool entry_actions_ready=false;
static vr::VRActionSetHandle_t entry_set=0,gaze_set=0;
static vr::VRActionHandle_t primary_action=0,upper_action=0,aim_action=0,close_action=0,navigate_action=0;
@@ -163,11 +166,39 @@ static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)thr
// runtime's overlay mouse space, before conversion to CEF's top-left origin.
// Keep a wider curved mesh around the narrow button; transparent margins never hit input.
static constexpr int dock_canvas_width=640,dock_canvas_height=128,dock_icon_left=256;
static std::vector<uint8_t> dock_icon(bool hover,bool active){
auto source=icon(hover,active);notification_badge(source,notification_badge_count.load());std::vector<uint8_t> canvas(dock_canvas_width*dock_canvas_height*4,0);
static std::vector<uint8_t> dock_icon(bool hover,bool active,float progress=0){
auto source=icon(hover,active);entry_progress_border(source,progress);notification_badge(source,notification_badge_count.load());std::vector<uint8_t> canvas(dock_canvas_width*dock_canvas_height*4,0);
for(int row=0;row<dock_canvas_height;row++)std::copy_n(source.data()+row*128*4,128*4,canvas.data()+(row*dock_canvas_width+dock_icon_left)*4);
return canvas;
}
// Keep texture storage alive while OpenVR samples the preceding frame. Raw
// overlay replacement during a hold can disturb the image and hover target.
struct DockIconTexture {
GLuint textures[2]{};int slot=0;std::vector<uint8_t> upload;
void init(){
glGenTextures(2,textures);
for(auto texture:textures){
glBindTexture(GL_TEXTURE_2D,texture);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,dock_canvas_width,dock_canvas_height,0,GL_RGBA,GL_UNSIGNED_BYTE,nullptr);
}
}
bool submit(vr::VROverlayHandle_t overlay,const std::vector<uint8_t>& pixels){
glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);
const int next=1-slot;glBindTexture(GL_TEXTURE_2D,textures[next]);
glPixelStorei(GL_UNPACK_ALIGNMENT,4);glPixelStorei(GL_UNPACK_ROW_LENGTH,0);
glPixelStorei(GL_UNPACK_SKIP_PIXELS,0);glPixelStorei(GL_UNPACK_SKIP_ROWS,0);
// Raster icons use top-left rows; OpenVR OpenGL textures use bottom-left.
merge_paint(upload,pixels.data(),dock_canvas_width,dock_canvas_height,0,static_cast<const cef_rect_t*>(nullptr),true);
glTexSubImage2D(GL_TEXTURE_2D,0,0,0,dock_canvas_width,dock_canvas_height,GL_RGBA,GL_UNSIGNED_BYTE,upload.data());
glFinish();vr::Texture_t texture{reinterpret_cast<void*>(uintptr_t(textures[next])),vr::TextureType_OpenGL,vr::ColorSpace_Auto};
const auto error=overlays->SetOverlayTexture(overlay,&texture);glFlush();
if(error){std::cerr<<"Dock texture submission failed "<<error<<"\n";return false;}
slot=next;return true;
}
void destroy(){glDeleteTextures(2,textures);}
};
static vr::VROverlayHandle_t active_main_window(){
static auto refreshed=std::chrono::steady_clock::time_point{};static vr::VROverlayHandle_t selected=0;
auto now=std::chrono::steady_clock::now();if(now-refreshed<std::chrono::milliseconds(500)&&selected&&overlays->IsActiveDashboardOverlay(selected))return selected;
@@ -306,9 +337,11 @@ static void observe_dashboard(const vr::VREvent_t& event){
if(!error&&key[0]&&dashboard_keys.size()<512)dashboard_keys.insert(key);
}
static void finish_launcher_close(){
launcher_fade.reset();
if(!views.count("launcher"))return;auto& v=*views.at("launcher");
overlays->SetOverlayAlpha(v.overlay,0.f);
// Do not steal focus if Steam or the user already selected another window.
if(!overlays->IsActiveDashboardOverlay(v.overlay))return;
if(!overlays->IsActiveDashboardOverlay(v.overlay)){overlays->HideOverlay(v.overlay);return;}
if(launcher_restore_previous&&!launcher_previous_dashboard.empty()){
vr::VROverlayHandle_t previous=0;if(!overlays->FindOverlay(launcher_previous_dashboard.c_str(),&previous)){
overlays->ShowDashboard(launcher_previous_dashboard.c_str());menu_open=launcher_previous_menu;return;
@@ -327,7 +360,7 @@ static void hide_launcher(bool restore=true,bool immediate=false){
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);
auto now=std::chrono::steady_clock::now();launcher_fade.close(now);launcher_close_at=now+std::chrono::milliseconds(launcher_fade.reduced?0:240);
}
}
static void launcher_back(){
@@ -344,16 +377,17 @@ static bool launcher_accepts(const vr::VREvent_t& e){
static void toggle_entry(bool launcher){
if(launcher){
if(launcher_open){hide_launcher();return;}
launcher_previous_dashboard=selected_dashboard();launcher_previous_menu=menu_open;launcher_restore_previous=true;
if(!launcher_close_at.time_since_epoch().count()||!views.count("launcher")||!overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay)){launcher_previous_dashboard=selected_dashboard();launcher_previous_menu=menu_open;}launcher_restore_previous=true;
menu_open=false;if(views.count("menu"))show(*views.at("menu"),false);
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_session;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->ShowOverlay(view.overlay);
overlays->ShowDashboard("launcher");
// Wake CEF before activation. A small nonzero alpha keeps Steam activation
// working without exposing the previous session's full-opacity texture.
launcher_fade.open(std::chrono::steady_clock::now());
view.host->was_hidden(view.host,0);view.host->invalidate(view.host,PET_VIEW);
overlays->SetOverlayAlpha(view.overlay,.02f);overlays->ShowOverlay(view.overlay);
overlays->ShowDashboard("launcher");
auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"window.framelyLauncherSession="+std::to_string(launcher_session)+";window.dispatchEvent(new Event('framely.launcher.open'))");frame->base.release(&frame->base);
}else{hide_launcher(false);menu_open=!menu_open;if(menu_open)menu_button.opened(std::chrono::steady_clock::now());}
}
@@ -436,10 +470,13 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
if(!entry_actions_ready)return;auto* input=vr::VRInput();vr::VRActiveActionSet_t sets[2]{};sets[0].ulActionSet=entry_set;sets[1].ulActionSet=gaze_set;
// Only the eye action participates in overlay priority. Controller bindings
// keep their existing priority so Steam laser and Dock controls are preserved.
if(launcher_gaze_allowed())sets[1].nPriority=vr::k_nActionSetOverlayGlobalPriorityMin;
if(launcher_gaze_allowed()&&!entry_hold.pressed&&!(available&&(entry_pointer.inside||overlays->IsHoverTargetOverlay(button))))sets[1].nPriority=vr::k_nActionSetOverlayGlobalPriorityMin;
if(input->UpdateActionState(sets,sizeof(sets[0]),gaze_set?2:1)!=vr::VRInputError_None)return;
auto now=std::chrono::steady_clock::now();
const bool gaze_allowed=launcher_gaze_allowed();bool gaze_connected[2]{},gaze_buttons[2]{};
// Dock ownership must be checked before reading confirm edges. Gaze focus
// alone does not own a trigger aimed at the entry, even before mouse-down
// arrives. Disabling gaze disarms it until all confirm inputs are released.
bool gaze_allowed=launcher_gaze_allowed()&&!entry_hold.pressed&&!(available&&(entry_pointer.inside||overlays->IsHoverTargetOverlay(button)));bool gaze_connected[2]{},gaze_buttons[2]{};
float stick_x=0,stick_y=0;bool stick_active=false;vr::TrackedDeviceIndex_t stick_device=vr::k_unTrackedDeviceIndexInvalid;
for(int hand=0;hand<2;hand++){
vr::InputPoseActionData_t pose{};auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);bool hit=available&&entry_pointer.hit(device);
@@ -454,6 +491,7 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
}
if(pose_ok){auto& m=pose.pose.mDeviceToAbsoluteTracking;vr::VROverlayIntersectionParams_t params{};params.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){params.vSource.v[r]=m.m[r][3];params.vDirection.v[r]=-m.m[r][2];}vr::VROverlayIntersectionResults_t result{};hit=hit||(overlays->IsHoverTargetOverlay(button)&&overlays->ComputeOverlayIntersection(button,&params,&result)&&result.vUVs.v[0]>=float(dock_icon_left)/dock_canvas_width&&result.vUVs.v[0]<=float(dock_icon_left+128)/dock_canvas_width);}
if(hit)gaze_allowed=false;
gaze_connected[hand]=device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device);
if(gaze_connected[hand]){vr::InputDigitalActionData_t confirm{};bool active=input->GetDigitalActionData(confirm_action,&confirm,sizeof(confirm),hands[hand])==vr::VRInputError_None&&confirm.bActive;gaze_buttons[hand]=active&&confirm.bState;
if(!active){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};
@@ -486,8 +524,8 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
if(kind==0&&!active)entry_analog[hand].held=false;
if(kind==0&&active)gaze_buttons[hand]|=down;
bool was_held=entry_buttons[hand][kind].held;
if(entry_buttons[hand][kind].update(active,down,hit,now))toggle_entry(kind==0?launcher_primary.load():!launcher_primary.load());
else if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
entry_buttons[hand][kind].update(active,down,hit,now);
if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
}
if(launcher_open&&hand==1){vr::InputDigitalActionData_t close{};bool active=input->GetDigitalActionData(close_action,&close,sizeof(close),hands[hand])==vr::VRInputError_None&&close.bActive;bool down=close.bState;
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};active=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_b",hands[hand],&mode,&state);down=active&&(state.uProperties&vr::VRComponentProperty_IsPressed);}
@@ -543,11 +581,11 @@ int main(int argc,char** argv){
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);
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);DockIconTexture icon_texture;icon_texture.init();if(!icon_texture.submit(button,pixels)){icon_texture.destroy();overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}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);icon_texture.destroy();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;json last_gaze;bool gaze_loaded=false;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));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}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));}});
std::thread poller([port]{int failures=0;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}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));}});
std::thread priority_worker([]{LauncherPriority priority;while(running){priority.update(launcher_priority_active.load());std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;int icon_progress=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}observe_dashboard(global);keyboard_event(global);}launcher_priority_active.store(launcher_open&&launcher_prioritize_ui.load());vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
if(visible&&(!was_visible||dock!=last_dock)){uv={};anchored_at={};sampled_at={};}last_dock=dock;
// Width and aspect are only ray-solver seeds; physical size comes from intersections.
@@ -564,7 +602,7 @@ int main(int argc,char** argv){
if(!main_overlay)main_overlay=active_main_window();
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(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);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)finish_launcher_close();}
// The runtime owns dashboard placement, curvature, visibility and switching.
// Never move the launcher into standing space or hide another app's overlay.
@@ -572,22 +610,43 @@ int main(int argc,char** argv){
if(views.count("launcher")){
bool selected=overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay);
if(launcher_open&&selected)launcher_selected=true;
if(launcher_open&&selected&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(views.at("launcher")->overlay)&&views.at("launcher")->texture_ready[views.at("launcher")->texture_slot])launcher_fade.ready(now);
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))){if(launcher_calibrating)hide_launcher();else launcher_open=false;}
if(launcher_open&&!selected&&overlays->IsDashboardVisible()&&!launcher_dock_interaction()&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(5))){hide_launcher(false,true);}
}
// Toggle once for a matched left click; never activate on a stale release.
auto activate_entry=[&](EntryHold::Action action){
if(action==EntryHold::Action::Idle)return;
toggle_entry(action==EntryHold::Action::Launcher);
if(menu_open&&views.count("menu"))views.at("menu")->host->set_focus(views.at("menu")->host,1);
};
vr::VREvent_t event{};while(overlays->PollNextOverlayEvent(button,&event,sizeof(event))){
laser_cursor(button,event);
if(event.eventType==vr::VREvent_MouseMove||event.eventType==vr::VREvent_MouseButtonDown||event.eventType==vr::VREvent_MouseButtonUp)entry_pointer.move(event.trackedDeviceIndex,event.data.mouse.x,event.data.mouse.y);
if(event.eventType==vr::VREvent_FocusLeave)entry_pointer.leave();
if(event.eventType==vr::VREvent_MouseButtonDown||event.eventType==vr::VREvent_MouseButtonUp){
if(!entry_actions_ready&&menu_button.event(event.eventType==vr::VREvent_MouseButtonDown,event.data.mouse.button==vr::VRMouseButton_Left,anchor_ok,now)){
toggle_entry(launcher_primary.load());if(menu_open){menu_button.opened(now);if(views.count("menu"))views.at("menu")->host->set_focus(views.at("menu")->host,1);}
std::cout<<"Menu "<<(menu_open?"opened":"closed")<<std::endl;
// Cancel any existing gaze gesture before toggling views. This also
// covers a complete down/up pair drained in one frame and unknown devices.
if(event.eventType==vr::VREvent_MouseButtonDown&&anchor_ok&&entry_pointer.inside){
auto edge=gaze_press.cancel();if(gaze_active||edge==LauncherGazePress::Cancel)launcher_gaze_event({{"valid",false},{"phase","cancel"}});gaze_active=false;
}
const auto received=std::chrono::steady_clock::now();const bool was_pressed=entry_hold.pressed;const auto previous_started=entry_hold.started;
activate_entry(entry_hold.event(event.eventType==vr::VREvent_MouseButtonDown,event.trackedDeviceIndex,event.data.mouse.button,anchor_ok&&(event.trackedDeviceIndex==vr::k_unTrackedDeviceIndexInvalid||vr_system->IsTrackedDeviceConnected(event.trackedDeviceIndex)),entry_pointer.inside,entry_event_time(received,event.eventAgeSeconds),received));
if(entry_hold.pressed&&(!was_pressed||entry_hold.started!=previous_started)){
entry_source_buttons=0;vr::VRControllerState_t state{};
if(entry_hold.device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->GetControllerState(entry_hold.device,&state,sizeof(state))){
const auto previous=entry_previous_buttons.find(entry_hold.device);
if(previous!=entry_previous_buttons.end())entry_source_buttons=state.ulButtonPressed&~previous->second;
// An unambiguous single button is safe even if its down event arrived late.
if(!entry_source_buttons&&state.ulButtonPressed&&(state.ulButtonPressed&(state.ulButtonPressed-1))==0)entry_source_buttons=state.ulButtonPressed;
}
}
}
}
if(!anchor_ok)entry_pointer.leave();
const bool entry_connected=entry_hold.device==vr::k_unTrackedDeviceIndexInvalid||vr_system->IsTrackedDeviceConnected(entry_hold.device);
const bool entry_inside=entry_pointer.inside&&entry_pointer.device==entry_hold.device;
if(entry_hold.pressed&&entry_source_buttons){vr::VRControllerState_t state{};if(vr_system->GetControllerState(entry_hold.device,&state,sizeof(state))&&!(state.ulButtonPressed&entry_source_buttons))activate_entry(entry_hold.released(anchor_ok&&entry_connected,entry_inside));}
activate_entry(entry_hold.tick(std::chrono::steady_clock::now(),anchor_ok&&entry_connected,entry_inside));
for(auto&[key,v]:views)v->stick_scrolling=false;
entry_actions(button,anchor_ok);
if(launcher_open){bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held;launcher_dismiss.observe(held);}
@@ -656,11 +715,14 @@ int main(int argc,char** argv){
close_keyboard(false);
}
for(auto i=views.begin();i!=views.end();){auto& v=*i->second;if(v.closed){overlays->ClearOverlayTexture(v.overlay);overlays->DestroyOverlay(v.overlay);if(v.thumbnail)overlays->DestroyOverlay(v.thumbnail);glDeleteTextures(2,v.textures);i=views.erase(i);}else ++i;}
bool hovered=anchor_ok&&overlays->IsHoverTargetOverlay(button);if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);const auto badge=notification_badge_count.load();if(icon_active!=(menu_open||launcher_open)||icon_hover!=hovered||icon_badge!=badge){icon_badge=badge;icon_active=menu_open||launcher_open;pixels=dock_icon(hovered,icon_active);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);}icon_hover=hovered;
bool hovered=anchor_ok&&(entry_hold.pressed||overlays->IsHoverTargetOverlay(button));if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);const auto badge=notification_badge_count.load();const float ring=entry_hold.progress(std::chrono::steady_clock::now());const int progress=ring>0?std::max(1,int(std::lround(ring*120))):0;if(icon_progress!=progress||icon_active!=(menu_open||launcher_open)||icon_hover!=hovered||icon_badge!=badge){pixels=dock_icon(hovered,menu_open||launcher_open,progress/120.f);if(icon_texture.submit(button,pixels)){icon_progress=progress;icon_badge=badge;icon_active=menu_open||launcher_open;}}icon_hover=hovered;
// Remember source state only after draining events, to identify the actual
// newly pressed button rather than unrelated buttons held on the controller.
for(auto device:{entry_pointer.device,entry_hold.device})if(device!=vr::k_unTrackedDeviceIndexInvalid){vr::VRControllerState_t state{};if(vr_system->GetControllerState(device,&state,sizeof(state)))entry_previous_buttons[device]=state.ulButtonPressed;else entry_previous_buttons.erase(device);}
auto sync=overlays->WaitFrameSync(8);
// A timeout already waited: never append another sleep to it. Bound retries
// only for immediate API failures, so a broken sync call cannot busy-spin.
if(sync!=vr::VROverlayError_None&&sync!=vr::VROverlayError_TimedOut)std::this_thread::sleep_until(frame_start+std::chrono::milliseconds(8));
}
close_keyboard();for(auto&[key,v]:views)close_view(*v);auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(5);while(std::chrono::steady_clock::now()<deadline){cef_do_message_loop_work();bool all=true;for(auto&[key,v]:views)all&=v->closed;if(all)break;std::this_thread::sleep_for(std::chrono::milliseconds(5));}poller.join();priority_worker.join();for(auto&[key,v]:views)if(!v->closed){std::cerr<<"Browser shutdown timed out\n";_Exit(7);}for(auto&[key,v]:views){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);glDeleteTextures(2,v->textures);}views.clear();overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();glXMakeContextCurrent(xdisplay,None,None,nullptr);glXDestroyContext(xdisplay,glcontext);glXDestroyPbuffer(xdisplay,glsurface);XCloseDisplay(xdisplay);return 0;
close_keyboard();for(auto&[key,v]:views)close_view(*v);auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(5);while(std::chrono::steady_clock::now()<deadline){cef_do_message_loop_work();bool all=true;for(auto&[key,v]:views)all&=v->closed;if(all)break;std::this_thread::sleep_for(std::chrono::milliseconds(5));}poller.join();priority_worker.join();for(auto&[key,v]:views)if(!v->closed){std::cerr<<"Browser shutdown timed out\n";_Exit(7);}for(auto&[key,v]:views){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);glDeleteTextures(2,v->textures);}views.clear();overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();glXMakeContextCurrent(xdisplay,None,None,nullptr);glXDestroyContext(xdisplay,glcontext);glXDestroyPbuffer(xdisplay,glsurface);XCloseDisplay(xdisplay);return 0;
}
+9 -5
View File
@@ -1,15 +1,19 @@
#pragma once
#include <algorithm>
#include <chrono>
// Compositor alpha fades even when a cached CEF texture has not repainted yet.
// Only compositor alpha animates; CEF can reuse its cached texture throughout.
struct LauncherFade {
bool reduced=false;float from=1,to=1;int duration=0;
bool reduced=false,waiting=false;float from=0,to=0;int duration=0;
std::chrono::steady_clock::time_point started{};
float value(std::chrono::steady_clock::time_point now)const{
if(waiting)return 0;
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;
float eased=to>from?1-(1-t)*(1-t)*(1-t):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;}
void open(std::chrono::steady_clock::time_point now){from=0;to=1;duration=320;started=now;waiting=true;}
void ready(std::chrono::steady_clock::time_point now){if(waiting){waiting=false;started=now;}}
void close(std::chrono::steady_clock::time_point now){from=value(now);waiting=false;to=0;duration=240;started=now;}
void reset(){waiting=false;from=to=0;duration=0;}
};
+2 -1
View File
@@ -13,8 +13,9 @@ inline bool launcher_gaze_ray(const vr::VREyeTrackingData_t& eye,vr::VROverlayIn
struct LauncherGazePress {
bool ready=false,held=false;int owner=-1;
enum Edge {None,Down,Up,Cancel};
Edge cancel(){auto edge=held?Cancel:None;ready=false;held=false;owner=-1;return edge;}
Edge update(bool enabled,const bool (&connected)[2],const bool (&buttons)[2]){
if(!enabled|| (held&&(owner<0||!connected[owner]))){auto edge=held?Cancel:None;ready=false;held=false;owner=-1;return edge;}
if(!enabled|| (held&&(owner<0||!connected[owner]))){return cancel();}
if(held){if(buttons[owner])return None;held=false;owner=-1;ready=!buttons[0]&&!buttons[1];return Up;}
if(!buttons[0]&&!buttons[1]){ready=true;return None;}
if(!ready)return None;ready=false;for(int hand=0;hand<2;hand++)if(connected[hand]&&buttons[hand]){owner=hand;held=true;return Down;}return None;
+98 -6
View File
@@ -24,7 +24,7 @@ mod lifecycle;
mod native;
mod steam_bridge;
mod steam_shortcuts;
mod steam_ui;
pub(crate) mod steam_ui;
mod storage;
pub fn steam_session_started() -> Result<()> {
steam_bridge::start_session()?;
@@ -51,6 +51,10 @@ struct Record {
#[serde(default)]
steam_registration_error: Option<String>,
#[serde(default)]
steam_transient_generation: Option<String>,
#[serde(default)]
steam_transient_expires: u64,
#[serde(default)]
steam_app_id: Option<u32>,
#[serde(default)]
steam_token: Option<String>,
@@ -73,6 +77,8 @@ struct Record {
}
#[derive(Serialize, Deserialize)]
struct Database {
#[serde(default = "close_container_default")]
retain_steam_entries: bool,
#[serde(default)]
auto_stop_container: bool,
#[serde(default = "close_container_default")]
@@ -91,6 +97,7 @@ struct Database {
impl Default for Database {
fn default() -> Self {
Self {
retain_steam_entries: true,
auto_stop_container: false,
stop_container_on_close: true,
gamepad_enabled: false,
@@ -871,7 +878,7 @@ pub fn list(home: &Path) -> Result<Value> {
}
}
Ok(
json!({"apps":apps.iter().map(|a| { let mut v=serde_json::to_value(a).unwrap(); v["steamLaunch"]=json!(db.records.get(&a.id).is_none_or(steam_shortcuts::wanted)); v["steamRegistered"]=json!(db.records.get(&a.id).is_some_and(|r|r.steam_launch));v["steamRegistrationError"]=json!(db.records.get(&a.id).and_then(|r|r.steam_registration_error.as_ref())); v }).collect::<Vec<_>>(),"containers":cs.iter().map(|c| { let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(false); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok(),"gamepadEnabled":db.gamepad_enabled,"autoStopContainer":db.auto_stop_container,"stopContainerOnClose":db.stop_container_on_close}),
json!({"apps":apps.iter().map(|a| { let mut v=serde_json::to_value(a).unwrap(); v["steamLaunch"]=json!(db.records.get(&a.id).is_none_or(steam_shortcuts::wanted)); v["steamRegistered"]=json!(db.records.get(&a.id).is_some_and(|r|r.steam_launch));v["steamRegistrationError"]=json!(db.records.get(&a.id).and_then(|r|r.steam_registration_error.as_ref())); v }).collect::<Vec<_>>(),"containers":cs.iter().map(|c| { let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(false); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok(),"retainSteamEntries":db.retain_steam_entries,"gamepadEnabled":db.gamepad_enabled,"autoStopContainer":db.auto_stop_container,"stopContainerOnClose":db.stop_container_on_close}),
)
}
pub fn launcher(home: &Path) -> Vec<Value> {
@@ -2178,7 +2185,7 @@ pub fn operate(
}
}
let result = operate_internal(home, kind, p, cancel, progress, None);
if kind == "install" && result.is_ok() {
if matches!(kind, "install" | "steam.retention.settings") && result.is_ok() {
steam_shortcuts::wake_registration();
}
result
@@ -2248,20 +2255,32 @@ fn operate_internal(
);
}
record_app(&mut db, &a);
let retain = db.retain_steam_entries;
let rec = db.records.get_mut(id).context("Missing APK record")?;
if enabled {
if enabled && retain {
steam_shortcuts::register(home, &a, rec)?;
} else {
rec.steam_launch = true;
} else if !enabled {
steam_shortcuts::unregister(home, rec)?;
rec.steam_binding = None;
rec.steam_app_id = None;
rec.steam_launch = false;
}
rec.steam_launch = enabled;
rec.steam_preference = Some(enabled);
rec.steam_registration_error = None;
save(home, &db)?;
Ok(json!(true))
}
"steam.retention.settings" => {
let retain = p["enabled"]
.as_bool()
.context("Invalid Steam entry retention setting")?;
cancel.commit(|| {
db.retain_steam_entries = retain;
save(home, &db)?;
Ok(json!(true))
})
}
"lifecycle.settings" => {
let auto = p["autoStopContainer"]
.as_bool()
@@ -3803,6 +3822,79 @@ setup_props
})
}
#[test]
fn transient_steam_entry_is_launch_only_protects_newer_launch_and_preserves_data() {
let f = Fixture::new();
list(&f.home).unwrap();
let id = "test/com.example.app";
let mut db = load(&f.home).unwrap();
assert!(db.retain_steam_entries);
assert!(
serde_json::from_value::<Database>(json!({}))
.unwrap()
.retain_steam_entries
);
db.retain_steam_entries = false;
save(&f.home, &db).unwrap();
assert!(f
.operation("steam.retention.settings", json!({"enabled":"bad"}))
.is_err());
fs::write(f.dir.path().join("running"), "false").unwrap();
let server = fake_steam_registry(&f, 4, false);
steam_shortcuts::synchronize(&f.home).unwrap();
assert!(!load(&f.home).unwrap().records[id].steam_launch);
assert!(steam_shortcuts::ensure_registered(&f.home, id, false).unwrap());
let first = load(&f.home).unwrap().records[id]
.steam_transient_generation
.clone()
.unwrap();
steam_shortcuts::cleanup_transient(&f.home, id, None).unwrap();
assert!(load(&f.home).unwrap().records[id].steam_launch); // startup grace
assert!(steam_shortcuts::ensure_registered(&f.home, id, false).unwrap());
let second = load(&f.home).unwrap().records[id]
.steam_transient_generation
.clone()
.unwrap();
assert_ne!(first, second);
steam_shortcuts::cleanup_transient(&f.home, id, Some(&first)).unwrap();
assert!(load(&f.home).unwrap().records[id].steam_launch);
let path = root(&f.home)
.join("steam")
.join(format!("context-{}.lock", hash("test")));
fs::create_dir_all(path.parent().unwrap()).unwrap();
let lock = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(path)
.unwrap();
assert_eq!(
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
0
);
steam_shortcuts::cleanup_transient(&f.home, id, Some(&second)).unwrap();
assert!(load(&f.home).unwrap().records[id].steam_launch);
drop(lock);
fs::write(f.dir.path().join("running"), "true").unwrap();
steam_shortcuts::cleanup_transient(&f.home, id, Some(&second)).unwrap();
assert!(load(&f.home).unwrap().records[id].steam_launch);
fs::write(f.dir.path().join("running"), "false").unwrap();
steam_shortcuts::cleanup_transient(&f.home, id, Some(&second)).unwrap();
let db = load(&f.home).unwrap();
assert!(!db.records[id].steam_launch);
assert_eq!(db.records[id].steam_preference, Some(true));
assert!(f.home.join(".local/share/lepton/contexts/test/baked/data_overlay/data/com.example.app/files/save").exists());
let calls = server.join().unwrap();
assert_eq!(
calls,
vec![
"list-shortcuts",
"create-shortcut",
"list-shortcuts",
"delete-shortcut"
]
);
}
#[test]
fn automatic_steam_wrapping_defaults_on_is_idempotent_and_preserves_opt_out() {
let f = Fixture::new();
let id = "test/com.example.app";
+6 -1
View File
@@ -185,7 +185,12 @@ pub(super) fn prepare(
}
db.records.get_mut(id).unwrap().steam_app_id = Some(app_id);
save(home, &db)?;
Ok((c, a, json!({"script":path,"env":env}), ownership))
Ok((
c,
a,
json!({"script":path,"env":env,"transientGeneration":db.records[id].steam_transient_generation}),
ownership,
))
}
pub(super) fn run(app: &str, token: &str, command: &[String]) -> Result<()> {
+4
View File
@@ -279,6 +279,7 @@ fn handle(home: &Path, pid: u32, v: Value) -> Result<Value> {
let home = home.to_owned();
let id = id.to_owned();
let token = token.to_owned();
let transient_generation = response["transientGeneration"].as_str().map(str::to_owned);
let gamepad_event = response["env"]["FRAMELY_GAMEPAD_EVENT"]
.as_str()
.map(PathBuf::from);
@@ -375,6 +376,9 @@ fn handle(home: &Path, pid: u32, v: Value) -> Result<Value> {
// affected by an old helper or have its new device removed.
crate::gamepad::stop_context(&c.name);
drop(ownership);
// A generation protects a newer launch from an old exit. A
// persistent entry switched to transient mode has no generation.
let _ = shortcuts::cleanup_transient(&home, &id, transient_generation.as_deref());
});
Ok(response)
}
+89
View File
@@ -207,6 +207,11 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
} else if marker.exists() {
fs::remove_file(&marker)?;
}
r.steam_binding = Some(Binding {
game_id: game.clone(),
native: true,
revision: revision(a),
});
rpc(home, "create-shortcut", Some(&game))?;
let v: Value = serde_json::from_str(&rpc(home, "list-shortcuts", None)?)?;
ensure!(
@@ -346,7 +351,12 @@ pub(super) fn ensure_registered(home: &Path, id: &str, force: bool) -> Result<bo
"No launch activity is available for Steam registration"
);
record_app(&mut db, &a);
let retain = db.retain_steam_entries;
let rec = db.records.get_mut(id).context("Missing APK record")?;
if !retain && !context_busy(home, rec) {
rec.steam_transient_generation = Some(hex::encode(rand::random::<[u8; 16]>()));
rec.steam_transient_expires = now() + 180;
}
rec.steam_preference = Some(true);
if !force
&& rec.steam_launch
@@ -394,6 +404,10 @@ pub(super) fn synchronize(home: &Path) -> Result<()> {
if db.records.get(&a.id).is_some_and(|r| !wanted(r)) {
continue;
}
if !db.retain_steam_entries {
let _ = cleanup_transient(home, &a.id, None);
continue;
}
let force = !registered.contains(&game_id(&a.id));
if !force
&& db.records.get(&a.id).is_some_and(|r| {
@@ -411,6 +425,81 @@ pub(super) fn synchronize(home: &Path) -> Result<()> {
}
Ok(())
}
fn context_busy(home: &Path, r: &Record) -> bool {
if running(&r.context) || native::runtime_name(r).is_some_and(|name| running(&name)) {
return true;
}
let path = root(home)
.join("steam")
.join(format!("context-{}.lock", hash(&r.context)));
if !path.exists() {
return false;
}
let Ok(file) = fs::OpenOptions::new().write(true).open(path) else {
return true;
};
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) != 0 }
}
pub(super) fn cleanup_transient(home: &Path, id: &str, completed: Option<&str>) -> Result<()> {
let _guard = MUTATION
.try_lock()
.map_err(|_| anyhow::anyhow!("Another APK operation is running"))?;
let directory = init(home)?;
let lock = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(directory.join("operation.lock"))?;
ensure!(
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
"Another APK operation is running"
);
let mut db = load(home)?;
if db.retain_steam_entries {
return Ok(());
}
let Some(r) = db.records.get(id) else {
return Ok(());
};
if r.steam_binding.is_none() || r.pending.is_some() {
return Ok(());
}
if let Some(generation) = completed {
if r.steam_transient_generation.as_deref() != Some(generation) {
return Ok(());
}
} else if now() < r.steam_transient_expires {
return Ok(());
}
fs::create_dir_all(directory.join("steam"))?;
let ownership = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(
directory
.join("steam")
.join(format!("context-{}.lock", hash(&r.context))),
)?;
if unsafe { libc::flock(ownership.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0
|| containers(home, &db)
.iter()
.any(|c| c.id == r.context && (c.running || wrapper_active(&c.name)))
{
return Ok(());
}
unregister(home, r)?;
let r = db.records.get_mut(id).unwrap();
r.steam_launch = false;
r.steam_binding = None;
r.steam_app_id = None;
r.steam_registration_error = None;
r.steam_transient_generation = None;
r.steam_transient_expires = 0;
save(home, &db)
}
pub(super) fn start_auto_registration() {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
if AUTO_WAKE.set(tx).is_err() {
+2 -2
View File
@@ -8,7 +8,7 @@ use tungstenite::{client, protocol::WebSocketConfig, Message};
#[cfg(test)]
thread_local! {pub(super) static TEST_RESULT: std::cell::RefCell<Option<Value>>= const {std::cell::RefCell::new(None)};}
fn evaluate(expression: &str) -> Result<Value> {
pub(crate) fn evaluate(expression: &str) -> Result<Value> {
#[cfg(test)]
if let Some(value) = TEST_RESULT.with(|v| v.borrow().clone()) {
return Ok(value);
@@ -84,7 +84,7 @@ fn evaluate(expression: &str) -> Result<Value> {
// Do not return JavaScript exception descriptions: they can contain launch tokens.
ensure!(
v.get("error").is_none() && v["result"].get("exceptionDetails").is_none(),
"Steam could not configure the APK entry"
"Steam UI operation failed"
);
return Ok(v["result"]["result"]["value"].clone());
}
+14 -2
View File
@@ -953,14 +953,26 @@ impl Agent {
}
Ok(json!(true))
}
"steam.list" => Ok(json!(crate::steam::discover(&crate::steam::home()?))),
"steam.list" => {
if p["remoteOnly"] == true {
Ok(crate::steam::remote_snapshot())
} else {
Ok(json!(crate::steam::discover_launcher(
&crate::steam::home()?
)))
}
}
"steam.launch" => {
let launcher_session = p["launcherSession"].as_u64();
let id = p["app"]
.as_u64()
.filter(|v| *v > 0 && *v <= u32::MAX as u64)
.context("Invalid Steam app")? as u32;
crate::steam::launch(id)?;
if let Some(client) = p["remoteClient"].as_str() {
crate::steam::launch_remote(id, client)?;
} else {
crate::steam::launch(id)?;
}
push(
&self.commands,
json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}),
+156 -68
View File
@@ -1,4 +1,5 @@
//! Local Steam library discovery. No account API or network access is needed.
//! Local Steam libraries and connected Remote Play hosts.
mod remote;
use anyhow::{ensure, Context, Result};
use serde::Serialize;
use std::{
@@ -9,6 +10,9 @@ use std::{
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct App {
pub remote: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_client: Option<String>,
pub id: u32,
pub name: String,
pub kind: &'static str,
@@ -202,77 +206,13 @@ pub fn discover(home: &Path) -> Vec<App> {
{
continue;
}
let dir = steam.join("appcache/librarycache").join(id.to_string());
let mut candidates: Vec<_> = fs::read_dir(dir)
.ok()
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.collect();
// Newer Steam clients keep named artwork one level below a content hash.
let nested: Vec<_> = candidates
.iter()
.filter(|p| p.is_dir())
.take(16)
.cloned()
.collect();
for path in nested {
candidates.extend(
fs::read_dir(path)
.ok()
.into_iter()
.flatten()
.flatten()
.take(32)
.map(|e| e.path()),
);
}
for suffix in ["icon.jpg", "icon.png", "library_600x900.jpg", "header.jpg"] {
candidates.push(
steam
.join("appcache/librarycache")
.join(format!("{id}_{suffix}")),
);
}
let artwork_fit = |p: &Path| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
if name.contains("600x900") || name == "library_capsule.jpg" {
(0, "cover")
} else if name.contains("header") {
(1, "cover")
} else if name == "logo.png" {
(2, "contain")
} else {
(3, "contain")
}
};
candidates.sort_by_key(|p| (artwork_fit(p).0, p.clone()));
let icon = candidates.into_iter().take(32).find_map(|p| {
if fs::metadata(&p).ok()?.len() > 1024 * 1024 {
return None;
}
let b = fs::read(&p).ok()?;
let mime = if b.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if b.starts_with(&[0xff, 0xd8, 0xff]) {
"image/jpeg"
} else {
return None;
};
use base64::Engine;
Some((
format!(
"data:{mime};base64,{}",
base64::engine::general_purpose::STANDARD.encode(b)
),
artwork_fit(&p).1,
))
});
let icon = cached_artwork(&steam, id);
apps.insert(
id,
App {
id,
remote: false,
remote_client: None,
name: name.into(),
kind: if text(v, "installdir").is_some_and(|dir| {
fs::read_dir(library.join("steamapps/common").join(dir)).is_ok_and(
@@ -295,6 +235,117 @@ pub fn discover(home: &Path) -> Vec<App> {
}
apps.into_values().collect()
}
fn cached_artwork(steam: &Path, id: u32) -> Option<(String, &'static str)> {
let dir = steam.join("appcache/librarycache").join(id.to_string());
let mut candidates: Vec<_> = fs::read_dir(dir)
.ok()
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.collect();
// Newer Steam clients keep named artwork one level below a content hash.
let nested: Vec<_> = candidates
.iter()
.filter(|p| p.is_dir())
.take(16)
.cloned()
.collect();
for path in nested {
candidates.extend(
fs::read_dir(path)
.ok()
.into_iter()
.flatten()
.flatten()
.take(32)
.map(|e| e.path()),
);
}
for suffix in ["icon.jpg", "icon.png", "library_600x900.jpg", "header.jpg"] {
candidates.push(
steam
.join("appcache/librarycache")
.join(format!("{id}_{suffix}")),
);
}
let artwork_fit = |p: &Path| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
if name.contains("600x900") || name == "library_capsule.jpg" {
(0, "cover")
} else if name.contains("header") {
(1, "cover")
} else if name == "logo.png" {
(2, "contain")
} else {
(3, "contain")
}
};
candidates.sort_by_key(|p| (artwork_fit(p).0, p.clone()));
candidates.into_iter().take(32).find_map(|p| {
if fs::metadata(&p).ok()?.len() > 1024 * 1024 {
return None;
}
let b = fs::read(&p).ok()?;
let mime = if b.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if b.starts_with(&[0xff, 0xd8, 0xff]) {
"image/jpeg"
} else {
return None;
};
use base64::Engine;
Some((
format!(
"data:{mime};base64,{}",
base64::engine::general_purpose::STANDARD.encode(b)
),
artwork_fit(&p).1,
))
})
}
pub fn remote_snapshot() -> serde_json::Value {
serde_json::json!(remote::cached())
}
pub fn discover_launcher(home: &Path) -> Vec<App> {
merge_remote(home, discover(home), remote::cached())
}
fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) -> Vec<App> {
let mut apps: BTreeMap<_, _> = local.into_iter().map(|a| (a.id, a)).collect();
let steam = home.join(".local/share/Steam");
let steam = if steam.exists() {
steam
} else {
home.join(".steam/steam")
};
for a in remote {
if let Some(existing) = apps.get_mut(&a.id) {
// Keep local metadata and a single stable icon, but expose both launch targets.
if existing.remote_client.is_none() {
existing.remote_client = Some(a.client);
}
continue;
}
apps.entry(a.id).or_insert_with(|| {
let icon = cached_artwork(&steam, a.id);
App {
id: a.id,
name: a.name,
kind: "steam",
icon_fit: icon.as_ref().map(|v| v.1).unwrap_or("contain"),
icon: icon.map(|v| v.0),
remote: true,
remote_client: Some(a.client),
}
});
}
apps.into_values().collect()
}
pub fn launch_remote(id: u32, client: &str) -> Result<()> {
remote::launch(id, client)
}
pub fn home() -> Result<PathBuf> {
ensure!(
unsafe { libc::geteuid() } != 0,
@@ -322,6 +373,43 @@ fn launch_installed(home: &Path, id: u32) -> Result<()> {
mod tests {
use super::*;
#[test]
fn remote_inventory_merges_launch_choices_without_duplicate_icons() {
let home = tempfile::tempdir().unwrap();
let local = vec![App {
id: 42,
name: "Local".into(),
kind: "steam",
icon: None,
icon_fit: "contain",
remote: false,
remote_client: None,
}];
let rows = vec![
remote::RemoteApp {
id: 42,
name: "Same remote".into(),
client: "12".into(),
},
remote::RemoteApp {
id: 43,
name: "Remote".into(),
client: "12".into(),
},
remote::RemoteApp {
id: 43,
name: "Other host".into(),
client: "13".into(),
},
];
let apps = merge_remote(home.path(), local, rows);
assert_eq!(apps.len(), 2);
assert!(!apps[0].remote);
assert_eq!(apps[0].name, "Local");
assert_eq!(apps[0].remote_client.as_deref(), Some("12"));
assert!(apps[1].remote);
assert_eq!(apps[1].remote_client.as_deref(), Some("12"));
}
#[test]
fn nested_escaped_and_invalid() {
let t = tokens("\"libraryfolders\" { \"0\" { \"path\" \"/a\\\\b\" } }").unwrap();
let v = object(&t, &mut 0, 0, false).unwrap();
+22
View File
@@ -0,0 +1,22 @@
async function(request) {
const store=window.appStore, remote=window.RemotePlayStore_SteamUI;
if(!store||!remote||!Array.isArray(store.allApps)||!Array.isArray(remote.devices)) {
if(request.mode==='list')return [];
throw Error('Steam remote library is unavailable');
}
const connected=new Set(remote.devices.filter(d=>d.status==='Connected').map(d=>String(d.clientId)));
const rows=[];
for(const a of store.allApps) {
if(!Number.isInteger(a.appid)||a.appid<=0||a.appid>4294967295||a.visible_in_game_list===false||!(a.app_type&(1|2|8)))continue;
for(const r of a.remote_per_client_data||[]) {
const client=String(r.clientid);
if(r.installed===true&&/^\d{1,20}$/.test(client)&&connected.has(client)&&a.BIsPerClientDataLocal?.(r)===false)
rows.push({id:a.appid,name:String(a.display_name||''),client});
}
}
if(request.mode==='list')return rows.slice(0,10000);
if(request.mode!=='launch'||!rows.some(r=>r.id===request.id&&r.client===request.client))return false;
if(typeof SteamClient.Apps.StreamGame!=='function')throw Error('Steam streaming is unavailable');
await SteamClient.Apps.StreamGame(request.id,request.client,-1);
return true;
}
+77
View File
@@ -0,0 +1,77 @@
//! Steam's current connected-host inventory; no account scraping or media reads.
use anyhow::{ensure, Result};
use serde::{Deserialize, Serialize};
use std::sync::{
atomic::{AtomicBool, Ordering},
Mutex, OnceLock,
};
use std::time::{Duration, Instant};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(super) struct RemoteApp {
pub id: u32,
pub name: String,
pub client: String,
}
fn query(request: serde_json::Value) -> Result<serde_json::Value> {
crate::apk::steam_ui::evaluate(&format!("({})({request})", include_str!("remote.js")))
}
fn inventory(value: serde_json::Value) -> Result<Vec<RemoteApp>> {
let rows: Vec<RemoteApp> = serde_json::from_value(value)?;
ensure!(rows.len() <= 10000, "Remote library is too large");
Ok(rows
.into_iter()
.filter(|a| {
a.id > 0 && !a.name.trim().is_empty() && a.name.len() <= 1024 && valid_client(&a.client)
})
.collect())
}
fn valid_client(client: &str) -> bool {
!client.is_empty()
&& client.len() <= 20
&& client.bytes().all(|b| b.is_ascii_digit())
&& client != "0"
}
static CACHE: OnceLock<Mutex<Option<(Instant, Vec<RemoteApp>)>>> = OnceLock::new();
static REFRESHING: AtomicBool = AtomicBool::new(false);
pub(super) fn cached() -> Vec<RemoteApp> {
let cache = CACHE.get_or_init(|| Mutex::new(None));
let snapshot = cache.lock().unwrap().clone();
if snapshot
.as_ref()
.is_none_or(|(at, _)| at.elapsed() >= Duration::from_secs(3))
&& REFRESHING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
std::thread::spawn(|| {
let rows = query(serde_json::json!({"mode":"list"}))
.and_then(inventory)
.unwrap_or_default();
*CACHE.get().unwrap().lock().unwrap() = Some((Instant::now(), rows));
REFRESHING.store(false, Ordering::Release);
});
}
snapshot
.filter(|(at, _)| at.elapsed() < Duration::from_secs(15))
.map(|(_, v)| v)
.unwrap_or_default()
}
pub(super) fn launch(id: u32, client: &str) -> Result<()> {
ensure!(id > 0 && valid_client(client), "Invalid remote Steam game");
ensure!(
query(serde_json::json!({"mode":"launch","id":id,"client":client}))? == true,
"Remote game or host is no longer available"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_remote_entries_and_clients_are_rejected() {
let rows=inventory(serde_json::json!([{ "id":1,"name":"Game","client":"123"},{"id":0,"name":"Invalid","client":"12"},{"id":2,"name":"Unsafe","client":"1);evil()"}])).unwrap();
assert_eq!(rows.len(), 1);
assert!(launch(42, "0").is_err());
assert!(launch(42, r#"1"2"#).is_err());
}
}
+97
View File
@@ -0,0 +1,97 @@
#include "entry_hold.h"
#include "paint_buffer.h"
#include <cassert>
#include <iostream>
int main(){
using A=EntryHold::Action;
using namespace std::chrono;
const auto start=EntryHold::Clock::time_point{}+seconds(10);
// All mouse buttons and runtime-supplied input (without a tracked device).
for(unsigned device:{1u,2u,~0u})for(unsigned button:{1u,2u,4u}){
EntryHold hold;
assert(hold.event(true,device,button,true,true,start)==A::Idle);
assert(hold.event(false,device,button,true,true,start+milliseconds(100))==A::Launcher);
assert(hold.event(false,device,button,true,true,start+milliseconds(110))==A::Idle);
}
EntryHold hold;
hold.event(true,1,1,true,true,start);
assert(hold.progress(start+milliseconds(199))==0);
assert(hold.tick(start+milliseconds(200),true,true)==A::Idle);
assert(hold.progress(start+milliseconds(200))>0&&hold.progress(start+milliseconds(200))<.01f);
assert(std::abs(hold.progress(start+milliseconds(400))-.5f)<.001f);
assert(hold.tick(start+milliseconds(599),true,true)==A::Idle);
assert(hold.progress(start+milliseconds(599))<1);
assert(hold.tick(start+milliseconds(600),true,true)==A::QuickPanel);
assert(hold.tick(start+milliseconds(900),true,true)==A::Idle);
assert(hold.event(false,1,1,true,true,start+seconds(1))==A::Idle);
assert(hold.progress(start+seconds(1))==0);
// Crossing the threshold between frames still resolves exclusively as a hold.
hold.event(true,1,1,true,true,start);
assert(hold.event(false,1,1,true,true,start+milliseconds(600))==A::QuickPanel);
hold.event(true,1,1,true,true,start);
assert(hold.event(false,2,1,true,true,start+milliseconds(100))==A::Idle);
assert(hold.event(false,1,2,true,true,start+milliseconds(110))==A::Idle);
assert(hold.event(false,1,1,true,true,start+milliseconds(120))==A::Launcher);
// A release with no tracked device must not leave an old progress ring held.
hold.event(true,1,1,true,true,start);
assert(hold.event(false,~0u,1,true,true,start+milliseconds(80))==A::Launcher);
assert(!hold.pressed&&hold.progress(start+milliseconds(100))==0);
// Small jitter does not restart the timer; a sustained leave or loss cancels.
hold.event(true,1,1,true,true,start);
hold.tick(start+milliseconds(480),true,true);
assert(hold.tick(start+milliseconds(550),true,false)==A::Idle);
assert(hold.tick(start+milliseconds(610),true,true)==A::QuickPanel);
hold.cancel();hold.event(true,1,1,true,true,start);
hold.tick(start+milliseconds(151),true,false);
assert(!hold.pressed);
assert(hold.event(false,1,1,true,true,start+seconds(1))==A::Idle);
for(bool inside:{false,true}){
hold.event(true,1,1,true,true,start);
assert(hold.tick(start+milliseconds(600),false,inside)==A::Idle);
assert(!hold.pressed);
}
hold.event(true,1,1,true,false,start);
assert(hold.tick(start+seconds(1),true,true)==A::Idle);
hold.event(true,1,1,true,true,start);
assert(hold.event(false,1,1,true,false,start+milliseconds(100))==A::Idle);
// A short click with a late release is judged by event time, not dequeue time.
const auto received=start+seconds(1);
hold.event(true,1,1,true,true,entry_event_time(received,1.f),received);
assert(hold.tick(received,true,true)==A::Idle);
assert(hold.progress(received+milliseconds(199))==0);
assert(hold.event(false,1,1,true,true,entry_event_time(received,.92f),received)==A::Launcher);
// A missing overlay release cannot leave a delayed quick-panel activation.
hold.event(true,1,1,true,true,start);
assert(hold.released(true,true)==A::Launcher);
assert(hold.tick(start+seconds(1),true,true)==A::Idle);
// A fresh down after a lost release starts a new gesture, never the old timer.
hold.event(true,1,1,true,true,start);
hold.event(true,1,1,true,true,start+seconds(1));
assert(hold.progress(start+seconds(1)+milliseconds(80))==0);
assert(hold.event(false,1,1,true,true,start+seconds(1)+milliseconds(80))==A::Launcher);
// A real long hold remains a single long action, including delayed dequeue.
hold.event(true,1,1,true,true,entry_event_time(received,1.f),received);
assert(hold.event(false,1,1,true,true,entry_event_time(received,.3f),received)==A::QuickPanel);
assert(entry_event_time(received,-1.f)==received);
assert(entry_event_time(received,NAN)==received);
// The progress follows the rounded square's border, leaving the logo intact.
std::vector<uint8_t> pixels(128*128*4,23),original=pixels;
entry_progress_border(pixels,0);assert(pixels==original);
entry_progress_border(pixels,.5f);assert(pixels!=original);
for(int y=0;y<128;y++)for(int x=0;x<128;x++){
int i=(y*128+x)*4;assert(pixels[i+3]==original[i+3]);
if(std::abs(x-63.5f)<58&&std::abs(y-63.5f)<58)for(int c=0;c<4;c++)assert(pixels[i+c]==original[i+c]);
}
const int top=(2*128+64)*4;
assert(pixels[top]==147&&pixels[top+1]==197&&pixels[top+2]==237);
assert(pixels[(64*128+125)*4]>original[(64*128+125)*4]);
assert(pixels[(4*128+123)*4]>original[(4*128+123)*4]); // rounded corner
assert(pixels[(5*128+64)*4]==original[(5*128+64)*4]); // no circular ring
struct Rect{int x,y,width,height;};std::vector<uint8_t> upload;
merge_paint(upload,pixels.data(),128,128,0,static_cast<const Rect*>(nullptr),true);
assert(upload[((127-2)*128+64)*4]==pixels[top]);
assert(pixels[top]>pixels[(2*128+63)*4]);
auto partial=pixels;entry_progress_border(pixels,1);assert(pixels!=partial);
std::cout<<"entry_hold: passed\n";
}
+8
View File
@@ -0,0 +1,8 @@
#include "gamepad_mapping.h"
#include <cassert>
int main(){
// Android Vendor_045e_Product_028e.kl scan codes, independent of Linux aliases.
const int androidXbox[]={304,305,307,308,310,311,314,315,317,318};
static_assert(sizeof(keys)==sizeof(androidXbox));
for(int i=0;i<10;i++)assert(keys[i]==androidXbox[i]);
}
+20 -4
View File
@@ -3,8 +3,24 @@
#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);
// Activation/first paint may be late: the fade must not elapse offscreen.
fade.open(now);assert(fade.value(now)==0);assert(fade.value(now+seconds(2))==0);
const auto visible=now+seconds(2);fade.ready(visible);
assert(fade.value(visible)==0);
assert(std::abs(fade.value(visible+milliseconds(160))-.875f)<.001f);
fade.ready(visible+milliseconds(160)); // Repeated visibility does not restart it.
assert(fade.value(visible+milliseconds(320))==1);
// Closing during entry remains continuous; interrupted close resets completely.
auto interrupted=visible+milliseconds(160);fade.close(interrupted);
assert(std::abs(fade.value(interrupted)-.875f)<.001f);
assert(std::abs(fade.value(interrupted+milliseconds(120))-.4375f)<.001f);
assert(fade.value(interrupted+milliseconds(240))==0);
fade.reset();assert(fade.value(interrupted)==0);assert(!fade.waiting);
fade.open(interrupted);assert(fade.value(interrupted)==0);
fade.ready(interrupted);assert(fade.value(interrupted)==0);
fade.reduced=true;assert(fade.value(interrupted)==1);
fade.close(interrupted);assert(fade.value(interrupted)==0);
// Cancelling before activation never produces a late fade or a visible flash.
fade.open(now);fade.close(now+milliseconds(100));assert(fade.value(now+milliseconds(100))==0);
fade.reset();fade.ready(now+seconds(3));assert(fade.value(now+seconds(3))==0);
}
+1 -1
View File
@@ -104,7 +104,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('.launch-spinner'));if(document.querySelectorAll('.launch-spinner').length!==1)throw Error('Launch spinner is not scoped to the pending app');await until(()=>!document.querySelector<HTMLButtonElement>('.launch-icon')!.disabled);if(document.querySelector('.launch-spinner'))throw Error('Timeout left the launch spinner visible');
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+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);
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 surface=document.querySelector<HTMLElement>('.launcher-shell')!;if(surface.style.pointerEvents!=='none'||surface.getAnimations().length)throw Error('Native fade must suppress input without a second page animation');window.dispatchEvent(new Event('framely.launcher.open'));await wait(500);if(surface.style.pointerEvents==='none')throw Error('Reopened launcher kept closing input state');
settings=true;redraw();await wait();const switches=document.querySelectorAll<HTMLInputElement>('[role=switch]');switches[0].click();await wait();if(config.prioritizeUi!==false)throw Error("Resource priority setting not saved");switches[1].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'));
+18
View File
@@ -18,4 +18,22 @@ int main(){
buttons[1]=false;p.update(true,connected,buttons);buttons[0]=true;assert(p.update(true,connected,buttons)==LauncherGazePress::Down);
assert(p.update(false,connected,buttons)==LauncherGazePress::Cancel);
assert(p.update(true,connected,buttons)==LauncherGazePress::None);
// A laser aimed at the Dock entry owns this press even if gaze previously
// focused a launcher icon. Moving away while held must not become gaze-down.
for(int hand=0;hand<2;hand++){
LauncherGazePress dock;bool online[2]{true,true},pressed[2]{false,false};
dock.update(true,online,pressed);pressed[hand]=true;
assert(dock.update(false,online,pressed)==LauncherGazePress::None);
assert(dock.update(true,online,pressed)==LauncherGazePress::None);
pressed[hand]=false;assert(dock.update(true,online,pressed)==LauncherGazePress::None);
pressed[hand]=true;assert(dock.update(true,online,pressed)==LauncherGazePress::Down);
// Dock mouse-down cancels an already-held gaze target instead of releasing
// it (release would click). Same-frame down/up remains disarmed while held.
assert(dock.cancel()==LauncherGazePress::Cancel);
assert(!dock.ready&&!dock.held&&dock.owner==-1);
assert(dock.update(true,online,pressed)==LauncherGazePress::None);
pressed[hand]=false;assert(dock.update(true,online,pressed)==LauncherGazePress::None);
pressed[hand]=true;assert(dock.update(true,online,pressed)==LauncherGazePress::Down);
pressed[hand]=false;assert(dock.update(true,online,pressed)==LauncherGazePress::Up);
}
}
+19
View File
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const calls=[];
const app=(id,remote,extra={})=>({appid:id,display_name:`Game ${id}`,app_type:1,visible_in_game_list:true,remote_per_client_data:remote,BIsPerClientDataLocal:r=>r.clientid==='local',...extra});
const context={window:{appStore:{allApps:[app(1,[{clientid:'12',installed:true}]),app(2,[{clientid:'13',installed:true}]),app(3,[{clientid:'12',installed:false}]),app(4,[{clientid:'local',installed:true}]),app(5,[{clientid:'12',installed:true}],{app_type:8192}),app(6,[{clientid:'12',installed:true}],{visible_in_game_list:false})]},RemotePlayStore_SteamUI:{devices:[{clientId:'12',status:'Connected'},{clientId:'13',status:'Disconnected'},{clientId:'local',status:'Connected'}]}},SteamClient:{Apps:{StreamGame:(...args)=>calls.push(args)}}};
const query=vm.runInNewContext(`(${fs.readFileSync('src/steam/remote.js','utf8')})`,context);
assert.deepEqual(JSON.parse(JSON.stringify(await query({mode:'list'}))),[{id:1,name:'Game 1',client:'12'}]);
assert.equal(await query({mode:'launch',id:1,client:'13'}),false);
assert.equal(await query({mode:'launch',id:999,client:'12'}),false);
assert.equal(calls.length,0);
assert.equal(await query({mode:'launch',id:1,client:'12'}),true);
assert.deepEqual(calls,[[1,'12',-1]]);
context.window.RemotePlayStore_SteamUI.devices=[];
assert.equal(await query({mode:'launch',id:1,client:'12'}),false);
assert.equal((await query({mode:'list'})).length,0);
context.window.appStore=null;
assert.equal((await query({mode:'list'})).length,0);
console.log('Steam remote inventory and validated launch: passed');
+1 -1
View File
@@ -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_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority; do
for test in gamepad_mapping entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority; do
"${CXX:-g++}" -std=c++17 -O2 -pthread -Inative -Inative/vendor/openvr "tests/$test.cpp" -o "target/tests/$test"
"target/tests/$test"
done
+14 -3
View File
@@ -445,8 +445,7 @@
"未找到应用": "No matching apps",
"本体通知,包括检查更新提醒。": "Framely notifications, including update reminders.",
"启动台": "Launcher",
"主扳机打开启动台": "Open launcher with the primary trigger",
"开启后,上扳机打开快捷面板。": "When enabled, the upper trigger opens the quick panel.",
"点击入口图标打开启动台;长按 600 毫秒打开快捷面板。": "Click the entry icon to open the launcher; hold for 600 ms to open the quick panel.",
"长按菜单自动关闭": "Automatically close the action menu",
"指针离开后开始计时,移入后取消。": "Start the timer when the pointer leaves; cancel it when the pointer returns.",
"自动关闭时间(秒)": "Close delay (seconds)",
@@ -922,6 +921,18 @@
"通过 Steam 启动": "Launch through Steam",
"保存 Steam 启动设置": "Save Steam launch settings",
"默认开启,自动包装已有和新安装的侧载应用。注册不启动应用或重启容器;首次通过 Steam 启动前请先关闭运行中的容器。": "Enabled by default. Existing and newly installed sideloaded apps are wrapped automatically. Registration does not start apps or restart containers. Close a running container before its first Steam launch.",
"等待 Steam 注册,Steam 就绪后会自动重试。": "Waiting for Steam registration. It will retry automatically when Steam is ready."
"等待 Steam 注册,Steam 就绪后会自动重试。": "Waiting for Steam registration. It will retry automatically when Steam is ready.",
"空容器": "Empty container",
"关闭容器": "Close container",
"安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。": "Close the container before installation to safely back up app data. This interrupts apps running inside it and retains saves and settings.",
"保留安装的 APK 到 Steam": "Keep installed APKs in Steam",
"默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。": "On by default. Keeps installed apps in the Steam library. When off, entries are registered at launch and removed after exit; app data is retained.",
"保存 Steam 入口设置": "Save Steam entry settings",
"远程畅玩": "Remote Play",
"通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。": "Launch through Steam to use its native window-close controls. Entry retention follows the option on the APK Settings page.",
"Remote game or host is no longer available": "The remote game or host is no longer available. Check the computer connection and try again.",
"Steam UI operation failed": "Steam UI operation failed. Keep Steam awake and try again.",
"选择启动方式": "Choose how to launch",
"本机启动": "Launch locally"
}
}
+1 -2
View File
@@ -445,8 +445,7 @@
"未找到应用": "未找到应用",
"本体通知,包括检查更新提醒。": "本体通知,包括检查更新提醒。",
"启动台": "启动台",
"主扳机打开启动台": "主扳机打开启动台",
"开启后,上扳机打开快捷面板。": "开启后,上扳机打开快捷面板。",
"点击入口图标打开启动台;长按 600 毫秒打开快捷面板。": "点击入口图标打开启动台;长按 600 毫秒打开快捷面板。",
"长按菜单自动关闭": "长按菜单自动关闭",
"指针离开后开始计时,移入后取消。": "指针离开后开始计时,移入后取消。",
"自动关闭时间(秒)": "自动关闭时间(秒)",
+9 -7
View File
@@ -12,13 +12,13 @@ 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={steamLaunch?:boolean;steamRegistered?:boolean;steamRegistrationError?:string;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;orientation?:string|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;gamepadEnabled?:boolean;autoStopContainer?:boolean;stopContainerOnClose?:boolean};
type Data={apps:App[];containers:Container[];warnings:string[];roots:string[];available:boolean;retainSteamEntries?:boolean;gamepadEnabled?:boolean;autoStopContainer?:boolean;stopContainerOnClose?: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:'启动容器',launching:'启动应用',started:'应用已启动,正在检查',installing:'安装 APK',verifying:'核对安装结果','backing-up':'备份容器数据',uninstalling:'卸载应用',done:'完成',failed:'失败',cancelled:'操作已取消'};
const operationLabels:Record<string,string>={'steam.settings':'保存 Steam 启动设置','lifecycle.settings':'保存容器退出设置','gamepad.settings':'保存手柄设置',install:'安装 APK',launch:'启动应用',close:'关闭应用',uninstall:'卸载应用',purge:'清除保留数据',clear:'清除应用数据',reconcile:'核对应用状态',settings:'保存设置','container.start':'启动容器','container.stop':'停止容器','container.delete':'删除容器',restore:'恢复备份',cleanup:'数据清理','root.add':'添加数据位置','root.remove':'移除数据位置'};
const operationLabels:Record<string,string>={'steam.settings':'保存 Steam 启动设置','steam.retention.settings':'保存 Steam 入口设置','lifecycle.settings':'保存容器退出设置','gamepad.settings':'保存手柄设置',install:'安装 APK',launch:'启动应用',close:'关闭应用',uninstall:'卸载应用',purge:'清除保留数据',clear:'清除应用数据',reconcile:'核对应用状态',settings:'保存设置','container.start':'启动容器','container.stop':'停止容器','container.delete':'删除容器',restore:'恢复备份',cleanup:'数据清理','root.add':'添加数据位置','root.remove':'移除数据位置'};
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(''),[displayMode,setDisplayMode]=useState('flat'),[updating,setUpdating]=useState<App|null>(null),[details,setDetails]=useState<App|null>(null),[confirm,setConfirm]=useState<Confirm|null>(null),[job,setJob]=useState<(Job&{cancellable?:boolean;operation?:string})|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);};
@@ -26,7 +26,7 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
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;operation?:string}>('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);}}}
useEffect(()=>{if(!data.apps.some(a=>a.installed&&!a.steam&&a.stateKnown!==false&&!a.pending&&(a.metadata.activities.length||a.activity)&&(a.steamLaunch??true)&&!a.steamRegistered))return;const timer=setInterval(()=>{if(!busyRef.current)void refresh();},20000);return()=>clearInterval(timer);},[data.apps]);
useEffect(()=>{if(data.retainSteamEntries===false||!data.apps.some(a=>a.installed&&!a.steam&&a.stateKnown!==false&&!a.pending&&(a.metadata.activities.length||a.activity)&&(a.steamLaunch??true)&&!a.steamRegistered))return;const timer=setInterval(()=>{if(!busyRef.current)void refresh();},20000);return()=>clearInterval(timer);},[data.apps,data.retainSteamEntries]);
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]);
@@ -43,6 +43,8 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
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 containerTitle=(c:Container)=>data.apps.filter(a=>a.context===c.id&&a.installed).map(a=>a.metadata.name).join(', ')||t('空容器');
const installContainer=review?contextFor(target||updating?.context||data.apps.find(a=>!a.steam&&a.metadata.package===review.metadata.package)?.context||''):undefined;
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">
@@ -51,13 +53,13 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
{!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','数据清理'],['settings','设置']].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==='settings'&&<><SwitchRow label={t('应用退出后自动停止容器')} description={t('默认关闭。应用进程连续消失 15 秒后停止容器;切到后台不会停止。')} checked={!!data.autoStopContainer} disabled={busy||loading} onChange={autoStopContainer=>void perform('lifecycle.settings',{autoStopContainer,stopContainerOnClose:data.stopContainerOnClose??true})}/><SwitchRow label={t('关闭窗口时停止容器')} description={t('收到系统窗口关闭信号后停止容器并保留数据。部分 SteamOS 版本不支持关闭 Lepton 窗口;切到后台不会停止,共享容器中的其他应用会受到保护。')} checked={data.stopContainerOnClose??true} disabled={busy||loading} onChange={stopContainerOnClose=>void perform('lifecycle.settings',{autoStopContainer:!!data.autoStopContainer,stopContainerOnClose})}/><SwitchRow label={t('Frame 手柄输入')} description={t('默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。')} checked={!!data.gamepadEnabled} disabled={busy||loading||!data.available&&!data.gamepadEnabled} onChange={enabled=>void perform('gamepad.settings',{enabled})}/></>}
{view==='settings'&&<><SwitchRow label={t('保留安装的 APK 到 Steam')} description={t('默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。')} checked={data.retainSteamEntries??true} disabled={busy||loading} onChange={enabled=>void perform('steam.retention.settings',{enabled})}/><SwitchRow label={t('应用退出后自动停止容器')} description={t('默认关闭。应用进程连续消失 15 秒后停止容器;切到后台不会停止。')} checked={!!data.autoStopContainer} disabled={busy||loading} onChange={autoStopContainer=>void perform('lifecycle.settings',{autoStopContainer,stopContainerOnClose:data.stopContainerOnClose??true})}/><SwitchRow label={t('关闭窗口时停止容器')} description={t('收到系统窗口关闭信号后停止容器并保留数据。部分 SteamOS 版本不支持关闭 Lepton 窗口;切到后台不会停止,共享容器中的其他应用会受到保护。')} checked={data.stopContainerOnClose??true} disabled={busy||loading} onChange={stopContainerOnClose=>void perform('lifecycle.settings',{autoStopContainer:!!data.autoStopContainer,stopContainerOnClose})}/><SwitchRow label={t('Frame 手柄输入')} description={t('默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。')} checked={!!data.gamepadEnabled} disabled={busy||loading||!data.available&&!data.gamepadEnabled} onChange={enabled=>void perform('gamepad.settings',{enabled})}/></>}
{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==='containers'&&<><div className="apk-list">{data.containers.map(c=><article className="apk-container" key={c.id}><div className="apk-copy"><h2><IconFolder size={24}/>{containerTitle(c)}</h2><code>{c.baked}</code><p>{t(c.steam?'Steam 管理':c.managed?'Framely 管理':'已有侧载容器')} · {t(c.running?'运行中':'已停止')}</p>{!data.apps.some(a=>a.context===c.id&&a.installed)&&<p>{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><SwitchRow label={t('通过 Steam 启动')} description={t('默认开启,自动包装已有和新安装的侧载应用。注册不启动应用或重启容器;首次通过 Steam 启动前请先关闭运行中的容器。')} checked={details.steamLaunch??true} disabled={busy} onChange={enabled=>void perform('steam.settings',{app:details.id,enabled})}/>{(details.steamLaunch??true)&&!details.steamRegistered&&<p role="status">{t('等待 Steam 注册,Steam 就绪后会自动重试。')}</p>}{details.steamRegistrationError&&<p className="error">{details.steamRegistrationError}</p>}<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.orientation??'auto'} disabled={busy} onChange={orientation=>void perform('settings',{app:details.id,orientation})} options={[{value:'auto',label:t('自动选择')},{value:'landscape',label:t('横屏')},{value:'portrait',label:t('竖屏')}]}/></label><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={displayMode} disabled={busy} onChange={setDisplayMode} options={[{value:'flat',label:t('平面窗口')},{value:'vr',label:t('VR 应用')}]}/></label><p className="sub">{t('平面模式显示二维窗口;VR 模式隐藏二维窗口,应用本身需要支持 VR。')}</p><label>{t('目标容器')}<Select label={t('目标容器')} value={target} disabled={true} onChange={setTarget} options={[{value:'',label:t('独立容器(默认)')},...data.containers.filter(c=>!c.steam&&c.id===updating?.context).map(c=>({value:c.id,label:c.name}))]}/></label>{!updating&&<p className="sub">{t('每个新应用使用独立容器,更新保留原容器。')}</p>}{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,showWindow:displayMode==='flat',approve:true})}>{t('确认安装')}</button></footer></>,t('安装 APK'))}
{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><SwitchRow label={t('通过 Steam 启动')} description={t('通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。')} checked={details.steamLaunch??true} disabled={busy} onChange={enabled=>void perform('steam.settings',{app:details.id,enabled})}/>{data.retainSteamEntries!==false&&(details.steamLaunch??true)&&!details.steamRegistered&&<p role="status">{t('等待 Steam 注册,Steam 就绪后会自动重试。')}</p>}{details.steamRegistrationError&&<p className="error">{details.steamRegistrationError}</p>}<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.orientation??'auto'} disabled={busy} onChange={orientation=>void perform('settings',{app:details.id,orientation})} options={[{value:'auto',label:t('自动选择')},{value:'landscape',label:t('横屏')},{value:'portrait',label:t('竖屏')}]}/></label><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&&!confirm&&!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={displayMode} disabled={busy} onChange={setDisplayMode} options={[{value:'flat',label:t('平面窗口')},{value:'vr',label:t('VR 应用')}]}/></label><p className="sub">{t('平面模式显示二维窗口;VR 模式隐藏二维窗口,应用本身需要支持 VR。')}</p><label>{t('目标容器')}<Select label={t('目标容器')} value={target} disabled={true} onChange={setTarget} options={[{value:'',label:t('独立容器(默认)')},...data.containers.filter(c=>!c.steam&&c.id===updating?.context).map(c=>({value:c.id,label:containerTitle(c)}))]}/></label>{!updating&&<p className="sub">{t('每个新应用使用独立容器,更新保留原容器。')}</p>}{installContainer?.running&&!installContainer.steam&&<div className="apk-notice"><p>{t('安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。')}</p><button disabled={busy} onClick={()=>containerAsk(installContainer,'container.stop')}>{t('关闭容器')}</button></div>}{updating&&<p>{t('同包名更新保留数据;签名不匹配或降级不会自动卸载重装。')}</p>}{updating&&<p className="banner">{t('共享容器的备份需手动恢复;独立侧载容器支持一键恢复。')}</p>}{error&&<div className="error" role="alert"><p>{error}</p>{installContainer&&!installContainer.steam&&!installContainer.running&&/container|backup|容器|备份/i.test(error)&&<button disabled={busy} onClick={()=>containerAsk(installContainer,'container.stop')}>{t('关闭容器')}</button>}</div>}<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,showWindow:displayMode==='flat',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(job.phase==='preparing'&&job.operation?operationLabels[job.operation]??phases[job.phase]:phases[job.phase]??job.phase)}</h2><p>{t('操作进行中,请等待实际结果。')}</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('操作已开始,请等待完成;此阶段无法取消。')}</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 日志'))}
+16
View File
@@ -184,3 +184,19 @@
.launch-unavailable .launch-disc{opacity:.45}
.launch-unavailable-mark{position:absolute;right:20px;bottom:12px;display:grid;place-items:center;width:30px;height:30px;border-radius:50%;background:#17191de8;color:#eee;pointer-events:none}
/* Progress is local to the held icon: no React rerender on animation frames. */
.launch-hold-progress{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:108px;height:108px;pointer-events:none;z-index:4;opacity:0;animation:launch-hold-reveal 1ms linear 200ms forwards;overflow:visible}
.launch-hold-progress circle{fill:none;stroke-width:2.5}
.launch-hold-track{stroke:#ffffff26}
.launch-hold-fill{stroke:#ffffffb3;stroke-linecap:round;stroke-dasharray:1;stroke-dashoffset:1;transform:rotate(-90deg);transform-origin:60px 60px;animation:launch-hold-fill 800ms linear forwards}
.launch-hold-tick{fill:none;stroke:#ffffffe6;stroke-width:2;stroke-linecap:round}
@keyframes launch-hold-reveal{to{opacity:1}}
@keyframes launch-hold-fill{to{stroke-dashoffset:0}}
@media(max-width:900px){.launch-hold-progress{width:94px;height:94px}}
.launch-remote-mark{position:absolute;right:18px;bottom:11px;width:29px;height:29px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:#252525e8;color:#f0f0f0;box-shadow:0 2px 6px #0005;pointer-events:none;z-index:2}
@media(max-width:900px){.launch-remote-mark{right:1px;bottom:1px}}
.launch-choice-actions{display:flex;gap:14px;margin:24px 0}.launch-choice-actions button{flex:1;display:flex;align-items:center;justify-content:center;gap:12px;min-height:64px}
+24 -21
View File
@@ -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,IconPackageImport,IconFocus2,IconBan} from '@tabler/icons-react';
import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck,IconSearch,IconX,IconApps,IconSettings,IconPackageImport,IconFocus2,IconBan,IconDeviceDesktopShare} from '@tabler/icons-react';
import {installedIcon} from './store';
import {PluginFrame} from './plugin-frame';
import type {Plugin} from './main';
@@ -12,7 +12,7 @@ import {LauncherCalibration} from './launcher-calibration';
import {matchesSearch,type SearchIndex} from './launcher-search';
export type LauncherConfig={prioritizeUi?:boolean;primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
const defaults:LauncherConfig={prioritizeUi:true,primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
type SteamApp={id:number;name:string;icon?:string;iconFit?:string;kind?:string};
type SteamApp={remote?:boolean;remoteClient?:string;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})));
@@ -29,10 +29,10 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
const[busy,setBusy]=useState(false),[error,setError]=useState(''),[seconds,setSeconds]=useState(config.menuTimeoutSeconds);
useEffect(()=>setSeconds(config.menuTimeoutSeconds),[config.menuTimeoutSeconds]);
async function save(p:Partial<LauncherConfig>){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><SwitchRow label={t('主扳机打开启动台')} description={t('开启后,上扳机打开快捷面板。')} checked={config.primaryTrigger} disabled={busy} onChange={primaryTrigger=>void save({primaryTrigger})}/><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
}
type LocalApp={steamAppId?:number|null;id:string;name:string;kind:'desktop'|'lepton';icon?:string;launchUnavailable?:string|null};
type LaunchItem={key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
type LaunchItem={remote?:boolean;remoteClient?:string;key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
const emptyOrder:string[]=[];
const categories=[{key:'favorites',label:'收藏分类',icon:IconStar},{key:'all',label:'全部',icon:IconLayoutGrid},{key:'plugin',label:'插件',icon:IconPuzzle},{key:'steam',label:'Steam',icon:IconBrandSteam},{key:'lepton',label:'Lepton',icon:IconBrandAndroid},{key:'desktop',label:'桌面程序',icon:IconDeviceDesktop}];
async function launcherRequest<T=any>(method:string,params:unknown={}):Promise<T>{
@@ -48,28 +48,29 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
const [calibrating,setCalibrating]=useState(location.hash==='#calibrate'),calibrationRef=useRef(calibrating);calibrationRef.current=calibrating;
function endCalibration(){void api('host.launcher.calibrate',{active:false}).catch(e=>setError(String(e)));location.hash='';setCalibrating(false);}
useEffect(()=>{const changed=()=>setCalibrating(location.hash==='#calibrate');window.addEventListener('hashchange',changed);return()=>window.removeEventListener('hashchange',changed);},[]);
const[launchChoice,setLaunchChoice]=useState<LaunchItem|null>(null),choiceRef=useRef<LaunchItem|null>(null);choiceRef.current=launchChoice;
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()),[launchingKeys,setLaunchingKeys]=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[]});
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),holdVisual=useRef<SVGSVGElement|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]);
useEffect(()=>{if(!shell.current)return;return installLauncherGaze(shell.current,()=>calibrationRef.current||keyboard.current||state.current.panning,()=>console.log('FRAMELY_GAZE_FOCUS'));},[calibrating]);
function selectCategory(key:string){remembered.current=key;opening.current=false;setFilter(key);}
function cancelHold(){if(hold.current)clearTimeout(hold.current);if(editHold.current)clearTimeout(editHold.current);hold.current=null;editHold.current=null;holdTarget.current=null;}
function cancelHold(){holdVisual.current?.remove();holdVisual.current=null;if(hold.current)clearTimeout(hold.current);if(editHold.current)clearTimeout(editHold.current);hold.current=null;editHold.current=null;holdTarget.current=null;}
function stopEdge(){if(edgeTimer.current)clearTimeout(edgeTimer.current);edgeTimer.current=null;setEdge(0);if(drag.current)drag.current.edge=0;}
function cancelDrag(){if(drag.current)setManual(drag.current.before);drag.current=null;setDragging(null);setGhost(null);stopEdge();}
useEffect(()=>{mounted.current=true;let live=true,sequence=0;const load=()=>{for(const operation of operations.current.values())if(Date.now()-operation.started>=120000)operation.expire();cancelHold();cancelDrag();cancelPan();setEditing(false);long.current=false;const current=++sequence;opening.current=remembered.current===null;setQuery('');setPage(0);setLoading(true);setMenu(null);setOutside(false);void Promise.allSettled([launcherRequest<SteamApp[]>('steam.list'),launcherRequest<LocalApp[]>('desktop.list')]).then(results=>{if(!live||current!==sequence)return;const [steamResult,localResult]=results;if(steamResult.status==='fulfilled')setSteam(steamResult.value);else setError(String(steamResult.reason));if(localResult.status==='fulfilled')setLocal(localResult.value);else setError(String(localResult.reason));if(opening.current){const known=props.current;const hasFavorite=Object.values(known.plugins).some(p=>p.enabled&&p.favorite)||(steamResult.status==='fulfilled'&&steamResult.value.some(a=>known.favorites.includes(a.id)))||known.localFavorites.includes('framely')||(localResult.status==='fulfilled'&&localResult.value.some(a=>known.localFavorites.includes(`${a.kind}:${a.id}`)));selectCategory(hasFavorite?'favorites':'all');}setLoading(false);});};load();const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;setMenu(null);setEditing(false);};window.addEventListener('framely.launcher.cancelInput',cancel);window.addEventListener('framely.launcher.open',load);return()=>{live=false;mounted.current=false;for(const operation of operations.current.values())operation.expire();for(const key of pending.current.keys())done(key,'Launcher closed');window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(panTimer.current)clearTimeout(panTimer.current);};},[]);
useEffect(()=>{mounted.current=true;let live=true,sequence=0;const load=()=>{for(const operation of operations.current.values())if(Date.now()-operation.started>=120000)operation.expire();cancelHold();cancelDrag();cancelPan();setEditing(false);setLaunchChoice(null);long.current=false;const current=++sequence;opening.current=remembered.current===null;setQuery('');setPage(0);setLoading(true);setMenu(null);setOutside(false);void Promise.allSettled([launcherRequest<SteamApp[]>('steam.list'),launcherRequest<LocalApp[]>('desktop.list')]).then(results=>{if(!live||current!==sequence)return;const [steamResult,localResult]=results;if(steamResult.status==='fulfilled')setSteam(steamResult.value);else setError(String(steamResult.reason));if(localResult.status==='fulfilled')setLocal(localResult.value);else setError(String(localResult.reason));if(opening.current){const known=props.current;const hasFavorite=Object.values(known.plugins).some(p=>p.enabled&&p.favorite)||(steamResult.status==='fulfilled'&&steamResult.value.some(a=>known.favorites.includes(a.id)))||known.localFavorites.includes('framely')||(localResult.status==='fulfilled'&&localResult.value.some(a=>known.localFavorites.includes(`${a.kind}:${a.id}`)));selectCategory(hasFavorite?'favorites':'all');}setLoading(false);});};load();const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;setMenu(null);setLaunchChoice(null);setEditing(false);};window.addEventListener('framely.launcher.cancelInput',cancel);window.addEventListener('framely.launcher.open',load);return()=>{live=false;mounted.current=false;for(const operation of operations.current.values())operation.expire();for(const key of pending.current.keys())done(key,'Launcher closed');window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(panTimer.current)clearTimeout(panTimer.current);};},[]);
useEffect(()=>{let enabled=true,pending=false,generation=0,signature:string|null=null;const timer=setInterval(()=>{if(!enabled||pending)return;pending=true;const epoch=generation;void launcherRequest<unknown[]>('steam.list',{remoteOnly:true}).then(async rows=>{const next=JSON.stringify(rows);if(!enabled||epoch!==generation||next===signature)return;const apps=await launcherRequest<SteamApp[]>('steam.list');if(enabled&&epoch===generation){signature=next;setSteam(apps);}}).catch(()=>{}).finally(()=>{pending=false;});},4000);const open=()=>{generation++;enabled=true;signature=null;};const close=()=>{generation++;enabled=false;};window.addEventListener('framely.launcher.open',open);window.addEventListener('framely.launcher.closing',close);window.addEventListener('framely.launcher.cancelInput',close);return()=>{enabled=false;clearInterval(timer);window.removeEventListener('framely.launcher.open',open);window.removeEventListener('framely.launcher.closing',close);window.removeEventListener('framely.launcher.cancelInput',close);};},[]);
useEffect(()=>{if(!drag.current)cancelHold();setMenu(null);setOutside(false);},[filter,page]);
useEffect(()=>{if(!menu||!outside||!config.menuAutoClose||!!(menu&&busyKeys.has(menu)))return;const timer=setTimeout(()=>setMenu(null),config.menuTimeoutSeconds*1000);return()=>clearTimeout(timer);},[menu,outside,busyKeys,config.menuAutoClose,config.menuTimeoutSeconds]);
function cancelPan(){if(panTimer.current)clearTimeout(panTimer.current);panTimer.current=null;swipe.current=null;setPanView(null);}
function close(){if(calibrationRef.current){endCalibration();return;}if(keyboard.current)return;cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void launcherRequest('host.launcher.close');}
function close(){if(choiceRef.current){setLaunchChoice(null);return;}if(calibrationRef.current){endCalibration();return;}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(calibrationRef.current||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 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(choiceRef.current||calibrationRef.current||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=()=>{if(calibrationRef.current){location.hash='';setCalibrating(false);}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.filter(a=>!local.some(l=>l.steamAppId===a.id)).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}`),launchUnavailable:a.launchUnavailable}))];
// The native compositor owns surface fades; animating this high-resolution
// page too would multiply alpha and force a full CEF readback every frame.
useEffect(()=>{const open=()=>{if(shell.current)shell.current.style.pointerEvents='';};const closing=()=>{if(calibrationRef.current){location.hash='';setCalibrating(false);}if(shell.current)shell.current.style.pointerEvents='none';};window.addEventListener('framely.launcher.open',open);window.addEventListener('framely.launcher.closing',closing);return()=>{window.removeEventListener('framely.launcher.open',open);window.removeEventListener('framely.launcher.closing',closing);};},[]);
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.filter(a=>!local.some(l=>l.steamAppId===a.id)).map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,remote:a.remote,remoteClient:a.remoteClient,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}`),launchUnavailable:a.launchUnavailable}))];
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]);
useEffect(()=>{let live=true;const names=Array.from(new Set(all.map(a=>a.name)));if(names.length)void launcherRequest<Record<string,SearchIndex>>('launcher.search.index',{names}).then(v=>{if(live)setIndexes(v);}).catch(()=>{if(live)setError(t('读音搜索暂不可用,仍可使用原文搜索。'));});return()=>{live=false;};},[indexKey]);
@@ -98,7 +99,7 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
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 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;}const indicator=document.createElementNS('http://www.w3.org/2000/svg','svg');indicator.classList.add('launch-hold-progress');indicator.setAttribute('viewBox','0 0 120 120');indicator.setAttribute('aria-hidden','true');indicator.innerHTML='<circle class="launch-hold-track" cx="60" cy="60" r="56"/><circle class="launch-hold-fill" cx="60" cy="60" r="56" pathLength="1"/><path class="launch-hold-tick" d="M60 112v8"/>';e.currentTarget.append(indicator);holdVisual.current=indicator;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 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);}
@@ -106,7 +107,9 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
}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)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){const launcherSession=(window as Window & {framelyLauncherSession?:number}).framelyLauncherSession;if(a.launchUnavailable)return Promise.reject(new Error(t(a.launchUnavailable)));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,launcherSession}):launcherRequest('desktop.launch',{app:a.key,launcherSession});}
function openItem(a:LaunchItem){setMenu(null);if(a.key.startsWith('steam:')&&!a.remote&&a.remoteClient){setLaunchChoice(a);return;}void run(a.key,()=>launch(a),true);}
function chooseLaunch(remote:boolean){if(!launchChoice)return;const item={...launchChoice,remoteClient:remote?launchChoice.remoteClient:undefined};setLaunchChoice(null);void run(item.key,()=>launch(item),true);}
function launch(a:LaunchItem){const launcherSession=(window as Window & {framelyLauncherSession?:number}).framelyLauncherSession;if(a.launchUnavailable)return Promise.reject(new Error(t(a.launchUnavailable)));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,remoteClient:a.remoteClient,launcherSession}):launcherRequest('desktop.launch',{app:a.key,launcherSession});}
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();}
@@ -114,16 +117,16 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
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:'apk',label:'安装APK',icon:IconPackageImport},{page:'catalog',label:'插件库',icon:IconApps},{page:'calibration',label:'眼追校准',icon:IconFocus2,IconBan},{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',()=>page==='calibration'?launcherRequest('host.launcher.calibrate',{active:true}):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:'calibration',label:'眼追校准',icon:IconFocus2,IconBan,IconDeviceDesktopShare},{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',()=>page==='calibration'?launcherRequest('host.launcher.calibrate',{active:true}):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 ${a.launchUnavailable?'launch-unavailable':''}`} title={a.launchUnavailable?t(a.launchUnavailable):undefined} aria-disabled={!!a.launchUnavailable&&!editing} 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;if(a.launchUnavailable){setError(t(a.launchUnavailable));return;}setMenu(null);void run(a.key,()=>launch(a),true);}} onContextMenu={e=>{e.preventDefault();setMenu(a.key);setOutside(false);}}><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/>{a.launchUnavailable&&<span className="launch-unavailable-mark" aria-hidden="true"><IconBan size={23}/></span>}{launchingKeys.has(a.key)&&<span className="launch-starting" role="status" aria-label={t("正在启动")}><span className="launch-spinner" aria-hidden="true"/></span>}</button><span className="launch-name" title={a.name}>{a.name}</span>
<button className={`launch-icon ${a.launchUnavailable?'launch-unavailable':''}`} title={a.launchUnavailable?t(a.launchUnavailable):undefined} aria-disabled={!!a.launchUnavailable&&!editing} 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;if(a.launchUnavailable){setError(t(a.launchUnavailable));return;}openItem(a);}} onContextMenu={e=>{e.preventDefault();setMenu(a.key);setOutside(false);}}><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/>{a.remoteClient&&<span className="launch-remote-mark" title={t("远程畅玩")} aria-label={t("远程畅玩")}><IconDeviceDesktopShare size={19} stroke={1.7}/></span>}{a.launchUnavailable&&<span className="launch-unavailable-mark" aria-hidden="true"><IconBan size={23}/></span>}{launchingKeys.has(a.key)&&<span className="launch-starting" role="status" aria-label={t("正在启动")}><span className="launch-spinner" aria-hidden="true"/></span>}</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':''} ${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" title={a.launchUnavailable?t(a.launchUnavailable):undefined} disabled={busyKeys.has(a.key)||!!a.launchUnavailable} onClick={()=>void run(a.key,()=>launch(a),true)}><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></>}
{a.launchUnavailable&&<small role="status">{t(a.launchUnavailable)}</small>}{busyKeys.has(a.key)&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&&current+panView.direction>=0&&current+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>
{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" title={a.launchUnavailable?t(a.launchUnavailable):undefined} disabled={busyKeys.has(a.key)||!!a.launchUnavailable} onClick={()=>openItem(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></>}
{a.launchUnavailable&&<small role="status">{t(a.launchUnavailable)}</small>}{busyKeys.has(a.key)&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&&current+panView.direction>=0&&current+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}/>{a.remoteClient&&<span className="launch-remote-mark"><IconDeviceDesktopShare size={19} stroke={1.7}/></span>}</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>;
{launchChoice&&<div className="modal-backdrop" onPointerDown={e=>e.stopPropagation()}><div className="modal launch-choice" role="dialog" aria-modal="true" aria-labelledby="launch-choice-title"><h2 id="launch-choice-title">{launchChoice.name}</h2><p>{t('选择启动方式')}</p><div className="launch-choice-actions"><button onClick={()=>chooseLaunch(false)}><IconDeviceDesktop size={24}/>{t('本机启动')}</button><button onClick={()=>chooseLaunch(true)}><IconDeviceDesktopShare size={24}/>{t('远程畅玩')}</button></div><button onClick={()=>setLaunchChoice(null)}>{t('取消')}</button></div></div>}{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>;
}