mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 02:01:27 +02:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3d751541a | ||
|
|
9c7dc06bad | ||
|
|
be36694719 | ||
|
|
786c8b7928 | ||
|
|
2c7863dcfb | ||
|
|
7e374a669a | ||
|
|
b1d0c739b2 | ||
|
|
73fa970021 | ||
|
|
73d2d4289b | ||
|
|
3b7382e1a0 | ||
|
|
d24aa708a7 | ||
|
|
c64bdfb374 | ||
|
|
1fc2e32f26 | ||
|
|
3d2899fb6e | ||
|
|
6589f85ec6 | ||
|
|
e8c406ffca | ||
|
|
16fb76c579 | ||
|
|
c75896541c | ||
|
|
36e1c04758 | ||
|
|
882b22aa62 | ||
|
|
782b6ab419 | ||
|
|
b22d7e9fc3 | ||
|
|
accae9cfbf | ||
|
|
70862a79d8 | ||
|
|
386c131a66 | ||
|
|
9b5e25cdf9 | ||
|
|
dec35d7529 | ||
|
|
45de17f617 | ||
|
|
7c130038f6 | ||
|
|
3ce1a9142f | ||
|
|
78010a93d8 |
No files matched your search
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.4.5-preview.2"
|
||||
version = "0.5.1-preview.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.4.5-preview.2"
|
||||
version = "0.5.1-preview.4"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -52,9 +52,9 @@ Launcher favorites, search and manual ordering continue to use stable context/pa
|
||||
|
||||
## Close, uninstall and clean data
|
||||
|
||||
**Close app** force-stops only the selected package. **Stop container** affects every app inside it. All non-Steam sideloaded containers with a verified data location support deletion after confirmation of affected apps and permanent data loss. Standard Lepton context directories are removed as a whole; registered external locations remove only the baked directory, preserving unrelated files in the parent. Uninstalling an app, even with Delete app data enabled, does not delete its container.
|
||||
**Close app** force-stops only the selected package. **Stop container** affects every app inside it. All non-Steam sideloaded containers with a verified data location support deletion after confirmation of affected apps and permanent data loss. Standard Lepton context directories are removed as a whole; registered external locations remove only the baked directory, preserving unrelated files in the parent. Uninstalling an app keeps its container unless **Delete container** is enabled.
|
||||
|
||||
Uninstallation retains saved data by default. Enable **Delete app data** to remove it. Retained apps appear under **Uninstalled, data retained**, where you can reinstall or remove retained data.
|
||||
Uninstallation retains saved data by default. Enable **Delete app data** to remove it. If the container contains only this app, this also selects **Delete container** by default; you can turn it off to keep the container. Shared containers and unknown application states cannot use this option. The backend checks the inventory again before deleting. Container deletion also removes the associated APK cache and Steam library entry, icon and artwork, while keeping historical backups. Retained apps appear under **Uninstalled, data retained**, where you can reinstall or remove retained data with the same container option.
|
||||
|
||||
Android may require the original signed APK to remove an already uninstalled package's retained state. Framely keeps the original APK when it manages an uninstall. If an externally uninstalled app has no recoverable APK, supply its original APK through reinstallation before removing its retained state. Framely does not bypass signing checks or delete arbitrary directories to simulate success.
|
||||
|
||||
@@ -93,22 +93,36 @@ The initial implementation supports individual APKs. Split APK bundles, XAPK/APK
|
||||
|
||||
### Flat window orientation
|
||||
|
||||
In **Manage app → Advanced settings → Window orientation**, choose **Automatic**, **Landscape**, or **Portrait**. The choice is saved per app and applied at its next launch. Forced portrait uses a tall window rather than rotating content sideways. Changing orientation may restart the shared container and stop other apps in it. VR apps ignore this setting.
|
||||
In **Manage app → Advanced settings → Window orientation**, choose **Automatic**, **Landscape**, or **Portrait**. The choice is saved per app and applied at its next launch. Forced portrait uses a tall window rather than rotating content sideways. Changing orientation may restart the shared container and stop other apps in it. VR apps ignore this setting. Flat APK startup also explicitly selects fullscreen and applies it to the created Android task, avoiding a restored freeform desktop window. This affects startup only; later manual resizing remains available.
|
||||
|
||||
Moonlight V+ 12.12.12’s connection-creation screen currently crashes on the tested Lepton build because Android’s clipboard service is absent. This is distinct from the window-startup failure. Framely does not modify or re-sign the APK; a compatible Lepton runtime or an app-side fallback is required.
|
||||
|
||||
### Optional Frame gamepad input
|
||||
### Optional gamepad input
|
||||
|
||||
**APK → Apps → Frame gamepad input** is off by default, including after upgrading an existing installation. Enable it to use the two Frame controllers as one standard Android gamepad for APKs opened through Framely. It includes ABXY, the D-pad and diagonals, both sticks and stick clicks, shoulder buttons, analog triggers, Select and Start. Applications must support Android gamepad input; this does not map controls to touchscreen gestures or provide Android VR controller tracking or vibration.
|
||||
**APK → Settings → Gamepad input** is off by default, including after upgrading an existing installation. Enable it to forward Steam Input as a standard Android gamepad for APKs opened through Framely. Steam Input is the default source and follows Steam’s controller configurations, including external controllers recognized by Steam. **Frame direct** retains the original OpenVR bindings for compatibility. Close and restart a container to change its source. It includes ABXY, the D-pad and diagonals, both sticks and stick clicks, shoulder buttons, analog triggers, Select and Start. Applications must support Android gamepad input; this does not map controls to touchscreen gestures or provide Android VR controller tracking; supported games can request gamepad rumble.
|
||||
|
||||
Enabling applies on the next application launch. Framely may restart its container to attach the input device, stopping other apps in that container. Disabling stops forwarding immediately. Only one Framely-launched APK receives input at a time. Switching apps, closing an app, transient process-query failures and foreground changes neutralize input without removing the virtual pad. Each running container retains its device for reuse; container stop, disabling the feature or session-service restart releases it. After a service restart, launch the APK again to reconnect. Keep the intended Android window selected in Dock; the bridge does not change Steam's physical-controller bindings.
|
||||
|
||||
The bridge runs as the Steam session user and requires access to `/dev/uinput`, active SteamVR and the supported Lepton startup hooks. It does not modify Steam's installed Lepton files. Only its virtual event node is mounted into the target container, where Android claims it exclusively. If this claim fails, no input is forwarded and the launch reports an error. Disable the option to use the normal launch path. Bridge errors are recorded in `~/.local/share/framely/apk-manager/logs/gamepad.log`; key presses are not logged. The native helper and Android adapter ship in both offline and core update packages.
|
||||
The bridge runs as the Steam session user and requires access to `/dev/uinput`, active SteamVR and the supported Lepton startup hooks. It does not modify Steam's installed Lepton files. Only its virtual event node and read-only Android key layout are mounted into the target container, where Android claims it exclusively. If this claim fails, no input is forwarded and the launch reports an error. Disable the option to use the normal launch path. Bridge errors are recorded in `~/.local/share/framely/apk-manager/logs/gamepad.log`; key presses are not logged. The native helper and Android adapter ship in both offline and core update packages.
|
||||
|
||||
Native Steam launches select the input target before startup acknowledgement. Forwarding begins only after Android claims the virtual device and the target application becomes foreground. Each container's helper has its own temporary SteamVR identity, so another helper cannot replace its registration. Returning to an already running application reselects its input target.
|
||||
Native Steam launches select the input target before startup acknowledgement. Forwarding begins only after Android claims the virtual device and the target application becomes foreground. Frame direct helpers have separate temporary SteamVR identities. Steam Input helpers do not register OpenVR action sets, because that registration can suppress Steam virtual-gamepad enumeration in SDL. Returning to an already running application reselects its input target.
|
||||
|
||||
When this option is enabled, launch APKs through the management panel or launcher. A one-shot CLI launch cannot own the persistent input bridge and reports an error instead. For read-only diagnostics, run `framely apk input-status '{"app":"CONTEXT/PACKAGE"}'` as the Steam session user. It reports Android process, foreground and lifecycle status without input events or Steam tokens; it does not prove that the application handles gamepad events.
|
||||
|
||||
**Gamepad rumble** can be disabled independently and takes effect immediately. Linux `FF_RUMBLE` effects are acknowledged and sent back to the current source controller. Input source changes, Android foreground loss, disconnects and bridge shutdown stop feedback; stale effects are not resumed. Rumble intensity is capped at half strength and each request at ten seconds. Games must use Android gamepad vibration; phone vibration and Android VR haptics are not forwarded.
|
||||
|
||||
Steam Input loads SDL3 from Steam’s bundled ARM64 runtime and reads Steam’s virtual gamepads, never the physical devices directly. Framely’s output has a distinct device identity and is excluded to prevent a feedback loop. If the Steam runtime is unavailable, launch reports an error; choose Frame direct explicitly instead of silently bypassing Steam’s mappings. With multiple source controllers, the most recently active controller supplies input and receives rumble; they are not merged.
|
||||
|
||||
### 可选手柄输入
|
||||
|
||||
**APK → 设置 → 手柄输入**默认关闭,旧配置升级也保持关闭。开启后默认使用 Steam Input,沿用 Steam 的手柄配置,支持 Frame 手柄及 Steam 已识别的外接手柄。可选择 **Frame 直连(兼容模式)**保留原有 OpenVR 输入。切换来源需关闭容器后重新启动;关闭输入立即生效。
|
||||
|
||||
支持 ABXY、十字键及斜向、双摇杆及按压、肩键、模拟扳机、Select 与 Start;应用必须原生支持 Android 游戏手柄,不模拟触摸或 VR 控制器追踪。同时仅一个目标 APK 接收输入,Android 前台状态丢失、查询失败时清零,但保留虚拟设备以便恢复。
|
||||
|
||||
**手柄震动**可独立关闭并立即生效。支持游戏请求的 `FF_RUMBLE`,回传给当前输入手柄;切换来源、失去 Android 前台状态、断连或退出时停止,不恢复旧效果。强度上限为一半,单次请求最长十秒;不转发手机振动或 Android VR 触觉。多个手柄以最近产生有效输入的手柄为来源,不合并输入。
|
||||
|
||||
桥接以 Steam 会话用户运行,使用 Steam 随附的 ARM64 SDL3,仅读取 Steam 虚拟手柄。Framely 输出设备使用独立标识并排除自身,避免反馈循环。仅把专用事件节点及只读按键布局挂载进容器,不修改系统或 Steam 安装文件。Frame 直连使用独立的 SteamVR 身份;Steam Input 不注册 OpenVR 输入动作,避免影响 SDL 的虚拟手柄枚举。SDL3 不可用时明确报错,可手动选择 Frame 直连。日志位于 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键。APK 必须从管理面板或启动台启动,单次 CLI 不能持有持续桥接。
|
||||
|
||||
## Container lifecycle
|
||||
|
||||
The APK page provides two persistent switches. **Stop container after app exits** defaults off: after a confirmed launch, all target app processes must remain absent for 15 seconds before the container stops. Background apps and services keep it alive. **Stop container when closing its window** defaults on: a confirmed system window-close signal stops the associated container (normally within a few seconds). Switching windows, minimizing, and opening the launcher do not count as closing.
|
||||
@@ -141,7 +155,7 @@ Disabling Steam launch removes only Framely's entry; close a native Steam app fi
|
||||
|
||||
Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的库封面、横幅和背景图,不联网下载图片。启动台原有图标、排序和收藏不变,通过明确的 Steam App ID 绑定过滤 Steam 分类中的重复入口。APK 更新会刷新生成的入口信息。
|
||||
|
||||
原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。
|
||||
原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。平面 APK 启动后会通过 Android 任务接口切换为全屏,避免恢复成桌面内的自由窗口;不会持续锁定用户之后的窗口调整。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。
|
||||
|
||||
切换启动路径之前,需要先关闭已经运行的容器;Framely 不会为了绑定 Steam 重启游戏。旧共享容器仍可识别,但同时只允许一个 Steam 启动占用其数据。跨进程保留的占用锁阻止重复启动及共享数据的并发启动。
|
||||
|
||||
@@ -149,10 +163,20 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
|
||||
|
||||
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
|
||||
|
||||
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
|
||||
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,确认 Android 卸载成功后再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
|
||||
|
||||
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.
|
||||
|
||||
### Uninstalling while keeping the container
|
||||
|
||||
Keeping application data uses Android PackageManager readiness instead of requiring user unlock or external storage mounting. Deleting data still waits for those conditions. Framely boots a stopped container without showing its desktop and repairs only the selected APK registration if needed. It reports startup, PackageManager, unlock, storage and container-exit failures separately. Steam entries are removed after Android confirms successful uninstall; Android success is saved first so a Steam cleanup retry does not uninstall again. Failed startup or uninstall preserves the application record, Steam binding and application data. The error dialog offers Retry and Export logs. A container that cannot boot still requires diagnosis; Framely does not silently delete it or edit Android package databases.
|
||||
|
||||
L2/R2 digital button activation is adjustable in **APK Manager → Settings**, from 1% to 100%, with an 80% default. Both triggers use this threshold with either Steam Input or Frame direct input. Analog trigger travel is unaffected. Saving applies to running bridges immediately; release occurs five percentage points below the selected threshold (at 0% for thresholds below 5%).
|
||||
|
||||
## Per-app graphics compatibility
|
||||
|
||||
Under **Manage app → Advanced settings**, **Framebuffer compatibility fix** is off by default. Enable it only for games that play sound but show a black screen (tested with Rock Kingdom on the current Mesa/Zink driver). Restart the app after changing it. The GLES layer validates framebuffer status after texture attachment, only in the selected application; other apps, system drivers, game files and saved data remain unchanged. Turning it off restores the normal rendering path on the next app launch. It is a targeted workaround, not a general graphics or performance enhancement.
|
||||
@@ -10,6 +10,10 @@ The top switcher contains **Plugin / APK / Terminal / Files / Settings**. Openin
|
||||
|
||||
Plugin contains installed plugins, the plugin library, sources and notification settings, including Framely's notifications. Plugin-specific settings remain in each plugin's management dialog. APK keeps its apps, containers and retained-data cleanup. Settings contains General, Launcher, Updates and About.
|
||||
|
||||
## Interaction feedback
|
||||
|
||||
In **Settings → General → Interaction feedback**, Haptic feedback is enabled by default; Sound feedback is disabled by default. Both preferences are saved across restarts. Haptic feedback controls laser hover and launcher gaze-focus vibration across Framely and plugin pages; it does not change APK game rumble. Sound feedback uses Steam’s installed keyboard sound at reduced volume for focus and clicks. It requires the local Steam sound files and audio runtime; no sound files are bundled or downloaded.
|
||||
|
||||
## Terminal
|
||||
|
||||
Create a terminal to open the configured Steam session user's login shell in their home directory, normally `steamos`. This is a real PTY: Ctrl-C, Tab completion, arrows, Unicode input, window resizing and full-screen programs such as Vim and top work. Use `sudo` inside the terminal when administrator privileges are needed; the panel password is separate from the user's sudo password.
|
||||
@@ -59,3 +63,7 @@ Select an image or video to preview it. PNG, JPEG, GIF, WebP, AVIF, BMP, MP4, We
|
||||
## Access and privacy
|
||||
|
||||
Terminal, file and transfer routes use the existing authenticated manager connection and port. Mutating routes and the terminal WebSocket require an exact matching Origin; plugin sandboxes cannot use them. The service runs as the configured Steam user, independently of the root daemon. Preview/download content is read only after explicit selection. Test media are generated fixtures; Framely does not automatically read or upload Arcturus recordings.
|
||||
|
||||
## Diagnostic export
|
||||
|
||||
**Settings → General → Export logs** and the installer’s **Export logs** use the same collector. The archive includes recent APK operation logs, Lepton’s saved Android logs, and a limited snapshot of running containers: Android boot, user unlock, storage mounting, PackageManager readiness and recent main/system/crash logs. Rootless containers are inspected as the configured Steam session user. Stopped containers are not started. App state is summarized without tokens or icons; APK files, saves, media and raw memory dumps are excluded. File and archive limits retain recent output; missing sources, truncation and command timeouts are listed in `report.json`. Logs can contain application information; review before sharing.
|
||||
@@ -56,9 +56,9 @@ APK 管理用于安装和管理 Steam Frame 上的 Lepton 侧载应用,本地
|
||||
|
||||
## 关闭、卸载与清理
|
||||
|
||||
“关闭应用”只停止目标包;“停止容器”影响其中全部应用。所有可确认数据位置的非 Steam 侧载容器均支持删除,并需确认受影响应用及永久数据删除。标准 Lepton 目录中的容器会整体删除;手动登记的外部位置只删除 baked 目录,保留父目录中的其他文件。卸载应用、包括选择删除应用数据,都不会删除容器。
|
||||
“关闭应用”只停止目标包;“停止容器”影响其中全部应用。所有可确认数据位置的非 Steam 侧载容器均支持删除,并需确认受影响应用及永久数据删除。标准 Lepton 目录中的容器会整体删除;手动登记的外部位置只删除 baked 目录,保留父目录中的其他文件。卸载应用时默认保留容器,开启“删除容器”才同时删除。
|
||||
|
||||
卸载默认保留存档与设置,开启“删除应用数据”才彻底删除。保留数据的应用显示在“已卸载但保留数据”中,可以重新安装或清除保留数据。
|
||||
卸载默认保留存档与设置,开启“删除应用数据”才彻底删除。如果容器内只有该应用,会默认勾选“删除容器”,也可手动关闭以保留容器。共享容器或应用状态未知时无法使用该选项,后端删除前会再次核对应用列表。删除容器会同时清理关联 APK 缓存和 Steam 库条目、图标及封面,历史备份保留。保留数据的应用显示在“已卸载但保留数据”中,可以重新安装或清除保留数据,清除时同样提供删除容器选项。
|
||||
|
||||
Android 清除已卸载包的保留状态时可能需要原签名 APK。Framely 管理卸载时会保存原 APK。如果其他工具已卸载应用且找不到原 APK,需要先提供原 APK 重新安装,再清除保留数据;不会绕过签名或删除任意目录假装成功。
|
||||
|
||||
@@ -137,10 +137,20 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
|
||||
|
||||
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
|
||||
|
||||
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
|
||||
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,确认 Android 卸载成功后再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
|
||||
|
||||
容器列表标题显示已安装应用名称,数据路径用于识别容器。更新 APK 会先停止容器再备份数据。如果停止失败,或原生 Steam 启动进程尚未释放容器,安装确认中提供 **关闭容器** 按钮并要求确认。关闭会中断运行中的应用,保留存档;已检查的 APK 会保留,可关闭后重试安装。
|
||||
|
||||
### Steam 入口保留
|
||||
|
||||
**APK → 设置 → 保留安装的 APK 到 Steam** 默认开启。关闭后清理空闲的 Framely 自有入口,不再自动注册。应用启用 **通过 Steam 启动** 时,会在启动时临时注册入口;原生启动进程和容器退出后删除入口及临时 Devkit 副本。已安装 APK、容器、存档和下载的游戏数据都会保留,运行中的入口不会删除。失败或中断的启动在启动宽限期后清理;Steam 不可用时稍后重试。重新开启保留设置会恢复自动注册。
|
||||
|
||||
### 卸载并保留容器
|
||||
|
||||
保留应用数据的卸载使用 Android 包管理器就绪检查,不再强制等待用户解锁或外部存储挂载。删除数据仍等待这些条件。Framely 在后台启动已停止的容器,不显示容器桌面;需要时只修复目标 APK 的登记。启动、包管理器、解锁、存储和容器退出分别报错。Android 确认卸载成功后才移除 Steam 入口,先保存 Android 卸载结果,重试 Steam 清理不会重复卸载。启动或卸载失败保留应用记录、Steam 绑定和应用数据。错误弹窗提供重试与导出日志。无法启动的容器仍需诊断,不会自动删除容器或直接修改 Android 包数据库。
|
||||
|
||||
在“APK 管理 → 设置”中可调节 L2/R2 按键触发行程,范围 1%~100%,默认 80%。两个扳机共用该设置,Steam Input 和 Frame 直连均适用,不改变模拟轴的完整行程。保存后立即应用到正在运行的转发进程;松开阈值比按下低 5 个百分点,设置低于 5% 时在归零后松开。
|
||||
|
||||
## 单应用图形兼容
|
||||
|
||||
在**管理应用 → 高级设置**中,“帧缓冲兼容修复”默认关闭。仅为有声音但黑屏的游戏开启(已在当前 Mesa/Zink 驱动下验证洛克王国),修改后需重启应用。GLES 修复层只在所选应用中,于绑定纹理后检查帧缓冲状态,不改变其他应用、系统驱动、游戏文件或存档。关闭后,下次启动恢复正常渲染路径。这是针对特定问题的兼容措施,并非通用画质或性能优化。
|
||||
@@ -10,6 +10,10 @@
|
||||
|
||||
插件区域包含已安装插件、插件库、来源和通知设置,通知仍包含 Framely 本体;插件自身设置位于对应插件的管理弹窗。APK 保留应用、容器及数据清理。设置集中放置通用、启动台、更新和关于。
|
||||
|
||||
## 交互反馈
|
||||
|
||||
在 **设置 → 通用 → 交互反馈** 中,振动反馈默认开启,提示音反馈默认关闭,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机键盘音效,以较低音量反馈聚焦和点击,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
|
||||
|
||||
## 终端
|
||||
|
||||
新建终端会以安装时配置的 Steam 会话用户打开登录 Shell,初始目录为该用户主目录,通常是 `steamos`。真实 PTY 支持 Ctrl-C、Tab 补全、方向键、中文、尺寸调整,以及 Vim、top 等全屏程序。需要管理员权限时在终端使用 `sudo`;面板访问密码与 sudo 密码不同。
|
||||
@@ -61,3 +65,7 @@
|
||||
## 访问与隐私
|
||||
|
||||
终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 WebSocket 严格匹配 Origin,插件沙箱不可调用。接口运行于 Steam 用户会话服务,不借用 root 本体执行文件操作。预览与下载仅在用户主动选择后读取内容;测试媒体为生成的样本,不自动读取或上传 Arcturus 录制文件。
|
||||
|
||||
## 导出诊断日志
|
||||
|
||||
**设置 → 通用 → 导出日志** 和安装器的 **导出日志** 使用同一收集流程。日志包包含近期 APK 操作日志、Lepton 已保存的 Android 日志,以及运行中容器的有限快照:Android 启动、用户解锁、存储挂载、包管理器就绪状态和近期 main/system/crash 日志。以配置的 Steam 会话用户检查无 root 容器,不启动已停止的容器。应用状态只导出摘要,排除令牌和图标;不读取 APK、存档、媒体或原始内存转储。单文件及总大小受限,保留近期输出;缺失来源、截断和命令超时记录在 `report.json`。日志可能包含应用信息,分享前请检查。
|
||||
@@ -0,0 +1,22 @@
|
||||
import java.lang.reflect.*;
|
||||
import java.util.List;
|
||||
public final class FramelyWindow {
|
||||
public static void main(String[] args) throws Exception {
|
||||
if(args.length!=1 || !args[0].matches("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z0-9_]+)+")) throw new IllegalArgumentException("Expected package name");
|
||||
Class<?> manager=Class.forName("android.app.ActivityTaskManager");
|
||||
Object service=manager.getDeclaredMethod("getService").invoke(null);
|
||||
Class<?> api=Class.forName("android.app.IActivityTaskManager");
|
||||
Method tasks=api.getMethod("getTasks",int.class);
|
||||
Method mode=api.getMethod("setTaskWindowingMode",int.class,int.class,boolean.class);
|
||||
for(int attempt=0;attempt<20;attempt++) {
|
||||
for(Object task:(List<?>)tasks.invoke(service,32)) {
|
||||
Object top=task.getClass().getField("topActivity").get(task);
|
||||
if(top==null || !args[0].equals(top.getClass().getMethod("getPackageName").invoke(top)))continue;
|
||||
int id=task.getClass().getField("taskId").getInt(task);
|
||||
if(Boolean.TRUE.equals(mode.invoke(service,id,1,true))) {System.out.println("FRAMELY_FULLSCREEN_OK");return;}
|
||||
}
|
||||
Thread.sleep(250);
|
||||
}
|
||||
throw new IllegalStateException("Target task could not enter fullscreen");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Android task window helper
|
||||
|
||||
`FramelyWindow.java` uses the Android 11 task service to put only the requested
|
||||
package's foreground task into fullscreen. A startup windowing-mode option alone
|
||||
can leave a restored Lepton task in freeform mode. This helper changes task mode
|
||||
without stopping the game or changing its installation, data, Android image or
|
||||
host compositor. VR launches do not invoke it. It exits after success or a bounded
|
||||
five-second wait; it does not prevent later manual window adjustments.
|
||||
|
||||
The small checked-in DEX is embedded in Framely. `window.sh` supplies ART's runtime
|
||||
environment for `podman exec`; both files are mounted read-only into the container.
|
||||
No APK installation or network access is required at runtime. An unsupported task
|
||||
API fails explicitly instead of guessing Binder transaction numbers.
|
||||
|
||||
Rebuild with `bash tools/build-android-window.sh` (JDK and curl required). This
|
||||
uses Google's D8 8.3.37, pinned by SHA-256. D8 is a build tool and is not shipped.
|
||||
|
||||
## 中文
|
||||
|
||||
`FramelyWindow.java` 通过 Android 11 的任务接口,只将指定包的前台任务切换为全屏。
|
||||
Lepton 恢复已有任务时,仅指定启动窗口模式仍可能留下自由窗口。此助手不停止游戏,
|
||||
不修改安装、数据、Android 镜像或宿主合成器。VR 启动不调用助手。成功后立即退出,
|
||||
等待最多五秒,不持续限制用户之后手动调整窗口。
|
||||
|
||||
小型 DEX 随 Framely 内置;`window.sh` 为 `podman exec` 补充 ART 运行环境。二者以
|
||||
只读方式挂载,运行时无需安装 APK 或联网。不支持的任务接口会明确失败,不猜测
|
||||
Binder 调用编号。
|
||||
|
||||
执行 `bash tools/build-android-window.sh` 可重新生成,需要 JDK 和 curl。
|
||||
构建使用 Google D8 8.3.37,并校验固定 SHA-256;安装包不包含 D8。
|
||||
@@ -0,0 +1,23 @@
|
||||
# Always mount the shipped layer if available, but activate it only for the
|
||||
# explicitly selected package. No Android/system/Lepton files are replaced.
|
||||
if [[ -n "${FRAMELY_FBO_LIBRARY:-}" ]]; then
|
||||
declare -F setup_podman_mounts >/dev/null || { echo 'Unsupported Lepton graphics hooks' >&2; exit 64; }
|
||||
eval "$(declare -f setup_podman_mounts | sed '1s/setup_podman_mounts/framely_original_fbo_mounts/')"
|
||||
function setup_podman_mounts() {
|
||||
framely_original_fbo_mounts "$@" || return
|
||||
podman_mount_entry "$FRAMELY_FBO_LIBRARY" /data/local/debug/gles/libFramelyFBOCompat.so ro
|
||||
}
|
||||
fi
|
||||
if [[ -n "${FRAMELY_FBO_LIBRARY:-}" ]]; then
|
||||
[[ -z "${FRAMELY_FBO_PACKAGE:-}" || "$FRAMELY_FBO_PACKAGE" =~ ^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$ && -n "${FRAMELY_FBO_LIBRARY:-}" ]] || { echo 'Invalid framebuffer compatibility configuration' >&2; exit 64; }
|
||||
declare -F generate_vulkan_settings_script >/dev/null || { echo 'Unsupported Lepton graphics settings hook' >&2; exit 64; }
|
||||
eval "$(declare -f generate_vulkan_settings_script | sed '1s/generate_vulkan_settings_script/framely_original_fbo_settings/')"
|
||||
function generate_vulkan_settings_script() {
|
||||
# Lepton does not clear stale GLES settings when its GLES list is empty.
|
||||
# Remove our previous selection before its own settings are generated.
|
||||
printf '%s\n' 'if [ "$(settings get global gpu_debug_layers_gles)" = libFramelyFBOCompat.so ]; then settings delete global gpu_debug_layers_gles; settings delete global gpu_debug_layer_app; fi;'
|
||||
framely_original_fbo_settings "$@" || return
|
||||
[[ -n "${FRAMELY_FBO_PACKAGE:-}" ]] || return 0
|
||||
printf '%s\n' 'settings put global enable_gpu_debug_layers 1;' "settings put global gpu_debug_app $FRAMELY_FBO_PACKAGE;" 'settings put global gpu_debug_layers_gles libFramelyFBOCompat.so;' "settings put global gpu_debug_layer_app $FRAMELY_FBO_PACKAGE;"
|
||||
}
|
||||
fi
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
#!/system/bin/sh
|
||||
set -eu
|
||||
# podman exec lacks init's ART environment. Import only the runtime paths.
|
||||
for entry in $(cat /proc/$(pidof system_server)/environ | tr '\0' '\n' | grep -E '^(ANDROID_(ROOT|DATA|ART_ROOT|I18N_ROOT|TZDATA_ROOT)|BOOTCLASSPATH)='); do
|
||||
export "$entry"
|
||||
done
|
||||
export CLASSPATH=/vendor/share/framely-window.dex
|
||||
exec app_process /system/bin FramelyWindow "$@"
|
||||
@@ -0,0 +1,32 @@
|
||||
// Android GLES layer: opt-in workaround for missing framebuffer attachments.
|
||||
// No libc/GL dependency: entry points come from Android's layer loader.
|
||||
typedef void (*Attach)(unsigned, unsigned, unsigned, unsigned, int);
|
||||
typedef unsigned (*Check)(unsigned);
|
||||
static Attach attach;
|
||||
static Check check;
|
||||
static int same(const char *a, const char *b) {
|
||||
while (*a && *a == *b) { ++a; ++b; }
|
||||
return *a == *b;
|
||||
}
|
||||
static void validate_attachment(unsigned target, unsigned attachment,
|
||||
unsigned texture_target, unsigned texture, int level) {
|
||||
Attach next = __atomic_load_n(&attach, __ATOMIC_RELAXED);
|
||||
Check validate = __atomic_load_n(&check, __ATOMIC_RELAXED);
|
||||
next(target, attachment, texture_target, texture, level);
|
||||
// Force driver attachment validation before the next draw. Keep all GL
|
||||
// arguments, shader code and render commands unchanged.
|
||||
if (validate) validate(target);
|
||||
}
|
||||
__attribute__((visibility("default")))
|
||||
void AndroidGLESLayer_Initialize(void *id, void *(*lookup)(void *, const char *)) {
|
||||
__atomic_store_n(&attach, (Attach)lookup(id, "glFramebufferTexture2D"), __ATOMIC_RELAXED);
|
||||
__atomic_store_n(&check, (Check)lookup(id, "glCheckFramebufferStatus"), __ATOMIC_RELAXED);
|
||||
}
|
||||
__attribute__((visibility("default")))
|
||||
void *AndroidGLESLayer_GetProcAddress(const char *name, void *next) {
|
||||
if (next && __atomic_load_n(&check, __ATOMIC_RELAXED) && same(name, "glFramebufferTexture2D")) {
|
||||
__atomic_store_n(&attach, (Attach)next, __ATOMIC_RELAXED);
|
||||
return (void *)validate_attachment;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
+48
-17
@@ -1,4 +1,4 @@
|
||||
// Session-owned OpenVR -> Android gamepad. No CEF dependency or timed test limit.
|
||||
// Session-owned Steam Input/OpenVR -> Android bridge with reverse rumble.
|
||||
#include "openvr.h"
|
||||
#include <linux/uinput.h>
|
||||
#include <fcntl.h>
|
||||
@@ -17,37 +17,51 @@
|
||||
static volatile sig_atomic_t stopped=0;
|
||||
static void stop(int){stopped=1;}
|
||||
#include "gamepad_mapping.h"
|
||||
#include "gamepad_steam.h"
|
||||
#include "gamepad_feedback.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;}
|
||||
static GamepadTriggers triggers;
|
||||
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<2;k++)ok=event(fd,EV_KEY,trigger_keys[k],triggers.update(k,state[14+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;}
|
||||
int main(int argc,char**argv){
|
||||
if((argc!=2&&argc!=4)||geteuid()==0){fprintf(stderr,"Run gamepad bridge as the Steam session user with an action manifest.\n");return 1;}
|
||||
// A session request runs on a short-lived worker thread. PR_SET_PDEATHSIG
|
||||
// follows that thread's lifetime, so it destroys the pad as soon as prepare
|
||||
// returns. The session-owned stdin pipe and parent process own this helper.
|
||||
const auto parent=getppid();
|
||||
const bool steamMode=std::getenv("FRAMELY_GAMEPAD_SOURCE")&&std::string(std::getenv("FRAMELY_GAMEPAD_SOURCE"))=="steam";
|
||||
SteamGamepad steam;
|
||||
if(steamMode&&!steam.start(std::getenv("FRAMELY_STEAM_SDL_LIBRARY"),std::getenv("FRAMELY_STEAM_GAMEPAD_INFO"))){fprintf(stderr,"Steam Input SDL3 runtime unavailable; select Frame direct input in APK settings.\n");return 1;}
|
||||
bool rumbleEnabled=!std::getenv("FRAMELY_GAMEPAD_RUMBLE")||std::string(std::getenv("FRAMELY_GAMEPAD_RUMBLE"))!="0";
|
||||
auto milliseconds=[](){return uint64_t(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());};
|
||||
signal(SIGTERM,stop);signal(SIGINT,stop);signal(SIGPIPE,SIG_IGN);
|
||||
vr::EVRInitError error;vr::VR_Init(&error,vr::VRApplication_Overlay);if(error){fprintf(stderr,"SteamVR gamepad input unavailable: %d\n",error);return 1;}
|
||||
vr::EVRInitError error=vr::VRInitError_None;if(!steamMode)vr::VR_Init(&error,vr::VRApplication_Overlay);if(error){fprintf(stderr,"SteamVR gamepad input unavailable: %d\n",error);return 1;}
|
||||
// Different live containers must not share SteamVR's generated executable
|
||||
// identity. Otherwise a second helper replaces the first app's registered PID.
|
||||
bool registered=false;
|
||||
auto shutdown=[&](){if(registered)vr::VRApplications()->RemoveApplicationManifest(argv[2]);vr::VR_Shutdown();};
|
||||
if(argc==4){
|
||||
// Steam Input already represents Steam's configured output. Registering an
|
||||
// independent OpenVR action app here suppresses SDL's virtual-gamepad
|
||||
// enumeration in this process on Frame.
|
||||
auto shutdown=[&](){if(!steamMode){if(registered)vr::VRApplications()->RemoveApplicationManifest(argv[2]);vr::VR_Shutdown();}};
|
||||
if(!steamMode&&argc==4){
|
||||
if(vr::VRApplications()->AddApplicationManifest(argv[2],true)!=vr::VRApplicationError_None){fprintf(stderr,"Gamepad application registration failed.\n");shutdown();return 1;}
|
||||
registered=true;
|
||||
auto identity=vr::VRApplications()->IdentifyApplication(getpid(),argv[3]);if(identity!=vr::VRApplicationError_None){fprintf(stderr,"Gamepad application identity failed: %d (%s).\n",identity,vr::VRApplications()->GetApplicationsErrorNameFromEnum(identity));shutdown();return 1;}
|
||||
}
|
||||
auto*input=vr::VRInput();vr::VRActiveActionSet_t set{};
|
||||
bool valid=input->SetActionManifestPath(argv[1])==vr::VRInputError_None&&input->GetActionSetHandle("/actions/framely_gamepad",&set.ulActionSet)==vr::VRInputError_None;
|
||||
auto*input=steamMode?nullptr:vr::VRInput();vr::VRActiveActionSet_t set{};
|
||||
bool valid=steamMode||(input->SetActionManifestPath(argv[1])==vr::VRInputError_None&&input->GetActionSetHandle("/actions/framely_gamepad",&set.ulActionSet)==vr::VRInputError_None);
|
||||
const char*names[]={"right_a","right_b","right_x","right_y","left_bumper","right_bumper","left_menu","right_menu","left_stickclick","right_stickclick","left_thumbstick","right_thumbstick","left_trigger","right_trigger","left_dpad_left","left_dpad_right","left_dpad_up","left_dpad_down"};
|
||||
vr::VRActionHandle_t handles[18]{};
|
||||
for(int k=0;k<18;k++)valid= input->GetActionHandle((std::string("/actions/framely_gamepad/in/")+names[k]).c_str(),&handles[k])==vr::VRInputError_None&&valid;
|
||||
for(int k=0;!steamMode&&k<18;k++)valid= input->GetActionHandle((std::string("/actions/framely_gamepad/in/")+names[k]).c_str(),&handles[k])==vr::VRInputError_None&&valid;
|
||||
if(!valid){fprintf(stderr,"Invalid gamepad action bindings.\n");shutdown();return 1;}
|
||||
int fd=open("/dev/uinput",O_WRONLY|O_NONBLOCK|O_CLOEXEC);if(fd<0){perror("Gamepad /dev/uinput");shutdown();return 1;}
|
||||
bool ok=ioctl(fd,UI_SET_EVBIT,EV_KEY)==0&&ioctl(fd,UI_SET_EVBIT,EV_ABS)==0;
|
||||
vr::VRActionHandle_t haptics[2]{};
|
||||
for(int k=0;!steamMode&&k<2;k++)input->GetActionHandle(k?"/actions/framely_gamepad/out/right_haptic":"/actions/framely_gamepad/out/left_haptic",&haptics[k]);
|
||||
int fd=open("/dev/uinput",O_RDWR|O_NONBLOCK|O_CLOEXEC);if(fd<0){perror("Gamepad /dev/uinput");shutdown();return 1;}
|
||||
bool ok=ioctl(fd,UI_SET_EVBIT,EV_KEY)==0&&ioctl(fd,UI_SET_EVBIT,EV_ABS)==0&&ioctl(fd,UI_SET_EVBIT,EV_FF)==0&&ioctl(fd,UI_SET_FFBIT,FF_RUMBLE)==0;
|
||||
for(auto key:keys)ok=ioctl(fd,UI_SET_KEYBIT,key)==0&&ok;
|
||||
for(auto key:trigger_keys)ok=ioctl(fd,UI_SET_KEYBIT,key)==0&&ok;
|
||||
for(int k=0;k<8;k++){ok=ioctl(fd,UI_SET_ABSBIT,axes[k])==0&&ok;uinput_abs_setup a{};a.code=axes[k];a.absinfo.minimum=k<4?-32768:(k<6?0:-1);a.absinfo.maximum=k<4?32767:(k<6?255:1);a.absinfo.flat=k<4?1024:0;ok=ioctl(fd,UI_ABS_SETUP,&a)==0&&ok;}
|
||||
uinput_setup device{};device.id={BUS_USB,0x045e,0x028e,0x0114};strcpy(device.name,"Framely Frame gamepad");
|
||||
uinput_setup device{};device.id={BUS_VIRTUAL,framely_pad_vendor,framely_pad_product,0x0001};device.ff_effects_max=GamepadFeedback::capacity;strcpy(device.name,"Framely Android gamepad");
|
||||
ok=ioctl(fd,UI_DEV_SETUP,&device)==0&&ioctl(fd,UI_DEV_CREATE)==0&&ok;
|
||||
char sysname[128]{};ok=ioctl(fd,UI_GET_SYSNAME(sizeof sysname),sysname)>=0&&ok;
|
||||
std::string node;
|
||||
@@ -57,24 +71,41 @@ int main(int argc,char**argv){
|
||||
if(witness<0){perror("Gamepad event access");ioctl(fd,UI_DEV_DESTROY);close(fd);shutdown();return 1;}
|
||||
printf("%s\n",node.c_str());fflush(stdout);
|
||||
// Parent owns routing. Start neutral; EOF, disconnect or disabled actions release input.
|
||||
bool enabled=false;std::string commands;int previous[18]{};
|
||||
while(!stopped&&getppid()==parent){pollfd p{STDIN_FILENO,POLLIN,0};if(poll(&p,1,0)>0){char buffer[128];auto count=read(0,buffer,sizeof buffer);if(count<=0)break;commands.append(buffer,count);size_t at;while((at=commands.find('\n'))!=std::string::npos){auto command=commands.substr(0,at);commands.erase(0,at+1);enabled=command=="enable";}if(commands.size()>128)break;}
|
||||
int state[18]{};
|
||||
if(const char* threshold=std::getenv("FRAMELY_GAMEPAD_TRIGGER_THRESHOLD"))triggers.configure(std::atoi(threshold));
|
||||
bool triggerChanged=false;bool enabled=false;std::string commands;int previous[18]{};GamepadFeedback feedback;std::array<uint16_t,2> lastRumble{};uint64_t rumbleAt=0;uint32_t source=0;bool frameConnected[2]{};
|
||||
while(!stopped&&getppid()==parent){pollfd p{STDIN_FILENO,POLLIN,0};if(poll(&p,1,0)>0){char buffer[128];auto count=read(0,buffer,sizeof buffer);if(count<=0)break;commands.append(buffer,count);size_t at;while((at=commands.find('\n'))!=std::string::npos){auto command=commands.substr(0,at);commands.erase(0,at+1);if(command=="enable")enabled=true;else if(command=="disable")enabled=false;else if(command=="rumble-on")rumbleEnabled=true;else if(command=="rumble-off")rumbleEnabled=false;else{int percent;char extra;if(std::sscanf(command.c_str(),"trigger-threshold %d %c",&percent,&extra)==1&&triggers.configure(percent))triggerChanged=true;}}if(commands.size()>128)break;}
|
||||
const auto now=milliseconds();
|
||||
// Android may upload effects even while unfocused. Always acknowledge the
|
||||
// kernel requests; only playback is gated by foreground ownership.
|
||||
for(int k=0;k<64;k++){input_event e{};if(read(fd,&e,sizeof e)!=sizeof e)break;
|
||||
if(e.type==EV_UINPUT&&e.code==UI_FF_UPLOAD){uinput_ff_upload q{};q.request_id=e.value;if(ioctl(fd,UI_BEGIN_FF_UPLOAD,&q)==0){q.retval=feedback.upload(q.effect);ioctl(fd,UI_END_FF_UPLOAD,&q);}}
|
||||
else if(e.type==EV_UINPUT&&e.code==UI_FF_ERASE){uinput_ff_erase q{};q.request_id=e.value;if(ioctl(fd,UI_BEGIN_FF_ERASE,&q)==0){q.retval=feedback.erase(q.effect_id);ioctl(fd,UI_END_FF_ERASE,&q);}}
|
||||
else if(e.type==EV_FF)feedback.play(e.code,e.value,now);
|
||||
}
|
||||
int state[18]{};bool inputAvailable=steamMode;
|
||||
// If Android releases its exclusive claim (e.g. InputReader restart),
|
||||
// pause until it claims the node again. Do not feed other host consumers.
|
||||
bool claimed=false;if(enabled){if(ioctl(witness,EVIOCGRAB,1)==0)ioctl(witness,EVIOCGRAB,0);else claimed=errno==EBUSY;}
|
||||
if(enabled&&claimed&&input->UpdateActionState(&set,sizeof set,1)==vr::VRInputError_None){
|
||||
if(steamMode){auto next=steam.read(now,enabled&&claimed);std::copy(next.begin(),next.end(),state);if(source!=steam.source()){feedback.stop();source=steam.source();lastRumble={};}}
|
||||
if(!steamMode&&enabled&&claimed&&input->UpdateActionState(&set,sizeof set,1)==vr::VRInputError_None){
|
||||
const auto leftIndex=vr::VRSystem()->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_LeftHand);
|
||||
const auto rightIndex=vr::VRSystem()->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_RightHand);
|
||||
const bool left=leftIndex!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRSystem()->IsTrackedDeviceConnected(leftIndex);
|
||||
const bool right=rightIndex!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRSystem()->IsTrackedDeviceConnected(rightIndex);
|
||||
if((frameConnected[0]&&!left)||(frameConnected[1]&&!right))feedback.stop();frameConnected[0]=left;frameConnected[1]=right;inputAvailable=left||right;
|
||||
bool buttons[18]{};for(int k=0;k<18;k++){if(k>=10&&k<=13)continue;vr::InputDigitalActionData_t data{};if(input->GetDigitalActionData(handles[k],&data,sizeof data,vr::k_ulInvalidInputValueHandle)==vr::VRInputError_None)buttons[k]=(k==4||k==6||k==8||k>=14?left:right)&&data.bActive&&data.bState;}
|
||||
for(int k=0;k<10;k++)state[k]=buttons[k];
|
||||
for(int k=10;k<=13;k++){if(!(k==10||k==12?left:right))continue;vr::InputAnalogActionData_t data{};if(input->GetAnalogActionData(handles[k],&data,sizeof data,vr::k_ulInvalidInputValueHandle)!=vr::VRInputError_None||!data.bActive||!std::isfinite(data.x)||!std::isfinite(data.y))continue;if(k<=11){state[10+(k-10)*2]=std::lround(std::clamp(data.x,-1.f,1.f)*32767);state[11+(k-10)*2]=std::lround(std::clamp(-data.y,-1.f,1.f)*32767);}else state[14+k-12]=std::lround(std::clamp(data.x,0.f,1.f)*255);}
|
||||
state[16]=int(buttons[15])-int(buttons[14]);state[17]=int(buttons[17])-int(buttons[16]);
|
||||
}
|
||||
if(memcmp(previous,state,sizeof state)){if(!send(fd,state))break;memcpy(previous,state,sizeof state);}
|
||||
const auto vibration=feedback.value(now,enabled&&claimed&&rumbleEnabled&&inputAvailable);
|
||||
if(vibration!=lastRumble||((vibration[0]||vibration[1])&&now-rumbleAt>=100)){
|
||||
if(steamMode)steam.rumble(vibration[0],vibration[1]);
|
||||
lastRumble=vibration;rumbleAt=now;
|
||||
}
|
||||
if(!steamMode&&(vibration[0]||vibration[1]))for(int k=0;k<2;k++)if(haptics[k]!=vr::k_ulInvalidActionHandle&&vibration[k])input->TriggerHapticVibrationAction(haptics[k],0,.012f,100.f,float(vibration[k])/65535.f,vr::k_ulInvalidInputValueHandle);
|
||||
if(triggerChanged||memcmp(previous,state,sizeof state)){triggerChanged=false;if(!send(fd,state))break;memcpy(previous,state,sizeof state);}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(8));
|
||||
}
|
||||
int neutral[18]{};send(fd,neutral);close(witness);ioctl(fd,UI_DEV_DESTROY);close(fd);shutdown();return 0;
|
||||
steam.stop();feedback.stop();int neutral[18]{};send(fd,neutral);close(witness);ioctl(fd,UI_DEV_DESTROY);close(fd);shutdown();return 0;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
#include <linux/input.h>
|
||||
#include <array>
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
// Kernel-owned effect IDs. Keep upload/erase acknowledgements independent of
|
||||
// focus so Android's InputReader cannot block while the game is in background.
|
||||
class GamepadFeedback {
|
||||
struct Slot { ff_effect effect{}; bool uploaded=false,playing=false; uint64_t start=0,end=0,delay=0,period=0; };
|
||||
std::array<Slot,32> slots{};
|
||||
public:
|
||||
static constexpr int capacity=32;
|
||||
int upload(const ff_effect& effect) {
|
||||
if(effect.id<0||effect.id>=capacity||effect.type!=FF_RUMBLE)return -EINVAL;
|
||||
auto& s=slots[effect.id];s.effect=effect;s.uploaded=true;return 0;
|
||||
}
|
||||
int erase(int id){if(id<0||id>=capacity)return -EINVAL;slots[id]={};return 0;}
|
||||
void play(int id,int count,uint64_t now){if(id<0||id>=capacity)return;auto& s=slots[id];s.playing=count>0&&s.uploaded;if(!s.playing)return;s.start=now;s.delay=std::min<unsigned>(s.effect.replay.delay,5000);s.period=s.delay+std::min<unsigned>(s.effect.replay.length,5000);s.end=now+std::min<uint64_t>(10000,s.period*std::min(count,32));}
|
||||
void stop(){for(auto& s:slots)s.playing=false;}
|
||||
std::array<uint16_t,2> value(uint64_t now,bool allowed){
|
||||
std::array<uint16_t,2> v{};if(!allowed){stop();return v;}
|
||||
for(auto& s:slots){if(!s.playing)continue;if(now>=s.end){s.playing=false;continue;}if(now<s.start||!s.period||(now-s.start)%s.period<s.delay)continue;v[0]=std::max<uint16_t>(v[0],std::min<unsigned>(s.effect.u.rumble.strong_magnitude,32768));v[1]=std::max<uint16_t>(v[1],std::min<unsigned>(s.effect.u.rumble.weak_magnitude,32768));}return v;
|
||||
}
|
||||
};
|
||||
@@ -4,3 +4,13 @@
|
||||
// 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};
|
||||
|
||||
inline constexpr int trigger_keys[]={BTN_TL2,BTN_TR2};
|
||||
// Preserve analog travel while exposing Android's digital L2/R2 events.
|
||||
// Default press at 80%; release five percentage points below it to avoid noise.
|
||||
struct GamepadTriggers {
|
||||
bool down[2]{};
|
||||
int press=204,release=191;
|
||||
bool configure(int percent){if(percent<1||percent>100)return false;press=(percent*255+99)/100;release=(percent>5?percent-5:0)*255/100;return true;}
|
||||
bool update(int side,int value){down[side]=down[side]?value>release:value>=press;return down[side];}
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
#include <dlfcn.h>
|
||||
#include <cstdint>
|
||||
#include <linux/input.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
inline constexpr unsigned framely_pad_vendor=0x0001,framely_pad_product=0xf001;
|
||||
struct SteamPadInfo {int slot=-1;unsigned vendor=0,product=0;bool identified=false;};
|
||||
inline std::vector<SteamPadInfo> read_steam_pad_info(std::istream& stream){
|
||||
std::vector<SteamPadInfo> rows;std::string line;size_t bytes=0;
|
||||
while(std::getline(stream,line)){bytes+=line.size()+1;if(bytes>65536)return {};int slot=-1;if(std::sscanf(line.c_str(),"[slot %d]",&slot)==1){if(slot<0||slot>63)return {};rows.push_back({slot});continue;}if(rows.empty())continue;auto& p=rows.back();unsigned value=0;if(std::sscanf(line.c_str(),"VID=%x",&value)==1)p.vendor=value;else if(std::sscanf(line.c_str(),"PID=%x",&value)==1){p.product=value;p.identified=true;}}
|
||||
return rows;
|
||||
}
|
||||
inline bool allowed_steam_slot(const std::vector<SteamPadInfo>& rows,int slot){return std::any_of(rows.begin(),rows.end(),[&](auto p){return p.slot==slot&&p.identified&&p.vendor!=0&&!(p.vendor==framely_pad_vendor&&p.product==framely_pad_product);});}
|
||||
inline std::array<int,18> steam_pad_state(const std::array<bool,15>& buttons,const std::array<int,6>& axes){
|
||||
std::array<int,18> state{};const int indices[]={0,1,2,3,9,10,4,6,7,8};for(int k=0;k<10;k++)state[k]=buttons[indices[k]];
|
||||
for(int k=0;k<4;k++)state[10+k]=std::clamp(axes[k],-32768,32767);
|
||||
for(int k=0;k<2;k++)state[14+k]=std::clamp(axes[4+k],0,32767)*255/32767;
|
||||
state[16]=int(buttons[14])-int(buttons[13]);state[17]=int(buttons[12])-int(buttons[11]);return state;
|
||||
}
|
||||
class SteamGamepad {
|
||||
void* library=nullptr;
|
||||
using Pad=void*;
|
||||
bool (*init)(uint32_t)=nullptr;void (*quit)()=nullptr;void (*update)()=nullptr;void (*pump)()=nullptr;void (*flush)(uint32_t,uint32_t)=nullptr;
|
||||
uint32_t* (*get)(int*)=nullptr;void (*freeMem)(void*)=nullptr;Pad (*openPad)(uint32_t)=nullptr;void (*closePad)(Pad)=nullptr;
|
||||
const char* (*path)(uint32_t)=nullptr;bool (*button)(Pad,int)=nullptr;int16_t (*axis)(Pad,int)=nullptr;
|
||||
bool (*rumblePad)(Pad,uint16_t,uint16_t,uint32_t)=nullptr;
|
||||
struct Entry {uint32_t id;Pad pad;std::array<int,18> previous{};};std::vector<Entry> pads;
|
||||
std::string infoPath;uint32_t selected=0,vibrating=0;uint64_t scanned=0;bool scanning=false,initialized=false;
|
||||
template<class T>bool symbol(T& target,const char* name){target=reinterpret_cast<T>(dlsym(library,name));return target!=nullptr;}
|
||||
bool valid(uint32_t id,const std::vector<SteamPadInfo>& info){
|
||||
const char* p=path(id);if(!p||std::string(p).rfind("/dev/input/event",0)!=0)return false;
|
||||
const std::string suffix=std::string(p).substr(16);if(suffix.empty()||!std::all_of(suffix.begin(),suffix.end(),[](char c){return c>='0'&&c<='9';}))return false;
|
||||
int fd=::open(p,O_RDONLY|O_NONBLOCK|O_CLOEXEC);if(fd<0)return false;input_id device{};char name[128]{};const bool ok=ioctl(fd,EVIOCGID,&device)==0&&device.vendor==0x28de&&device.product==0x11ff&&ioctl(fd,EVIOCGNAME(sizeof name),name)>0;::close(fd);if(!ok)return false;
|
||||
const char* at=std::strstr(name,"pad ");int slot=-1;return at&&std::sscanf(at+4,"%d",&slot)==1&&allowed_steam_slot(info,slot);
|
||||
}
|
||||
void scan(uint64_t now){
|
||||
if(scanning&&now-scanned<500)return;scanning=true;scanned=now;std::ifstream f(infoPath);auto info=read_steam_pad_info(f);
|
||||
int count=0;auto* ids=get(&count);std::vector<uint32_t> validIds;
|
||||
if(ids&&count>=0&&count<=64)for(int k=0;k<count;k++)if(valid(ids[k],info))validIds.push_back(ids[k]);freeMem(ids);
|
||||
for(auto it=pads.begin();it!=pads.end();){if(std::find(validIds.begin(),validIds.end(),it->id)==validIds.end()){if(vibrating==it->id){rumblePad(it->pad,0,0,0);vibrating=0;}if(selected==it->id)selected=0;closePad(it->pad);it=pads.erase(it);}else ++it;}
|
||||
for(auto id:validIds)if(std::none_of(pads.begin(),pads.end(),[&](auto p){return p.id==id;})){auto p=openPad(id);if(p)pads.push_back({id,p});}
|
||||
}
|
||||
public:
|
||||
uint32_t source()const{return selected;}
|
||||
bool start(const char* lib,const char* info){
|
||||
if(!lib||!info)return false;infoPath=info;library=dlopen(lib,RTLD_NOW|RTLD_LOCAL);if(!library)return false;
|
||||
bool ok=symbol(init,"SDL_Init")&&symbol(quit,"SDL_Quit")&&symbol(update,"SDL_UpdateGamepads")&&symbol(pump,"SDL_PumpEvents")&&symbol(flush,"SDL_FlushEvents")&&symbol(get,"SDL_GetGamepads")&&symbol(freeMem,"SDL_free")&&symbol(openPad,"SDL_OpenGamepad")&&symbol(closePad,"SDL_CloseGamepad")&&symbol(path,"SDL_GetGamepadPathForID")&&symbol(button,"SDL_GetGamepadButton")&&symbol(axis,"SDL_GetGamepadAxis")&&symbol(rumblePad,"SDL_RumbleGamepad");if(!ok)return false;
|
||||
setenv("SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD","1",1);setenv("SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS","1",1);setenv("SteamVirtualGamepadInfo",info,1);
|
||||
// Only Steam's post-configuration evdev pads are accepted. HIDAPI must not
|
||||
// additionally open the physical devices or bypass Steam's mappings.
|
||||
setenv("SDL_JOYSTICK_HIDAPI","0",1);setenv("SDL_GAMECONTROLLER_IGNORE_DEVICES","0x0001/0xf001",1);
|
||||
initialized=init(0x2000);return initialized;
|
||||
}
|
||||
std::array<int,18> read(uint64_t now,bool enabled){
|
||||
std::array<int,18> result{};if(!initialized)return result;pump();flush(0,0xffff);update();scan(now);uint32_t next=selected;
|
||||
for(auto& p:pads){std::array<bool,15> buttons{};std::array<int,6> axes{};for(int k=0;k<15;k++)buttons[k]=button(p.pad,k);for(int k=0;k<6;k++)axes[k]=axis(p.pad,k);auto state=steam_pad_state(buttons,axes);
|
||||
bool activity=false;for(int k=0;k<10;k++)activity|=state[k]&&!p.previous[k];for(int k=10;k<16;k++)activity|=std::abs(state[k])>(k<14?8000:40)&&std::abs(state[k]-p.previous[k])>(k<14?1500:15);for(int k=16;k<18;k++)activity|=state[k]!=0&&state[k]!=p.previous[k];p.previous=state;
|
||||
if(enabled&&activity)next=p.id;
|
||||
}
|
||||
if(!next&&!pads.empty())next=pads.front().id;
|
||||
if(next!=selected){stop();selected=next;return result;}
|
||||
if(enabled)for(auto& p:pads)if(p.id==selected)result=p.previous;return result;
|
||||
}
|
||||
bool rumble(uint16_t low,uint16_t high){if(!low&&!high){stop();return true;}for(auto& p:pads)if(p.id==selected){const bool ok=rumblePad(p.pad,low,high,250);if(ok)vibrating=p.id;return ok;}return false;}
|
||||
// Cancel only feedback issued by this bridge, never unrelated Steam rumble.
|
||||
void stop(){for(auto& p:pads)if(p.id==vibrating)rumblePad(p.pad,0,0,0);vibrating=0;}
|
||||
~SteamGamepad(){if(initialized){stop();for(auto& p:pads)closePad(p.pad);quit();}if(library)dlclose(library);}
|
||||
};
|
||||
+15
-9
@@ -30,6 +30,7 @@
|
||||
#include "keyboard_input.h"
|
||||
#include "brand_mask.h"
|
||||
#include "ui_visibility.h"
|
||||
#include "ui_feedback.h"
|
||||
#include "plugin_navigation.h"
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glx.h>
|
||||
@@ -59,6 +60,8 @@ static GLXContext glcontext=nullptr;
|
||||
static GLXPbuffer glsurface=0;
|
||||
static std::atomic<unsigned> notification_badge_count{0};
|
||||
static bool launcher_open=false;
|
||||
static bool ui_haptics=true,ui_sounds=false;
|
||||
static SteamUiSounds ui_audio;
|
||||
static LauncherFade launcher_fade;
|
||||
static std::chrono::steady_clock::time_point launcher_close_at{};
|
||||
static LauncherDismissGuard launcher_dismiss;
|
||||
@@ -120,8 +123,8 @@ template<class T> struct Handler {
|
||||
T* acquire(){api.base.add_ref(&api.base);return &api;}
|
||||
};
|
||||
struct View {
|
||||
std::string key,plugin,title;int width=600,height=840,render_scale=1;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false;
|
||||
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_at{};
|
||||
std::string key,plugin,title;int width=600,height=840;float render_scale=1.f;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false;
|
||||
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_at{},activation_at{};
|
||||
cef_mouse_event_t pointer{};bool pointer_valid=false,stick_scrolling=false;vr::TrackedDeviceIndex_t pointer_device=vr::k_unTrackedDeviceIndexInvalid;std::chrono::steady_clock::time_point pointer_at{};
|
||||
cef_file_dialog_callback_t* file_dialog=nullptr;std::string file_dialog_id;bool file_multiple=false,file_directory=false;
|
||||
bool active_seen=false,notification_content=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
|
||||
@@ -206,9 +209,10 @@ static vr::VROverlayHandle_t active_main_window(){
|
||||
overlays->FindOverlay("valve.steam.gamepadui.main",&selected);
|
||||
return selected;
|
||||
}
|
||||
static void hover_haptic(vr::VROverlayHandle_t overlay,std::chrono::steady_clock::time_point& last){
|
||||
static void hover_haptic(vr::VROverlayHandle_t overlay,std::chrono::steady_clock::time_point& last,bool activation=false){
|
||||
auto now=std::chrono::steady_clock::now();if(now-last<std::chrono::milliseconds(80)||!overlays->IsOverlayVisible(overlay)||!overlays->IsHoverTargetOverlay(overlay))return;
|
||||
auto e=overlays->TriggerLaserMouseHapticVibration(overlay,.012f,120.f,.15f);if(!e)last=now;else std::cerr<<"Hover haptic failed "<<overlay<<" "<<e<<"\n";
|
||||
last=now;if(ui_sounds)ui_audio.play(activation);
|
||||
if(ui_haptics&&!activation){auto e=overlays->TriggerLaserMouseHapticVibration(overlay,.012f,120.f,.15f);if(e)std::cerr<<"Hover haptic failed "<<overlay<<" "<<e<<"\n";}
|
||||
}
|
||||
static void laser_cursor(vr::VROverlayHandle_t overlay,const vr::VREvent_t& event){
|
||||
if(event.eventType==vr::VREvent_MouseMove){vr::HmdVector2_t position{{event.data.mouse.x,event.data.mouse.y}};overlays->SetOverlayCursorPositionOverride(overlay,&position);}
|
||||
@@ -277,9 +281,9 @@ static void anchor_notification(View& v){
|
||||
}
|
||||
static View& create_view(const std::string& key,const std::string& title,const std::string& url,bool dock,int width,int height,const std::string& plugin="",float physical_width=0){
|
||||
if(views.count(key)){auto& v=*views.at(key);if(dock&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="notifications"&&key!="launcher")show(v,true);return v;}
|
||||
// Use 3x for launcher artwork and 2x for scrolling panels. CEF readback,
|
||||
// Use 1.5x for launcher artwork and 2x for scrolling panels. CEF readback,
|
||||
// texture upload and launcher mipmap generation scale with the pixel count.
|
||||
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dock;v->width=width;v->height=height;v->render_scale=key=="launcher"?3:(key=="framely.manager"||key=="menu"||key=="notifications")?2:1;
|
||||
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dock;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications")?2:1;
|
||||
auto error=dock?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
|
||||
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(v->overlay,0);
|
||||
|
||||
@@ -543,7 +547,7 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
if(gaze_allowed&&gaze_active)launcher_outside_at={};
|
||||
if(launcher_open&&launcher_outside_at.time_since_epoch().count()&&now>=launcher_outside_at){launcher_outside_at={};if(!keyboard_open&&!launcher_dock_interaction())launcher_back();}
|
||||
}
|
||||
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){hide_launcher(false);std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");ifLine truncated
|
||||
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){hide_launcher(false);std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely Line truncated
|
||||
}
|
||||
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")close_view(v);break;default:break;}}
|
||||
|
||||
@@ -568,7 +572,7 @@ int main(int argc,char** argv){
|
||||
life_handler.api.on_before_popup=[](cef_life_span_handler_t*,cef_browser_t*,cef_frame_t*,int,const cef_string_t*,const cef_string_t*,cef_window_open_disposition_t,int,const cef_popup_features_t*,cef_window_info_t*,cef_client_t**,cef_browser_settings_t*,cef_dictionary_value_t**,int*){return 1;};
|
||||
life_handler.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t* b){auto* v=find_browser(b);if(v){cancel_file_dialog(*v);v->closed=true;v->host->base.release(&v->host->base);v->host=nullptr;v->browser->base.release(&v->browser->base);v->browser=nullptr;}};
|
||||
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);if(v&&v->key=="launcher"){const auto value=text(message);if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=1")launcher_fade.reduced=true;else if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=0")launcher_fade.reduced=false;else if(value=="FRAMELY_GAZE_FOCUS"&&gaze_active&&launcher_gaze_allowed()){
|
||||
auto now=std::chrono::steady_clock::now();if(now-gaze_feedback_at>=std::chrono::milliseconds(180)){gaze_feedback_at=now;int hand=gaze_feedback_hand;auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);if(device==vr::k_unTrackedDeviceIndexInvalid){hand=1-hand;device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);}if(device!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRInput()->TriggerHapticVibrationAction(gaze_haptic_action,0,.012f,110.f,.16f,hands[hand])!=vr::VRInputError_None)vr_system->TriggerHapticPulse(device,0,1200);}
|
||||
auto now=std::chrono::steady_clock::now();if(now-gaze_feedback_at>=std::chrono::milliseconds(180)){gaze_feedback_at=now;if(ui_sounds)ui_audio.play();int hand=gaze_feedback_hand;auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);if(device==vr::k_unTrackedDeviceIndexInvalid){hand=1-hand;device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);}if(ui_haptics&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRInput()->TriggerHapticVibrationAction(gaze_haptic_action,0,.012f,110.f,.16f,hands[hand])!=vr::VRInputError_None)vr_system->TriggerHapticPulse(device,0,1200);}
|
||||
return 1;}}std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
|
||||
load_handler.api.on_load_end=[](cef_load_handler_t*,cef_browser_t*,cef_frame_t* frame,int){inject_page_scroll(frame);};
|
||||
client.api.get_load_handler=[](cef_client_t*){return load_handler.acquire();};
|
||||
@@ -583,7 +587,8 @@ int main(int argc,char** argv){
|
||||
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);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_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));}});
|
||||
const char* steam_root=std::getenv("FRAMELY_STEAM_ROOT");if(!ui_audio.prepare(steam_root?steam_root:""))std::cerr<<"Steam UI sounds unavailable\n";
|
||||
std::thread poller([port]{int failures=0;json last_feedback;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 feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",false)}});last_feedback=feedback;}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;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);
|
||||
@@ -616,6 +621,7 @@ int main(int argc,char** argv){
|
||||
}
|
||||
auto activate_entry=[&](EntryHold::Action action){
|
||||
if(action==EntryHold::Action::Idle)return;
|
||||
if(ui_sounds)ui_audio.play(true);
|
||||
toggle_entry(action==EntryHold::Action::Launcher);
|
||||
if(menu_open&&views.count("menu"))views.at("menu")->host->set_focus(views.at("menu")->host,1);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Framely's isolated virtual Android gamepad; Xbox-compatible scan codes.
|
||||
key 304 BUTTON_A
|
||||
key 305 BUTTON_B
|
||||
key 307 BUTTON_X
|
||||
key 308 BUTTON_Y
|
||||
key 310 BUTTON_L1
|
||||
key 311 BUTTON_R1
|
||||
key 312 BUTTON_L2
|
||||
key 313 BUTTON_R2
|
||||
key 314 BUTTON_SELECT
|
||||
key 315 BUTTON_START
|
||||
key 317 BUTTON_THUMBL
|
||||
key 318 BUTTON_THUMBR
|
||||
axis 0x00 X flat 1024
|
||||
axis 0x01 Y flat 1024
|
||||
axis 0x03 Z flat 1024
|
||||
axis 0x04 RZ flat 1024
|
||||
axis 0x02 LTRIGGER
|
||||
axis 0x05 RTRIGGER
|
||||
axis 0x10 HAT_X
|
||||
axis 0x11 HAT_Y
|
||||
@@ -77,6 +77,14 @@
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_stickclick",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/out/left_haptic",
|
||||
"type": "vibration"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/out/right_haptic",
|
||||
"type": "vibration"
|
||||
}
|
||||
],
|
||||
"default_bindings": [
|
||||
|
||||
@@ -154,6 +154,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"haptics": [
|
||||
{
|
||||
"path": "/user/hand/left/output/haptic",
|
||||
"output": "/actions/framely_gamepad/out/left_haptic"
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/output/haptic",
|
||||
"output": "/actions/framely_gamepad/out/right_haptic"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
// CEF view/input coordinates stay in DIP; only the paint buffer uses this scale.
|
||||
inline int render_screen_info(cef_screen_info_t* info,int width,int height,int scale){
|
||||
inline int render_screen_info(cef_screen_info_t* info,int width,int height,float scale){
|
||||
info->size=sizeof(*info);info->device_scale_factor=float(scale);info->depth=32;info->depth_per_component=8;info->is_monochrome=0;
|
||||
info->rect={0,0,width,height};info->available_rect=info->rect;return 1;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <dlfcn.h>
|
||||
#include <string>
|
||||
#include <iostream>
|
||||
|
||||
// Load Steam's installed SDL/audio assets; no Steam sounds are redistributed.
|
||||
// Only the native UI thread owns these streams. Playback queues cached PCM and
|
||||
// never spawns a process or reads files on a hover/click.
|
||||
class SteamUiSounds {
|
||||
struct Spec { int format, channels, freq; };
|
||||
struct Clip { void* stream=nullptr; uint8_t* data=nullptr; uint32_t size=0; } clips[2];
|
||||
void* library=nullptr;
|
||||
bool initialized=false;
|
||||
bool (*init)(uint32_t)=nullptr;
|
||||
void (*quit)(uint32_t)=nullptr;
|
||||
bool (*load)(const char*,Spec*,uint8_t**,uint32_t*)=nullptr;
|
||||
void* (*open)(uint32_t,const Spec*,void*,void*)=nullptr;
|
||||
bool (*resume)(void*)=nullptr;
|
||||
bool (*put)(void*,const void*,int)=nullptr;
|
||||
bool (*clear)(void*)=nullptr;
|
||||
bool (*gain)(void*,float)=nullptr;
|
||||
void (*destroy)(void*)=nullptr;
|
||||
void (*release)(void*)=nullptr;
|
||||
template<class T> bool bind(T& fn,const char* name){fn=reinterpret_cast<T>(dlsym(library,name));return fn!=nullptr;}
|
||||
public:
|
||||
SteamUiSounds()=default;
|
||||
SteamUiSounds(const SteamUiSounds&)=delete;
|
||||
~SteamUiSounds(){shutdown();}
|
||||
void shutdown(){
|
||||
for(auto& clip:clips){if(clip.stream&&destroy)destroy(clip.stream);if(clip.data&&release)release(clip.data);clip={};}
|
||||
if(initialized&&quit)quit(0x10);initialized=false;
|
||||
if(library)dlclose(library);library=nullptr;
|
||||
}
|
||||
bool prepare(const std::string& root){
|
||||
if(root.empty())return false;
|
||||
for(const auto* name:{"steamrtarm64/libSDL3.so.0","steamrt64/libSDL3.so.0"}){library=dlopen((root+"/"+name).c_str(),RTLD_NOW|RTLD_LOCAL);if(library)break;}
|
||||
if(!library)return false;
|
||||
if(!(bind(init,"SDL_InitSubSystem")&&bind(quit,"SDL_QuitSubSystem")&&bind(load,"SDL_LoadWAV")&&bind(open,"SDL_OpenAudioDeviceStream")&&bind(resume,"SDL_ResumeAudioStreamDevice")&&bind(put,"SDL_PutAudioStreamData")&&bind(clear,"SDL_ClearAudioStream")&&bind(gain,"SDL_SetAudioStreamGain")&&bind(destroy,"SDL_DestroyAudioStream")&&bind(release,"SDL_free"))){shutdown();return false;}
|
||||
if(!init(0x10)){shutdown();return false;}initialized=true;
|
||||
const char* names[]={"deck_ui_typing.wav","deck_ui_typing.wav"};
|
||||
for(int i=0;i<2;++i){Spec spec{};auto& clip=clips[i];
|
||||
if(!load((root+"/steamui/sounds/"+names[i]).c_str(),&spec,&clip.data,&clip.size)||!clip.size||clip.size>1024*1024){shutdown();return false;}
|
||||
clip.stream=open(0xffffffffu,&spec,nullptr,nullptr);
|
||||
if(!clip.stream||!gain(clip.stream,i==0?.30f:.45f)||!resume(clip.stream)){shutdown();return false;}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
void silence(){if(!initialized)return;for(auto& clip:clips)clear(clip.stream);}
|
||||
void play(bool activation=false){if(!initialized)return;silence();auto& clip=clips[activation?1:0];put(clip.stream,clip.data,int(clip.size));}
|
||||
};
|
||||
@@ -12,6 +12,6 @@ installKeyboard(params=>request('keyboard',params));
|
||||
|
||||
window.addEventListener('pagehide',()=>{for(const p of pending.values()){clearTimeout(p.timer);p.reject(new Error('Plugin page closed'));}pending.clear();});
|
||||
|
||||
installHoverFeedback(()=>{void request('haptic').catch(console.error);});
|
||||
installHoverFeedback(feedback=>{void request('haptic',{feedback}).catch(console.error);});
|
||||
|
||||
installScrollbars();
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
// Shared by the host page and isolated plugin frames.
|
||||
export function installHoverFeedback(pulse:()=>void){
|
||||
export function installHoverFeedback(pulse:(kind:'hover'|'activate')=>void){
|
||||
let hovered:Element|null=null;
|
||||
function interactive(target:EventTarget|null):Element|null{
|
||||
const element=target instanceof Element?target:null;
|
||||
@@ -14,8 +14,9 @@ export function installHoverFeedback(pulse:()=>void){
|
||||
}
|
||||
return pointer;
|
||||
}
|
||||
function enter(e:Event){const next=interactive(e.target);if(next!==hovered){hovered=next;if(next)pulse();}}
|
||||
function enter(e:Event){const next=interactive(e.target);if(next!==hovered){hovered=next;if(next)pulse('hover');}}
|
||||
document.addEventListener('pointerover',enter);
|
||||
document.addEventListener('click',e=>{if(interactive(e.target))pulse('activate');});
|
||||
document.addEventListener('pointerout',e=>{if(interactive(e.relatedTarget)!==hovered)hovered=null;});
|
||||
window.addEventListener('blur',()=>{hovered=null;});
|
||||
}
|
||||
+1034
-88
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,199 @@
|
||||
//! Delete only storage and runtime objects attributed to one APK context.
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn absent(error: &std::io::Error) -> bool {
|
||||
error.kind() == std::io::ErrorKind::NotFound
|
||||
}
|
||||
fn directory(path: &Path) -> Result<()> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(m) => ensure!(
|
||||
m.is_dir() && m.uid() == unsafe { libc::geteuid() },
|
||||
"Invalid cleanup directory"
|
||||
),
|
||||
Err(e) if absent(&e) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn remove_owned(path: &Path) -> Result<()> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(m) => {
|
||||
ensure!(
|
||||
m.uid() == unsafe { libc::geteuid() },
|
||||
"Cleanup file belongs to another user"
|
||||
);
|
||||
remove_tree(path)?;
|
||||
}
|
||||
Err(e) if absent(&e) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn runtime(name: &str, baked: &Path, original: bool) -> Result<Option<String>> {
|
||||
ensure!(safe(name), "Invalid container name");
|
||||
let result = crate::process::command_output_timeout(
|
||||
crate::process::tool("podman").args([
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{.Id}}|{{ index .Config.Labels \"STEAM_COMPAT_DATA_PATH\" }}|{{json .Mounts}}",
|
||||
&format!("lepton-{name}"),
|
||||
]),
|
||||
Duration::from_secs(10),
|
||||
true,
|
||||
)?;
|
||||
if !result.status.success() {
|
||||
let error = String::from_utf8_lossy(&result.stderr).to_lowercase();
|
||||
ensure!(
|
||||
error.contains("no such") || error.contains("does not exist"),
|
||||
"Cannot verify container ownership"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
let text = String::from_utf8(result.stdout)?;
|
||||
let mut fields = text.trim().splitn(3, '|');
|
||||
let id = fields.next().context("Unknown container identity")?;
|
||||
let location = fields
|
||||
.next()
|
||||
.context("Invalid container ownership response")?;
|
||||
let mounts = fields.next().context("Missing container mounts")?;
|
||||
ensure!(!id.is_empty(), "Unknown container identity");
|
||||
if location.is_empty() || location == "<no value>" {
|
||||
// Older direct runtimes have no compatdata label. Verify their actual
|
||||
// Android data mount as well as the unambiguous original name.
|
||||
ensure!(original, "Cannot verify Steam container storage");
|
||||
let mounts: Vec<Value> = serde_json::from_str(mounts)?;
|
||||
ensure!(
|
||||
mounts.iter().any(|m| m["Destination"] == "/data"
|
||||
&& m["Source"]
|
||||
.as_str()
|
||||
.is_some_and(|p| fs::canonicalize(p).is_ok_and(|p| p.starts_with(baked)))),
|
||||
"Container data mount belongs to another location"
|
||||
);
|
||||
} else {
|
||||
ensure!(
|
||||
fs::canonicalize(Path::new(location).join("baked"))? == baked,
|
||||
"Container belongs to another data location"
|
||||
);
|
||||
}
|
||||
Ok(Some(id.to_owned()))
|
||||
}
|
||||
|
||||
pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path) -> Result<()> {
|
||||
let records: Vec<_> = db
|
||||
.records
|
||||
.values()
|
||||
.filter(|r| r.context == c.id)
|
||||
.cloned()
|
||||
.collect();
|
||||
let steam = root(home).join("steam");
|
||||
directory(&steam)?;
|
||||
directory(&steam.join("leases"))?;
|
||||
for rec in &records {
|
||||
let lease = steam.join("leases").join(format!("{}.json", hash(&rec.id)));
|
||||
if let Ok(m) = fs::symlink_metadata(&lease) {
|
||||
if m.is_file() {
|
||||
ensure!(
|
||||
m.len() <= 8192 && m.uid() == unsafe { libc::geteuid() },
|
||||
"Invalid Steam lease file"
|
||||
);
|
||||
let value: Value = serde_json::from_slice(&fs::read(&lease)?)?;
|
||||
ensure!(
|
||||
value["app"] == rec.id,
|
||||
"Steam lease belongs to another application"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let original = if c.id.starts_with("external-") {
|
||||
db.root_contexts
|
||||
.get(&c.baked)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| c.name.clone())
|
||||
} else {
|
||||
c.id.clone()
|
||||
};
|
||||
let standard = home.join(".local/share/lepton/contexts").join(&c.id);
|
||||
let default_location = !c.id.starts_with("external-")
|
||||
&& !fs::symlink_metadata(&standard).is_ok_and(|m| m.file_type().is_symlink())
|
||||
&& fs::canonicalize(&standard).is_ok_and(|p| p == standard && p.join("baked") == c.baked);
|
||||
let target = if default_location {
|
||||
standard
|
||||
} else {
|
||||
c.baked.clone()
|
||||
};
|
||||
let mut names = BTreeSet::from([original.clone(), c.name.clone()]);
|
||||
names.extend(records.iter().filter_map(native::runtime_name));
|
||||
// Resolve every name before stopping anything. A recycled Steam App ID
|
||||
// pointing at different storage must never be removed.
|
||||
let mut runtimes = Vec::new();
|
||||
for name in names {
|
||||
if let Some(id) = runtime(&name, &c.baked, name == original)? {
|
||||
runtimes.push((name, id));
|
||||
}
|
||||
}
|
||||
for (name, _) in &runtimes {
|
||||
let mut owned = c.clone();
|
||||
owned.name = name.clone();
|
||||
stop(&owned, log)?;
|
||||
}
|
||||
fs::create_dir_all(&steam)?;
|
||||
let ownership = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.mode(0o600)
|
||||
.open(steam.join(format!("context-{}.lock", hash(&c.id))))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while unsafe { libc::flock(ownership.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||
ensure!(
|
||||
Instant::now() < deadline,
|
||||
"Steam launch still owns this container; wait for it to close and retry"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
for rec in &records {
|
||||
steam_shortcuts::unregister(home, rec)?;
|
||||
}
|
||||
ensure!(
|
||||
validate_baked(&c.baked)? == c.baked,
|
||||
"Container data location changed"
|
||||
);
|
||||
for (name, identity) in &runtimes {
|
||||
if let Some(current) = runtime(name, &c.baked, *name == original)? {
|
||||
ensure!(
|
||||
current == *identity && !running(name),
|
||||
"Container changed during deletion"
|
||||
);
|
||||
podman(&["rm", identity], Some(log))?;
|
||||
}
|
||||
}
|
||||
let mut keys = BTreeSet::from([hash(&c.id)]);
|
||||
for rec in &records {
|
||||
let key = hash(&rec.id);
|
||||
keys.insert(key.clone());
|
||||
remove_owned(&root(home).join("apks").join(&key))?;
|
||||
remove_owned(&steam.join(format!("native-{key}.sh")))?;
|
||||
let lease = steam.join("leases").join(format!("{key}.json"));
|
||||
remove_owned(&lease)?;
|
||||
}
|
||||
for entry in fs::read_dir(root(home).join("logs"))? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if entry.path() != log
|
||||
&& name.ends_with(".log")
|
||||
&& keys.iter().any(|key| name.starts_with(&format!("{key}-")))
|
||||
{
|
||||
remove_owned(&entry.path())?;
|
||||
}
|
||||
}
|
||||
remove_tree(&target)?;
|
||||
db.roots.retain(|p| p != &c.baked);
|
||||
db.root_contexts.remove(&c.baked);
|
||||
db.records.retain(|_, r| r.context != c.id);
|
||||
db.owned_contexts.retain(|id| id != &c.id);
|
||||
save(home, db)?;
|
||||
// Keep the context lock inode: unlinking it can split mutual exclusion.
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Per-application GLES workaround. The library is inert unless Android's
|
||||
//! debug-layer loader selects this exact package at process startup.
|
||||
use super::*;
|
||||
|
||||
pub(super) const HOOKS: &str = include_str!("../../native/android/fbo-compat.sh");
|
||||
const LAYER: &str = "libFramelyFBOCompat.so";
|
||||
|
||||
pub(super) fn library(required: bool) -> Result<Option<PathBuf>> {
|
||||
let exe = std::env::current_exe()?;
|
||||
let path = exe
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.context("Missing Framely runtime directory")?
|
||||
.join("lib/openvr")
|
||||
.join(LAYER);
|
||||
ensure!(
|
||||
!required || path.is_file(),
|
||||
"Framebuffer compatibility runtime is missing; install a complete Framely build"
|
||||
);
|
||||
Ok(path.is_file().then_some(path))
|
||||
}
|
||||
pub(super) fn configure_command(cmd: &mut Command, enabled: bool, package: &str) -> Result<()> {
|
||||
// Do not inherit a launch configuration from another application/process.
|
||||
cmd.env_remove("FRAMELY_FBO_LIBRARY")
|
||||
.env_remove("FRAMELY_FBO_PACKAGE");
|
||||
if let Some(path) = library(enabled)? {
|
||||
cmd.env("FRAMELY_FBO_LIBRARY", path);
|
||||
}
|
||||
if enabled {
|
||||
ensure!(apk_metadata::valid_package(package), "Invalid APK package");
|
||||
cmd.env("FRAMELY_FBO_PACKAGE", package);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn configure_environment(env: &mut Value, enabled: bool, package: &str) -> Result<()> {
|
||||
// Empty values also prevent inherited Steam environment settings from
|
||||
// enabling a layer for an application whose preference is off.
|
||||
env["FRAMELY_FBO_LIBRARY"] = json!(library(enabled)?);
|
||||
if env["FRAMELY_FBO_LIBRARY"].is_null() {
|
||||
env["FRAMELY_FBO_LIBRARY"] = json!("");
|
||||
}
|
||||
ensure!(
|
||||
!enabled || apk_metadata::valid_package(package),
|
||||
"Invalid APK package"
|
||||
);
|
||||
env["FRAMELY_FBO_PACKAGE"] = json!(if enabled { package } else { "" });
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn mounted(c: &Container, log: &Path) -> Result<bool> {
|
||||
Ok(podman(
|
||||
&[
|
||||
"exec",
|
||||
&format!("lepton-{}", c.name),
|
||||
"sh",
|
||||
"-c",
|
||||
"if [ -r /data/local/debug/gles/libFramelyFBOCompat.so ]; then echo ready; fi",
|
||||
],
|
||||
Some(log),
|
||||
)?
|
||||
.trim()
|
||||
== "ready")
|
||||
}
|
||||
pub(super) fn apply(c: &Container, a: &App, log: &Path) -> Result<()> {
|
||||
ensure!(
|
||||
apk_metadata::valid_package(&a.metadata.package),
|
||||
"Invalid APK package"
|
||||
);
|
||||
// Reusing a container is safe: restart only the selected app if its loader
|
||||
// configuration changed, and clear only our own layer when disabling it.
|
||||
let script = r#"
|
||||
package=$1
|
||||
enabled=$2
|
||||
layer=$(settings get global gpu_debug_layers_gles)
|
||||
target=$(settings get global gpu_debug_app)
|
||||
if [ "$enabled" = true ]; then
|
||||
[ -r /data/local/debug/gles/libFramelyFBOCompat.so ] || exit 64
|
||||
active=$(settings get global enable_gpu_debug_layers)
|
||||
if [ "$layer" != libFramelyFBOCompat.so ] || [ "$target" != "$package" ] || [ "$active" != 1 ]; then
|
||||
am force-stop --user 0 "$package" || exit
|
||||
fi
|
||||
settings put global enable_gpu_debug_layers 1 || exit
|
||||
settings put global gpu_debug_app "$package" || exit
|
||||
settings put global gpu_debug_layers_gles libFramelyFBOCompat.so || exit
|
||||
settings put global gpu_debug_layer_app "$package" || exit
|
||||
elif [ "$layer" = libFramelyFBOCompat.so ]; then
|
||||
if [ "$target" = "$package" ]; then am force-stop --user 0 "$package" || exit; fi
|
||||
settings delete global gpu_debug_layers_gles || exit
|
||||
settings delete global gpu_debug_layer_app || exit
|
||||
fi
|
||||
"#;
|
||||
podman(
|
||||
&[
|
||||
"exec",
|
||||
&format!("lepton-{}", c.name),
|
||||
"sh",
|
||||
"-c",
|
||||
script,
|
||||
"framely-fbo",
|
||||
&a.metadata.package,
|
||||
if a.framebuffer_compatibility {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
},
|
||||
],
|
||||
Some(log),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn compiled_layer_preserves_calls_and_handles_missing_symbols() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let base = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let library = dir.path().join("libFramelyFBOCompat.so");
|
||||
let tester = dir.path().join("test-layer");
|
||||
assert!(Command::new("gcc")
|
||||
.args([
|
||||
"-shared",
|
||||
"-fPIC",
|
||||
"-nostdlib",
|
||||
"-fno-stack-protector",
|
||||
"-fvisibility=hidden",
|
||||
"-Wl,-z,defs",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror"
|
||||
])
|
||||
.arg(base.join("native/fbo_compat.c"))
|
||||
.arg("-o")
|
||||
.arg(&library)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
assert!(Command::new("gcc")
|
||||
.arg(base.join("tests/fbo-layer.c"))
|
||||
.args(["-ldl", "-o"])
|
||||
.arg(&tester)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
assert!(Command::new(tester)
|
||||
.arg(library)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
}
|
||||
#[test]
|
||||
fn boot_hooks_scope_layer_and_clear_stale_configuration() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let script = dir.path().join("test.sh");
|
||||
fs::write(
|
||||
&script,
|
||||
format!(
|
||||
r#"set -euo pipefail
|
||||
function setup_podman_mounts() {{ echo original-mounts; }}
|
||||
function podman_mount_entry() {{ printf '%s|%s|%s\n' "$1" "$2" "$3"; }}
|
||||
function generate_vulkan_settings_script() {{ echo original-vulkan-settings; }}
|
||||
{HOOKS}
|
||||
setup_podman_mounts
|
||||
generate_vulkan_settings_script
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
for enabled in [false, true] {
|
||||
let result = Command::new("bash")
|
||||
.arg(&script)
|
||||
.env("FRAMELY_FBO_LIBRARY", "/runtime/layer.so")
|
||||
.env(
|
||||
"FRAMELY_FBO_PACKAGE",
|
||||
if enabled { "com.example.game" } else { "" },
|
||||
)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
let output = String::from_utf8(result.stdout).unwrap();
|
||||
assert!(output.contains("original-mounts"));
|
||||
assert!(output
|
||||
.contains("/runtime/layer.so|/data/local/debug/gles/libFramelyFBOCompat.so|ro"));
|
||||
assert!(output.contains("settings delete global gpu_debug_layers_gles"));
|
||||
assert!(output.contains("original-vulkan-settings"));
|
||||
assert_eq!(
|
||||
output.contains("settings put global gpu_debug_app com.example.game"),
|
||||
enabled
|
||||
);
|
||||
assert_eq!(
|
||||
output.contains("settings put global gpu_debug_layers_gles libFramelyFBOCompat.so"),
|
||||
enabled
|
||||
);
|
||||
}
|
||||
let result = Command::new("bash")
|
||||
.arg(&script)
|
||||
.env("FRAMELY_FBO_LIBRARY", "/runtime/layer.so")
|
||||
.env("FRAMELY_FBO_PACKAGE", "bad;command")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!result.status.success());
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,29 @@ fn packages(c: &Container) -> Result<BTreeSet<String>> {
|
||||
ensure!(!out.is_empty(), "No known application packages");
|
||||
Ok(out)
|
||||
}
|
||||
pub(super) fn idle_instance(c: &Container) -> Result<String> {
|
||||
let identity = instance(c)?;
|
||||
let known = packages(c)?;
|
||||
let text = short(&[
|
||||
"exec",
|
||||
&format!("lepton-{}", c.name),
|
||||
"sh",
|
||||
"-c",
|
||||
"dumpsys activity processes || exit 1; printf '\\nFRAMELY_IDLE_DONE\\n'",
|
||||
"framely-lifecycle-idle",
|
||||
])?;
|
||||
ensure!(
|
||||
text.starts_with("ACTIVITY MANAGER RUNNING PROCESSES")
|
||||
&& text.ends_with("FRAMELY_IDLE_DONE\n"),
|
||||
"Cannot verify whether applications are running"
|
||||
);
|
||||
ensure!(
|
||||
!known.iter().any(|p| gamepad_present(&text, p)),
|
||||
"An application is still running in this container; close it before Steam launch"
|
||||
);
|
||||
ensure!(instance(c)? == identity, "Container changed during launch");
|
||||
Ok(identity)
|
||||
}
|
||||
pub(super) fn sample(c: &Container) -> Result<Sample> {
|
||||
let identity = instance(c)?;
|
||||
let text = short(&[
|
||||
|
||||
+55
-5
@@ -9,6 +9,17 @@ const NATIVE_HOOKS: &str = r#"
|
||||
function is_app_baked() { return 0; }
|
||||
function get_app_mount_dir() { print "${FRAMELY_NATIVE_MOUNT:?}"; }
|
||||
function get_app_activity() { print "${FRAMELY_NATIVE_ACTIVITY:?}"; }
|
||||
# Android may otherwise reuse its desktop/freeform launch preference. Select
|
||||
# fullscreen before creating the activity, rather than resizing it afterwards.
|
||||
function run_app_commands() {
|
||||
local APP_ID="${1:-$(get_app_id)}"
|
||||
local APP_ACTIVITY="${2:-$(get_app_activity)}"
|
||||
if app_wants_flatscreen; then
|
||||
print "am start -S --windowingMode 1 ${APP_ID}/${APP_ACTIVITY} && /system/bin/sh /vendor/share/framely-window.sh ${APP_ID}"
|
||||
else
|
||||
print "am start -S ${APP_ID}/${APP_ACTIVITY}"
|
||||
fi
|
||||
}
|
||||
function get_app_hash() { extract_app_hash; }
|
||||
function get_app_last_depot_version() { extract_app_last_depot_version; }
|
||||
function clear_baked_app_data() { println 'Framely: keeping installed application data'; }
|
||||
@@ -98,7 +109,6 @@ pub(super) fn prepare(
|
||||
"APK installation is unavailable or an operation is pending"
|
||||
);
|
||||
let component = launch_component(&a)?;
|
||||
ensure!(!running(&c.name),"Close the running container before native Steam launch; it will not be restarted automatically");
|
||||
// Also guard the original name: registration can occur while the old launch
|
||||
// is still running, and two runtime names must never share an overlay.
|
||||
let original = if c.id.starts_with("external-") {
|
||||
@@ -110,9 +120,16 @@ pub(super) fn prepare(
|
||||
c.id.clone()
|
||||
};
|
||||
ensure!(
|
||||
!running(&original),
|
||||
"Close the running container before native Steam launch"
|
||||
c.name == original || !running(&c.name),
|
||||
"This application is already running through Steam"
|
||||
);
|
||||
let mut installer = c.clone();
|
||||
installer.name = original;
|
||||
let idle = if running(&installer.name) {
|
||||
Some(lifecycle::idle_instance(&installer)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
c.name = format!("steamlaunch-{app_id}");
|
||||
c.steam = false;
|
||||
let xml = fs::read_to_string(c.baked.join("data_overlay/system/packages.xml"))?;
|
||||
@@ -143,7 +160,10 @@ pub(super) fn prepare(
|
||||
c.baked.join("external")
|
||||
};
|
||||
let flat = a.show_window.unwrap_or(!a.metadata.vr);
|
||||
let (window_dex, window_script) = window_runtime(home)?;
|
||||
let mut env = json!({
|
||||
"FRAMELY_WINDOW_DEX": window_dex,
|
||||
"FRAMELY_WINDOW_SCRIPT": window_script,
|
||||
"FRAMELY_LEPTON_DIR":runner.parent().context("Missing Lepton directory")?,
|
||||
"STEAM_COMPAT_DATA_PATH":c.baked.parent().context("Missing application data")?,
|
||||
"FRAMELY_NATIVE_MOUNT":mount,
|
||||
@@ -156,6 +176,7 @@ pub(super) fn prepare(
|
||||
"APP_WANTS_FLATSCREEN":if flat {"true"}else{"false"},
|
||||
"LEPTON_NO_CLEANUP":"true", "TERM":"dumb"
|
||||
});
|
||||
graphics::configure_environment(&mut env, a.framebuffer_compatibility, &a.metadata.package)?;
|
||||
let installed_dir = c
|
||||
.baked
|
||||
.join("data_overlay")
|
||||
@@ -171,12 +192,33 @@ pub(super) fn prepare(
|
||||
.baked
|
||||
.join(format!("native-apk-{}", hash(&a.metadata.package))));
|
||||
}
|
||||
// Older releases left the headless installation container alive. Transfer
|
||||
// only an unchanged, idle instance after validating the native launch setup.
|
||||
if let Some(identity) = idle {
|
||||
ensure!(
|
||||
lifecycle::idle_instance(&installer)? == identity,
|
||||
"Container changed during launch"
|
||||
);
|
||||
stop(
|
||||
&installer,
|
||||
&root(home)
|
||||
.join("logs")
|
||||
.join(format!("{}-native-handoff.log", hash(id))),
|
||||
)?;
|
||||
}
|
||||
if db.gamepad_enabled {
|
||||
let m = crate::gamepad::prepare(&root(home), &c.name)?;
|
||||
let m = crate::gamepad::prepare(
|
||||
&root(home),
|
||||
&c.name,
|
||||
&db.gamepad_source,
|
||||
db.gamepad_rumble,
|
||||
db.gamepad_trigger_threshold,
|
||||
)?;
|
||||
crate::gamepad::select_target(&c.name, &a.metadata.package)?;
|
||||
for (k, v) in [
|
||||
("FRAMELY_GAMEPAD_EVENT", json!(m.event)),
|
||||
("FRAMELY_GAMEPAD_GRAB", json!(m.grab)),
|
||||
("FRAMELY_GAMEPAD_LAYOUT", json!(m.layout)),
|
||||
("FRAMELY_GAMEPAD_READY", json!(m.ready)),
|
||||
("FRAMELY_GAMEPAD_TOKEN", json!(m.token)),
|
||||
] {
|
||||
@@ -351,6 +393,7 @@ waitforexitandrun)
|
||||
setup_podman_mounts
|
||||
get_app_mount_dir
|
||||
get_app_activity
|
||||
run_app_commands test.Package test.Selected
|
||||
teardown
|
||||
;;
|
||||
esac
|
||||
@@ -365,7 +408,7 @@ esac
|
||||
let save = data.join("data/test/save");
|
||||
fs::create_dir_all(save.parent().unwrap()).unwrap();
|
||||
fs::write(&save, b"saved game").unwrap();
|
||||
for _ in 0..2 {
|
||||
for flat in [true, false] {
|
||||
let out = Command::new("bash")
|
||||
.arg(&entry)
|
||||
.env("FRAMELY_LEPTON_DIR", lib.parent().unwrap())
|
||||
@@ -375,6 +418,7 @@ esac
|
||||
.env("FRAMELY_NATIVE_LAUNCH", "true")
|
||||
.env("FRAMELY_NATIVE_MOUNT", "/data/app/test")
|
||||
.env("FRAMELY_NATIVE_ACTIVITY", "test.Selected")
|
||||
.env("APP_WANTS_FLATSCREEN", if flat { "true" } else { "false" })
|
||||
.env("TEST_DATA", &data)
|
||||
.env("TEST_PROPS", t.path().join("props"))
|
||||
.output()
|
||||
@@ -385,6 +429,12 @@ esac
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("test.Selected"));
|
||||
let expected = if flat {
|
||||
"am start -S --windowingMode 1 test.Package/test.Selected"
|
||||
} else {
|
||||
"am start -S test.Package/test.Selected"
|
||||
};
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains(expected));
|
||||
assert_eq!(fs::read(&save).unwrap(), b"saved game");
|
||||
assert_eq!(fs::read(&download).unwrap(), b"resume-me");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
async function(p) {
|
||||
const executable = d => d.strShortcutExe?.replace(/^"|"$/g, '').replace('/./', '/');
|
||||
const details = id => new Promise((resolve, reject) => {
|
||||
let sub, settled = false;
|
||||
const timer = setTimeout(() => { sub?.unregister(); reject(Error('Details timed out')); }, 3000);
|
||||
sub = SteamClient.Apps.RegisterForAppDetails(id, d => { settled = true; clearTimeout(timer); sub?.unregister(); resolve(d); });
|
||||
// Steam may invoke a cached callback synchronously.
|
||||
if (settled) sub?.unregister();
|
||||
});
|
||||
let apps, app;
|
||||
for (let n = 0; n < 10; n++) {
|
||||
apps = Array.from(appStore.m_mapApps.values());
|
||||
const matches = apps.filter(a => a.display_name === 'Devkit Game: ' + p.game);
|
||||
app = matches.length === 1 ? matches[0] : apps.find(a => a.appid === p.previous);
|
||||
if (app) break;
|
||||
if (n < 9) await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
if (app) {
|
||||
if (app.appid < 2147483648) throw Error('Missing owned shortcut');
|
||||
const d = await details(app.appid);
|
||||
if (executable(d) !== p.exe) throw Error('Shortcut target does not match owned APK');
|
||||
return { id: app.appid };
|
||||
}
|
||||
// A previous attempt may have renamed the entry before persisting its ID.
|
||||
// Recover by exact executable ownership, never by a user-visible app name.
|
||||
const shortcuts = apps.filter(a => a.appid >= 2147483648);
|
||||
if (shortcuts.length > 256) throw Error('Too many shortcuts to recover owned entry');
|
||||
const rows = await Promise.allSettled(shortcuts.map(async a => ({ id: a.appid, details: await details(a.appid) })));
|
||||
const owned = rows.filter(r => r.status === 'fulfilled' && executable(r.value.details) === p.exe);
|
||||
if (owned.length !== 1) throw Error('Missing or ambiguous owned shortcut');
|
||||
return { id: owned[0].value.id };
|
||||
}
|
||||
@@ -245,6 +245,11 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
|
||||
pub(super) fn unregister(home: &Path, r: &Record) -> Result<()> {
|
||||
if let Some(b) = &r.steam_binding {
|
||||
ensure!(b.game_id == game_id(&r.id), "Invalid Steam APK binding");
|
||||
if b.native {
|
||||
if let Some(app_id) = r.steam_app_id {
|
||||
steam_ui::clear_artwork(app_id, &native_apk(home, &r.id))?;
|
||||
}
|
||||
}
|
||||
rpc(home, "delete-shortcut", Some(&b.game_id))?;
|
||||
// Remove only our fixed configuration files, so the Devkit sync cannot
|
||||
// re-register a disabled entry. Never recursively remove user content.
|
||||
|
||||
+27
-19
@@ -105,24 +105,8 @@ pub(super) fn configure(
|
||||
let expression = format!(
|
||||
r#"(async()=>{{
|
||||
const p={parameters};
|
||||
let app;
|
||||
for(let n=0;n<10;n++){{
|
||||
const apps=Array.from(appStore.m_mapApps.values());
|
||||
const matches=apps.filter(a=>a.display_name==='Devkit Game: '+p.game);
|
||||
app=matches.length===1?matches[0]:apps.find(a=>a.appid===p.previous);
|
||||
if(app) break;
|
||||
await new Promise(r=>setTimeout(r,100));
|
||||
}}
|
||||
if(!app || app.appid<2147483648) throw Error('Missing owned shortcut');
|
||||
const id=app.appid;
|
||||
let sub;
|
||||
const details=await new Promise((resolve,reject)=>{{
|
||||
let timer=setTimeout(()=>{{sub?.unregister();reject(Error('Details timed out'))}},3000);
|
||||
sub=SteamClient.Apps.RegisterForAppDetails(id,d=>{{clearTimeout(timer);sub?.unregister();resolve(d)}});
|
||||
}});
|
||||
sub?.unregister();
|
||||
const exe=details.strShortcutExe?.replace(/^"|"$/g,'').replace('/./','/');
|
||||
if(exe!==p.exe) throw Error('Shortcut target does not match owned APK');
|
||||
const owned=await ({lookup})(p);
|
||||
const id=owned.id;
|
||||
SteamClient.Apps.SetShortcutLaunchOptions(id,p.launch);
|
||||
SteamClient.Apps.SetShortcutName(id,p.name);
|
||||
if(p.icon) SteamClient.Apps.SetShortcutIcon(id,p.icon);
|
||||
@@ -134,7 +118,8 @@ pub(super) fn configure(
|
||||
}}
|
||||
}}
|
||||
return id;
|
||||
}})()"#
|
||||
}})()"#,
|
||||
lookup = include_str!("steam_shortcut_lookup.js")
|
||||
);
|
||||
let result = evaluate(&expression)?;
|
||||
result
|
||||
@@ -170,6 +155,29 @@ fn artwork(icon: Option<&Path>) -> Result<Vec<Value>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn clear_artwork(app_id: u32, executable: &Path) -> Result<()> {
|
||||
ensure!(app_id >= 0x80000000, "Invalid owned Steam shortcut ID");
|
||||
let parameters = json!({"id":app_id,"exe":executable});
|
||||
evaluate(&format!(
|
||||
r#"(async()=>{{
|
||||
const p={parameters};
|
||||
const app=appStore.m_mapApps.get(p.id);
|
||||
if(!app) return;
|
||||
let sub;
|
||||
const details=await new Promise((resolve,reject)=>{{
|
||||
const timer=setTimeout(()=>{{sub?.unregister();reject(Error('Details timed out'))}},3000);
|
||||
sub=SteamClient.Apps.RegisterForAppDetails(p.id,d=>{{clearTimeout(timer);sub?.unregister();resolve(d)}});
|
||||
}});
|
||||
sub?.unregister();
|
||||
const exe=details.strShortcutExe?.replace(/^"|"$/g,'').replace('/./','/');
|
||||
if(exe!==p.exe) throw Error('Shortcut target does not match owned APK');
|
||||
for(const kind of [0,1,3,4]) await SteamClient.Apps.ClearCustomArtworkForApp(p.id,kind);
|
||||
await SteamClient.Apps.SetShortcutIcon(p.id,'');
|
||||
}})()"#
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+54
-2
@@ -16,6 +16,7 @@ pub struct Mount {
|
||||
pub token: String,
|
||||
pub event: PathBuf,
|
||||
pub grab: PathBuf,
|
||||
pub layout: PathBuf,
|
||||
pub ready: PathBuf,
|
||||
}
|
||||
struct Bridge {
|
||||
@@ -85,6 +86,22 @@ pub fn stop() {
|
||||
bridges.active = None;
|
||||
bridges.contexts.clear();
|
||||
}
|
||||
pub fn set_rumble_enabled(enabled: bool) {
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
for bridge in bridges.contexts.values_mut() {
|
||||
let _ = writeln!(
|
||||
bridge.input,
|
||||
"{}",
|
||||
if enabled { "rumble-on" } else { "rumble-off" }
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn set_trigger_threshold(percent: u8) {
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
for bridge in bridges.contexts.values_mut() {
|
||||
let _ = writeln!(bridge.input, "trigger-threshold {percent}");
|
||||
}
|
||||
}
|
||||
pub fn stop_context(context: &str) {
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
if bridges.active.as_deref() == Some(context) {
|
||||
@@ -118,12 +135,26 @@ pub fn current(context: &str) -> Option<Mount> {
|
||||
}
|
||||
bridges.contexts.get(context).map(|b| b.mount.clone())
|
||||
}
|
||||
pub fn prepare(storage: &Path, context: &str) -> Result<Mount> {
|
||||
pub fn prepare(
|
||||
storage: &Path,
|
||||
context: &str,
|
||||
source: &str,
|
||||
rumble: bool,
|
||||
threshold: u8,
|
||||
) -> Result<Mount> {
|
||||
ensure!(
|
||||
matches!(source, "steam" | "frame"),
|
||||
"Invalid gamepad input source"
|
||||
);
|
||||
ensure!(SESSION.load(std::sync::atomic::Ordering::Relaxed),"Gamepad launch requires the UI session; open the APK from the management panel or launcher");
|
||||
ensure!(
|
||||
unsafe { libc::geteuid() } != 0,
|
||||
"Gamepad input must run as the Steam session user"
|
||||
);
|
||||
ensure!(
|
||||
(1..=100).contains(&threshold),
|
||||
"Invalid gamepad trigger threshold"
|
||||
);
|
||||
let current = current(context);
|
||||
{
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
@@ -133,6 +164,7 @@ pub fn prepare(storage: &Path, context: &str) -> Result<Mount> {
|
||||
}
|
||||
if let Some(bridge) = bridges.contexts.get_mut(context) {
|
||||
bridge.package = None;
|
||||
writeln!(bridge.input, "trigger-threshold {threshold}")?;
|
||||
}
|
||||
}
|
||||
if let Some(mount) = current {
|
||||
@@ -146,8 +178,9 @@ pub fn prepare(storage: &Path, context: &str) -> Result<Mount> {
|
||||
let helper = distribution.join("lib/openvr/framely-gamepad");
|
||||
let grab = distribution.join("lib/openvr/libframely-gamepad-grab.so");
|
||||
let manifest = distribution.join("share/input/gamepad/actions.json");
|
||||
let layout = distribution.join("share/input/gamepad/Vendor_0001_Product_f001.kl");
|
||||
ensure!(
|
||||
helper.is_file() && grab.is_file() && manifest.is_file(),
|
||||
helper.is_file() && grab.is_file() && manifest.is_file() && layout.is_file(),
|
||||
"Gamepad input runtime is missing; install a complete Framely build"
|
||||
);
|
||||
let token = hex::encode(rand::random::<[u8; 16]>());
|
||||
@@ -170,7 +203,24 @@ pub fn prepare(storage: &Path, context: &str) -> Result<Mount> {
|
||||
.create(true)
|
||||
.mode(0o600)
|
||||
.open(storage.join("logs/gamepad.log"))?;
|
||||
let home = crate::steam::home()?;
|
||||
let steam = if home.join(".local/share/Steam").exists() {
|
||||
home.join(".local/share/Steam")
|
||||
} else {
|
||||
home.join(".steam/steam")
|
||||
};
|
||||
let mut child = Command::new(helper)
|
||||
.env("FRAMELY_GAMEPAD_SOURCE", source)
|
||||
.env("FRAMELY_GAMEPAD_TRIGGER_THRESHOLD", threshold.to_string())
|
||||
.env("FRAMELY_GAMEPAD_RUMBLE", if rumble { "1" } else { "0" })
|
||||
.env(
|
||||
"FRAMELY_STEAM_SDL_LIBRARY",
|
||||
steam.join("steamrtarm64/libSDL3.so.0"),
|
||||
)
|
||||
.env(
|
||||
"FRAMELY_STEAM_GAMEPAD_INFO",
|
||||
steam.join("config/virtualgamepadinfo.txt"),
|
||||
)
|
||||
.arg(manifest)
|
||||
.arg(&app_manifest)
|
||||
.arg(&app_key)
|
||||
@@ -200,6 +250,7 @@ pub fn prepare(storage: &Path, context: &str) -> Result<Mount> {
|
||||
token,
|
||||
event: PathBuf::new(),
|
||||
grab,
|
||||
layout,
|
||||
ready,
|
||||
},
|
||||
child,
|
||||
@@ -337,6 +388,7 @@ mod tests {
|
||||
token: "same-device".into(),
|
||||
event: PathBuf::from("/dev/input/event999"),
|
||||
grab: PathBuf::new(),
|
||||
layout: PathBuf::new(),
|
||||
ready: directory.path().into(),
|
||||
},
|
||||
child,
|
||||
|
||||
@@ -176,6 +176,23 @@ pub struct LauncherAction {
|
||||
pub label: String,
|
||||
pub target: LauncherActionTarget,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct FeedbackSettings {
|
||||
#[serde(default = "yes")]
|
||||
pub haptics: bool,
|
||||
#[serde(default)]
|
||||
pub sounds: bool,
|
||||
}
|
||||
impl Default for FeedbackSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
haptics: true,
|
||||
sounds: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct LauncherSettings {
|
||||
@@ -832,6 +849,8 @@ pub struct Database {
|
||||
#[serde(default)]
|
||||
pub notification_settings: NotificationSettings,
|
||||
#[serde(default)]
|
||||
pub feedback: FeedbackSettings,
|
||||
#[serde(default)]
|
||||
pub launcher: LauncherSettings,
|
||||
#[serde(default)]
|
||||
pub gaze_calibration: Option<GazeCalibration>,
|
||||
|
||||
@@ -1068,6 +1068,11 @@ impl Service {
|
||||
self.save()?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
"feedback.settings.save" => {
|
||||
self.db.feedback = serde_json::from_value(p)?;
|
||||
self.save()?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
"launcher.settings.save" => {
|
||||
let settings: LauncherSettings = serde_json::from_value(p)?;
|
||||
settings.validate()?;
|
||||
@@ -2631,6 +2636,13 @@ mod notification_tests {
|
||||
#[test]
|
||||
fn framely_notification_preferences_control_update_reminders_and_survive_reload() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
// Model an installed release independently of the build's package version.
|
||||
fs::create_dir(root.path().join("current")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("current/VERSION"),
|
||||
"0.4.2-preview.12-012345abcdef\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
|
||||
plugin(&mut core);
|
||||
send(&mut core, "saved", json!({"inbox":true}));
|
||||
@@ -2818,6 +2830,13 @@ mod notification_tests {
|
||||
#[test]
|
||||
fn automatic_update_notifications_ignore_old_versions_and_deduplicate_after_reload() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
// Model an installed release independently of the build's package version.
|
||||
fs::create_dir(root.path().join("current")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("current/VERSION"),
|
||||
"0.4.2-preview.12-012345abcdef\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
|
||||
let source = UpdateSource {
|
||||
url: "https://example.org/framely-release.json".into(),
|
||||
@@ -2871,6 +2890,13 @@ mod notification_tests {
|
||||
#[test]
|
||||
fn automatic_update_schedule_settings_and_stale_completions() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
// Model an installed release independently of the build's package version.
|
||||
fs::create_dir(root.path().join("current")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("current/VERSION"),
|
||||
"0.4.2-preview.12-012345abcdef\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
|
||||
let source = UpdateSource {
|
||||
url: "https://example.org/framely-release.json".into(),
|
||||
@@ -2952,3 +2978,39 @@ mod notification_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod feedback_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn feedback_defaults_persist_and_reject_invalid_settings() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
|
||||
assert!(core.db.feedback.haptics && !core.db.feedback.sounds);
|
||||
core.handle(
|
||||
"feedback.settings.save",
|
||||
json!({"haptics":false,"sounds":true}),
|
||||
)
|
||||
.unwrap();
|
||||
let mut reloaded = Service::load(root.path(), 1000).unwrap();
|
||||
assert!(!reloaded.db.feedback.haptics && reloaded.db.feedback.sounds);
|
||||
assert!(reloaded
|
||||
.handle("feedback.settings.save", json!({"haptics":"off"}))
|
||||
.is_err());
|
||||
assert!(reloaded
|
||||
.handle("feedback.settings.save", json!({"other":true}))
|
||||
.is_err());
|
||||
assert!(!reloaded.db.feedback.haptics && reloaded.db.feedback.sounds);
|
||||
reloaded
|
||||
.handle(
|
||||
"feedback.settings.save",
|
||||
json!({"haptics":false,"sounds":false}),
|
||||
)
|
||||
.unwrap();
|
||||
let reloaded = Service::load(root.path(), 1000).unwrap();
|
||||
assert!(!reloaded.db.feedback.haptics && !reloaded.db.feedback.sounds);
|
||||
let old: Database =
|
||||
serde_json::from_value(json!({"plugins":{},"sources":[],"safeMode":false})).unwrap();
|
||||
assert!(old.feedback.haptics && !old.feedback.sounds);
|
||||
}
|
||||
}
|
||||
+32
-8
@@ -370,9 +370,14 @@ impl Agent {
|
||||
let badge = self
|
||||
.core("host.notification.badge", json!({}))
|
||||
.unwrap_or(json!(0));
|
||||
let database = self
|
||||
.core("status", json!({}))
|
||||
.ok()
|
||||
.map(|v| v["database"].clone())
|
||||
.unwrap_or(json!({}));
|
||||
return send_json(
|
||||
r,
|
||||
json!({"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":self.core("status",json!({})).ok().map(|v|v["database"]["launcher"].clone()).unwrap_or(json!({}))}),
|
||||
json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"]}),
|
||||
);
|
||||
}
|
||||
if let Some(rel) = path.strip_prefix("/plugin-assets/") {
|
||||
@@ -1245,13 +1250,17 @@ impl Agent {
|
||||
) || view.starts_with("framely.window.")),
|
||||
"Invalid haptic view"
|
||||
);
|
||||
let feedback = p["feedback"].as_str().unwrap_or("hover");
|
||||
ensure!(
|
||||
matches!(feedback, "hover" | "activate"),
|
||||
"Invalid feedback kind"
|
||||
);
|
||||
let mut commands = self.commands.lock().unwrap();
|
||||
if !commands
|
||||
.iter()
|
||||
.any(|c| c["kind"] == "haptic" && c["view"] == view)
|
||||
{
|
||||
if !commands.iter().any(|c| {
|
||||
c["kind"] == "haptic" && c["view"] == view && c["feedback"] == feedback
|
||||
}) {
|
||||
ensure!(commands.len() < 256, "Native command queue full");
|
||||
commands.push_back(json!({"kind":"haptic","view":view}));
|
||||
commands.push_back(json!({"kind":"haptic","view":view,"feedback":feedback}));
|
||||
}
|
||||
Ok(json!(true))
|
||||
}
|
||||
@@ -1286,6 +1295,7 @@ impl Agent {
|
||||
| "plugin.launch.context"
|
||||
| "plugin.launch"
|
||||
| "plugin.launcher.action"
|
||||
| "feedback.settings.save"
|
||||
| "launcher.settings.save"
|
||||
| "launcher.gaze.calibration.get"
|
||||
| "launcher.gaze.calibration.save"
|
||||
@@ -1794,6 +1804,15 @@ pub fn serve(
|
||||
"--no-default-browser-check",
|
||||
])
|
||||
.env("FRAMELY_NATIVE_TOKEN", &native_key)
|
||||
.env("FRAMELY_STEAM_ROOT", {
|
||||
let home = PathBuf::from(std::env::var_os("HOME").unwrap_or_default());
|
||||
let steam = home.join(".local/share/Steam");
|
||||
if steam.is_dir() {
|
||||
steam
|
||||
} else {
|
||||
home.join(".steam/steam")
|
||||
}
|
||||
})
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.status();
|
||||
@@ -2800,8 +2819,8 @@ mod tests {
|
||||
for expected in [
|
||||
"host.ui.visibility",
|
||||
"host.notification.badge",
|
||||
"launcher.gaze.calibration.get",
|
||||
"status",
|
||||
"launcher.gaze.calibration.get",
|
||||
"agreement.status",
|
||||
] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
@@ -2829,10 +2848,15 @@ mod tests {
|
||||
.set("Cookie", &format!("framely={}", agent.web_key))
|
||||
.send_json(json!({"menu":true}))
|
||||
.is_err());
|
||||
ureq::post(&url)
|
||||
let poll: Value = ureq::post(&url)
|
||||
.set("X-Framely-Native", &agent.native_key)
|
||||
.send_json(json!({"menu":true}))
|
||||
.unwrap()
|
||||
.into_json()
|
||||
.unwrap();
|
||||
assert_eq!(poll["feedback"], json!({"haptics":true,"sounds":false}));
|
||||
assert!(poll.get("gazeCalibration").is_some());
|
||||
assert!(poll["launcher"].is_object());
|
||||
assert!(agent
|
||||
.api(json!({"method":"host.ui.visibility","params":{"menu":false}}))
|
||||
.is_err());
|
||||
|
||||
+30
-9
@@ -13,12 +13,18 @@ pub struct App {
|
||||
pub remote: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub remote_client: Option<String>,
|
||||
pub remote_targets: Vec<RemoteTarget>,
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub kind: &'static str,
|
||||
pub icon: Option<String>,
|
||||
pub icon_fit: &'static str,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct RemoteTarget {
|
||||
pub client: String,
|
||||
pub name: String,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
enum Value {
|
||||
Text(String),
|
||||
@@ -213,6 +219,7 @@ pub fn discover(home: &Path) -> Vec<App> {
|
||||
id,
|
||||
remote: false,
|
||||
remote_client: None,
|
||||
remote_targets: Vec::new(),
|
||||
name: name.into(),
|
||||
kind: if text(v, "installdir").is_some_and(|dir| {
|
||||
fs::read_dir(library.join("steamapps/common").join(dir)).is_ok_and(
|
||||
@@ -320,14 +327,7 @@ fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) ->
|
||||
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 existing = apps.entry(a.id).or_insert_with(|| {
|
||||
let icon = cached_artwork(&steam, a.id);
|
||||
App {
|
||||
id: a.id,
|
||||
@@ -336,9 +336,23 @@ fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) ->
|
||||
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),
|
||||
remote_client: None,
|
||||
remote_targets: Vec::new(),
|
||||
}
|
||||
});
|
||||
if existing.remote_targets.iter().any(|t| t.client == a.client) {
|
||||
continue;
|
||||
}
|
||||
if existing.remote_client.is_none() {
|
||||
existing.remote_client = Some(a.client.clone());
|
||||
}
|
||||
existing.remote_targets.push(RemoteTarget {
|
||||
client: a.client,
|
||||
name: a.device_name,
|
||||
});
|
||||
existing
|
||||
.remote_targets
|
||||
.sort_by(|a, b| a.name.cmp(&b.name).then(a.client.cmp(&b.client)));
|
||||
}
|
||||
apps.into_values().collect()
|
||||
}
|
||||
@@ -383,22 +397,26 @@ mod tests {
|
||||
icon_fit: "contain",
|
||||
remote: false,
|
||||
remote_client: None,
|
||||
remote_targets: Vec::new(),
|
||||
}];
|
||||
let rows = vec![
|
||||
remote::RemoteApp {
|
||||
id: 42,
|
||||
name: "Same remote".into(),
|
||||
client: "12".into(),
|
||||
device_name: "Gaming PC".into(),
|
||||
},
|
||||
remote::RemoteApp {
|
||||
id: 43,
|
||||
name: "Remote".into(),
|
||||
client: "12".into(),
|
||||
device_name: "Gaming PC".into(),
|
||||
},
|
||||
remote::RemoteApp {
|
||||
id: 43,
|
||||
name: "Other host".into(),
|
||||
client: "13".into(),
|
||||
device_name: "Laptop".into(),
|
||||
},
|
||||
];
|
||||
let apps = merge_remote(home.path(), local, rows);
|
||||
@@ -406,6 +424,9 @@ mod tests {
|
||||
assert!(!apps[0].remote);
|
||||
assert_eq!(apps[0].name, "Local");
|
||||
assert_eq!(apps[0].remote_client.as_deref(), Some("12"));
|
||||
assert_eq!(apps[0].remote_targets[0].name, "Gaming PC");
|
||||
assert_eq!(apps[1].remote_targets.len(), 2);
|
||||
assert_eq!(apps[1].remote_targets[1].name, "Laptop");
|
||||
assert!(apps[1].remote);
|
||||
assert_eq!(apps[1].remote_client.as_deref(), Some("12"));
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ async function(request) {
|
||||
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 connected=new Map(remote.devices.filter(d=>d.status==='Connected').map(d=>[String(d.clientId),String(d.clientName||'').trim().slice(0,256)]));
|
||||
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});
|
||||
rows.push({id:a.appid,name:String(a.display_name||''),client,deviceName:connected.get(client)});
|
||||
}
|
||||
}
|
||||
if(request.mode==='list')return rows.slice(0,10000);
|
||||
|
||||
@@ -11,6 +11,8 @@ pub(super) struct RemoteApp {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub client: String,
|
||||
#[serde(default, rename = "deviceName")]
|
||||
pub device_name: String,
|
||||
}
|
||||
fn query(request: serde_json::Value) -> Result<serde_json::Value> {
|
||||
crate::apk::steam_ui::evaluate(&format!("({})({request})", include_str!("remote.js")))
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
pub const CHUNK: usize = 4 * 1024 * 1024;
|
||||
pub const CHUNK: usize = 16 * 1024 * 1024;
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Uploads(Arc<Mutex<Option<Upload>>>);
|
||||
struct Upload {
|
||||
|
||||
@@ -5,17 +5,18 @@ const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unk
|
||||
const meta=(pkg:string,name:string,launch=true)=>({package:pkg,name,version:'1.2.3',versionCode:123,minSdk:24,activities:launch?[`${pkg}.Main`]:[],declaredActivities:[`${pkg}.Main`],abis:['arm64-v8a'],vr:false});
|
||||
const apps:any[]=[{id:'framely-wolvic/com.igalia.wolvic',context:'framely-wolvic',metadata:meta('com.igalia.wolvic','Wolvic'),steamLaunch:true,steamRegistered:true,installed:true,running:false,managed:true,size:128*1024*1024},{id:'dev/com.example.service',context:'dev',metadata:meta('com.example.service','Background service',false),installed:true,running:true,managed:false,size:20*1024*1024},{id:'old/com.example.notes',context:'old',metadata:meta('com.example.notes','Notes'),installed:false,running:false,managed:true,size:4*1024*1024}];
|
||||
const containers=[{acceptsAdditionalApps:false,id:'framely-wolvic',name:'framely-wolvic',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/framely-wolvic/baked'},{acceptsAdditionalApps:true,id:'dev',name:'dev',managed:false,running:true,baked:'/home/steamos/.local/share/lepton/contexts/dev/baked'},{id:'old',name:'old',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/old/baked'}];
|
||||
const calls:{method:string;params:any}[]=[];const jobs=new Map<string,any>();let counter=0,fail=false,gamepadEnabled=false,autoStopContainer=false,stopContainerOnClose=true,cleanupItems:any[]=[{id:'data:old/com.example.notes',kind:'data',name:'Notes',bytes:4*1024*1024,sensitive:true,app:'old/com.example.notes'},{id:'backups:backup1',kind:'backups',name:'20261005-backup',bytes:128*1024*1024,sensitive:true,context:'framely-wolvic',restorable:true},{id:'unknown:dev/com.example.orphan',kind:'unknown',name:'com.example.orphan',bytes:20,sensitive:true,unknown:true}];
|
||||
const calls:{method:string;params:any}[]=[];const jobs=new Map<string,any>();let uninstallError:string|null=null;let counter=0,fail=false,gamepadEnabled=false,gamepadTriggerThreshold=80,autoStopContainer=false,stopContainerOnClose=true,cleanupItems:any[]=[{id:'data:old/com.example.notes',kind:'data',name:'Notes',bytes:4*1024*1024,sensitive:true,app:'old/com.example.notes'},{id:'backups:backup1',kind:'backups',name:'20261005-backup',bytes:128*1024*1024,sensitive:true,context:'framely-wolvic',restorable:true},{id:'unknown:dev/com.example.orphan',kind:'unknown',name:'com.example.orphan',bytes:20,sensitive:true,unknown:true}];
|
||||
window.fetch=async(url,init)=>{if(String(url).startsWith('/host/files/')){const p=JSON.parse(String(init?.body));calls.push({method:String(url),params:p});let result:any=true;if(String(url).endsWith('/list'))result={path:'/home/steamos/Downloads',parent:'/home/steamos',roots:[],entries:[{name:'device.apk',path:'/home/steamos/Downloads/device.apk',directory:false,size:1024}],total:1,offset:0,skipped:0};else if(String(url).endsWith('/apk-inspect')){const id='job-'+counter++;jobs.set(id,{id,kind:'apk',phase:'done',result:{ticket:'local-review-ticket',metadata:meta('com.example.local','Local APK'),bytes:1024},received:0});result={job:id};}return new Response(JSON.stringify({result}));}if(String(url).startsWith('/api/upload/')){const offset=+String(url).split('/').at(-1)!;return new Response(JSON.stringify({result:{received:offset+(init?.body as Blob).size}}));}const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
|
||||
if(method==='apk.upload.start')result={upload:'fixture-upload',chunkSize:512*1024};
|
||||
else if(method==='apk.cleanup.list')result=cleanupItems;
|
||||
else if(method==='diagnostics.export')result={name:'logs.zip',path:'/home/steamos/Downloads/logs.zip'};
|
||||
else if(method==='apk.logs')result={name:'apk.log',text:'Installation complete\nAndroid package manager: Success'};
|
||||
else if(method==='apk.list.start'||method==='apk.inspect.start'||method==='apk.operation.start'){
|
||||
const id='job-'+counter++;let value:any=true;
|
||||
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true,gamepadEnabled,autoStopContainer,stopContainerOnClose};
|
||||
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true,gamepadEnabled,gamepadTriggerThreshold,autoStopContainer,stopContainerOnClose};
|
||||
else if(method==='apk.inspect.start')value={ticket:'review-ticket',metadata:meta('com.example.new','New APK'),bytes:4};
|
||||
else{if(params.operation==='lifecycle.settings'){autoStopContainer=params.autoStopContainer;stopContainerOnClose=params.stopContainerOnClose;}if(params.operation==='gamepad.settings')gamepadEnabled=params.enabled;const app=apps.find(a=>a.id===params.app);if(params.operation==='steam.settings')app.steamLaunch=params.enabled;if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall')app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
jobs.set(id,{id,kind:'apk',phase:fail&&method==='apk.operation.start'?'failed':'done',result:value,error:'Fixture signature mismatch',received:0});result={job:id};
|
||||
else{if(params.operation==='lifecycle.settings'){autoStopContainer=params.autoStopContainer;stopContainerOnClose=params.stopContainerOnClose;}if(params.operation==='gamepad.settings'){gamepadEnabled=params.enabled;if(params.triggerThreshold!==undefined)gamepadTriggerThreshold=params.triggerThreshold;}const app=apps.find(a=>a.id===params.app);if(params.operation==='steam.settings')app.steamLaunch=params.enabled;if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall'&&!uninstallError)app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
jobs.set(id,{id,kind:'apk',phase:method==='apk.operation.start'&&(fail||(params.operation==='uninstall'&&uninstallError))?'failed':'done',result:value,error:uninstallError??'Fixture signature mismatch',received:0});result={job:id};
|
||||
}else if(method==='job.status')result=jobs.get(params.job);
|
||||
return new Response(JSON.stringify({result}));};
|
||||
const root=createRoot(document.getElementById('root')!);function render(){root.render(<div className="app manager"><header className="shell-header"><Brand/><span>Framely</span></header><div className="workspace"><nav>{[['installed','已安装'],['download','插件库'],['download','APK 管理'],['settings','设置']].map(([icon,key])=><button className={key==='APK 管理'?'active':''} key={key}><Icon name={icon as any}/>{t(key)}</button>)}</nav><main className="content"><ApkManager/><FrameFilePicker/></main></div></div>);}
|
||||
@@ -23,27 +24,44 @@ const button=(text:string,scope:ParentNode=document)=>Array.from(scope.querySele
|
||||
async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIEW_INSTALL_'+name);await wait(180);}
|
||||
(async()=>{
|
||||
configureLanguage('en-US',[]);render();await until(()=>document.querySelectorAll('.apk-row').length===2);const content=document.querySelector('.content')!.getBoundingClientRect(),manager=document.querySelector('.apk-manager')!.getBoundingClientRect();const padding=getComputedStyle(document.querySelector('.content')!);if(Math.abs(manager.width-(document.querySelector('.content')!.clientWidth-parseFloat(padding.paddingLeft)-parseFloat(padding.paddingRight)))>2)throw Error(`Manager width does not fill content: ${manager.width}/${content.width}, ${padding.paddingLeft}/${padding.paddingRight}`);const search=document.querySelector<HTMLInputElement>('.apk-filters input')!.getBoundingClientRect(),filter=document.querySelector<HTMLButtonElement>('.apk-filters .framely-select>button')!.getBoundingClientRect();if(window.innerWidth>700?(Math.abs(search.y-filter.y)>1||Math.abs(search.height-filter.height)>1||search.right>=filter.left):(filter.top<search.bottom||Math.abs(filter.width-search.width)>1))throw Error('APK search/filter controls are not aligned');await snapshot('APK_EN');
|
||||
if(document.querySelector('[role=switch]'))throw Error('Global settings leaked onto app list');button('Settings',document.querySelector('.apk-tabs')!).click();await until(()=>!!document.querySelector('[role=switch]'));
|
||||
if(document.querySelector('[role=switch]'))throw Error('Global settings leaked onto app list');button('Settings',document.querySelector('.apk-tabs')!).click();await until(()=>!!document.querySelector('[role=switch][aria-label="Stop container after app exits"]'));
|
||||
const automatic=()=>document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Stop container after app exits"]')!;
|
||||
const closePolicy=()=>document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Stop container when closing its window"]')!;
|
||||
if(automatic().getAttribute('aria-checked')!=='false'||closePolicy().getAttribute('aria-checked')!=='true')throw Error('Incorrect container lifecycle defaults');
|
||||
automatic().click();await until(()=>automatic().getAttribute('aria-checked')==='true'&&!automatic().disabled);closePolicy().click();await until(()=>closePolicy().getAttribute('aria-checked')==='false'&&!closePolicy().disabled);
|
||||
automatic().click();await until(()=>automatic().getAttribute('aria-checked')==='false'&&!automatic().disabled);closePolicy().click();await until(()=>closePolicy().getAttribute('aria-checked')==='true'&&!closePolicy().disabled);
|
||||
const gamepad=()=>document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Frame gamepad input"]')!;
|
||||
const gamepad=()=>document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Gamepad input"]')!;
|
||||
if(gamepad().getAttribute('aria-checked')!=='false')throw Error('Gamepad input does not default off');
|
||||
gamepad().click();await until(()=>gamepad().getAttribute('aria-checked')==='true'&&!gamepad().disabled);if(!gamepadEnabled)throw Error('Gamepad switch was not persisted');
|
||||
gamepad().click();await until(()=>gamepad().getAttribute('aria-checked')==='false'&&!gamepad().disabled);if(gamepadEnabled)throw Error('Gamepad input did not turn off');
|
||||
const trigger=()=>document.querySelector<HTMLInputElement>('input[type=range][aria-valuetext]')!;
|
||||
if(trigger().value!=='80')throw Error('Trigger threshold default is not 80%');
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(trigger(),'65');trigger().dispatchEvent(new Event('input',{bubbles:true}));await until(()=>trigger().getAttribute('aria-valuetext')==='65%');
|
||||
if(gamepadTriggerThreshold!==80)throw Error('Unsaved trigger threshold applied');
|
||||
button('Save',document.querySelector('.apk-trigger-setting')!).click();await until(()=>gamepadTriggerThreshold===65&&!trigger().disabled&&button('Save',document.querySelector('.apk-trigger-setting')!).disabled);
|
||||
if(trigger().value!=='65'||gamepadEnabled||!calls.some(c=>c.params.operation==='gamepad.settings'&&c.params.triggerThreshold===65))throw Error('Trigger setting was not persisted without enabling input');
|
||||
// Container startup and shutdown are not data deletion operations.
|
||||
button('Containers').click();await until(()=>document.querySelectorAll('.apk-container').length===3);
|
||||
for(const title of ['Start container','Stop container']){button(title).click();await until(()=>!!document.querySelector('.apk-dialog'));if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Container operation displayed data deletion warning');if(!button('Confirm',document.querySelector('.apk-dialog')!).classList.contains('primary'))throw Error('Non-destructive container confirmation uses destructive styling');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));}
|
||||
const external=document.querySelectorAll('.apk-container')[1];button('Delete container',external).click();await until(()=>document.querySelector('.apk-dialog'));if(!document.querySelector('.apk-dialog')!.textContent!.includes(t('容器内全部数据将永久删除。')))throw Error('External deletion lacks data warning');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));if(calls.some(c=>c.params.operation==='container.delete'))throw Error('Cancelled deletion modified external container');
|
||||
const external=document.querySelectorAll('.apk-container')[1];button('Delete container',external).click();await until(()=>document.querySelector('.apk-dialog'));if(!document.querySelector('.apk-dialog')!.textContent!.includes(t('容器数据、关联 APK 缓存和 Steam 库条目将永久删除,历史备份保留。')))throw Error('External deletion lacks data warning');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));if(calls.some(c=>c.params.operation==='container.delete'))throw Error('Cancelled deletion modified external container');
|
||||
button('Apps').click();await until(()=>document.querySelectorAll('.apk-row').length===2);
|
||||
button('Manage',document.querySelector('.apk-row')!).click();await until(()=>document.querySelector('.apk-dialog'));const advanced=document.querySelector<HTMLDetailsElement>('.apk-advanced')!;advanced.open=true;document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Handle window display automatically"]')!.click();await until(()=>calls.some(c=>c.method==='apk.operation.start'&&c.params.operation==='settings'));await until(()=>!document.querySelector('progress'));await until(()=>document.querySelector('.apk-dialog')&&!button('Uninstall app',document.querySelector('.apk-dialog')!).disabled);
|
||||
button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>document.querySelector('[role=switch][aria-label="Delete app data"]'));if(document.querySelector('[aria-label="Delete app data"]')!.getAttribute('aria-checked')!=='false')throw Error('Uninstall defaults to data deletion');if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Data-preserving uninstall warns of permanent data deletion');document.querySelector<HTMLButtonElement>('[aria-label="Delete app data"]')!.click();await until(()=>document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')));document.querySelector<HTMLButtonElement>('[aria-label="Delete app data"]')!.click();await until(()=>!document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')));button('Confirm',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='uninstall'));await until(()=>!document.querySelector('progress'));if(calls.find(c=>c.params.operation==='uninstall')!.params.purge)throw Error('Uninstall purged by default');await until(()=>Array.from(document.querySelectorAll('.apk-dialog button')).some(b=>b.textContent==='Done'));button('Done',document.querySelector('.apk-dialog')!).click();
|
||||
button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>document.querySelector('[role=switch][aria-label="Delete app data"]'));
|
||||
const purgeSwitch=()=>document.querySelector<HTMLButtonElement>('[aria-label="Delete app data"]')!;
|
||||
const deleteSwitch=()=>document.querySelector<HTMLButtonElement>('[aria-label="Delete container"]')!;
|
||||
if(purgeSwitch().getAttribute('aria-checked')!=='false'||deleteSwitch().getAttribute('aria-checked')!=='false'||!deleteSwitch().disabled)throw Error('Uninstall defaults to data deletion');
|
||||
if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Data-preserving uninstall warns of permanent data deletion');
|
||||
await snapshot('APK_UNINSTALL_KEEP');purgeSwitch().click();await until(()=>deleteSwitch().getAttribute('aria-checked')==='true');
|
||||
if(deleteSwitch().disabled)throw Error('Exclusive container deletion is disabled');await snapshot('APK_UNINSTALL_DELETE');
|
||||
deleteSwitch().click();await until(()=>deleteSwitch().getAttribute('aria-checked')==='false');if(purgeSwitch().getAttribute('aria-checked')!=='true')throw Error('Container opt-out cleared purge');
|
||||
purgeSwitch().click();await until(()=>!document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')));
|
||||
button('Uninstall',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='uninstall'));await until(()=>!document.querySelector('progress'));
|
||||
const uninstall=calls.find(c=>c.params.operation==='uninstall')!.params;if(uninstall.purge||uninstall.deleteContainer)throw Error('Uninstall purged by default');await until(()=>Array.from(document.querySelectorAll('.apk-dialog button')).some(b=>b.textContent==='Done'));button('Done',document.querySelector('.apk-dialog')!).click();
|
||||
|
||||
button('Data cleanup').click();await until(()=>document.querySelectorAll('.apk-cleanup-row').length===3);if(document.querySelector('[role=switch][aria-checked=true]'))throw Error('Sensitive data selected by default');if(!document.querySelector<HTMLButtonElement>('[role=switch][aria-label*="com.example.orphan"]')!.disabled)throw Error('Unknown data can be deleted');await snapshot('APK_CLEANUP_EN');
|
||||
document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Select Notes"]')!.click();await until(()=>!button('Clean selected').disabled);button('Clean selected').click();await until(()=>document.querySelector('.apk-dialog'));button('Cancel',document.querySelector('.apk-dialog')!).click();if(calls.some(c=>c.params.operation==='cleanup'))throw Error('Cancel performed cleanup');
|
||||
apps[0].installed=true;await wait();configureLanguage('zh-CN',[]);console.log('APK_LOCALE '+t('APK 管理'));root.render(<div/>);await wait();configureLanguage('zh-CN',[]);render();await until(()=>document.querySelector('.apk-manager h1')?.textContent==='APK 管理');await until(()=>!document.querySelector('.empty'));await snapshot('APK_ZH');button('数据清理').click();await until(()=>document.querySelectorAll('.apk-cleanup-row').length===3);await snapshot('APK_CLEANUP_ZH');button('应用').click();
|
||||
const file=new File(['test'],'sample.apk',{type:'application/vnd.android.package-archive'}),transfer=new DataTransfer();transfer.items.add(file);const input=document.querySelector<HTMLInputElement>('input[type=file]')!;input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));await until(()=>document.querySelector('[aria-label="安装 APK"] .apk-meta'));if(calls.find(c=>c.method==='apk.upload.start')?.params.size!==4)throw Error('APK upload transport mismatch');await snapshot('APK_REVIEW_ZH');document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="显示方式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));button('VR 应用',document.querySelector('[role=listbox]')!).click();await until(()=>document.querySelector('.apk-dialog button[aria-label="显示方式"]')!.textContent?.includes('VR 应用'));if(!document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="目标容器"]')!.disabled)throw Error('New APK can select a shared container');button('取消',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.method==='apk.review.drop'));if(calls.some(c=>c.params.operation==='install'))throw Error('Cancelled review installed APK');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));await until(()=>document.querySelector('[aria-label="安装 APK"] .apk-meta'));document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="显示方式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));button('VR 应用',document.querySelector('[role=listbox]')!).click();await until(()=>document.querySelector('.apk-dialog button[aria-label="显示方式"]')!.textContent?.includes('VR 应用'));button('确认安装',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='install'));if(calls.find(c=>c.params.operation==='install')!.params.showWindow!==false)throw Error('VR display mode was not submitted');await until(()=>!document.querySelector('.apk-dialog'));
|
||||
const file=new File(['test'],'sample.apk',{type:'application/vnd.android.package-archive'}),transfer=new DataTransfer();transfer.items.add(file);const input=document.querySelector<HTMLInputElement>('input[type=file]')!;input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));await until(()=>document.querySelector('[aria-label="安装 APK"] .apk-meta'));if(calls.find(c=>c.method==='apk.upload.start')?.params.size!==4)throw Error('APK upload transport mismatch');await snapshot('APK_REVIEW_ZH');document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="显示方式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));button('VR 应用',document.querySelector('[role=listbox]')!).click();await until(()=>document.querySelector('.apk-dialog button[aria-label="显示方式"]')!.textContent?.includes('VR 应用'));if(document.querySelector('.apk-dialog button[aria-label="目标容器"]')||!document.querySelector('.apk-install-location')!.textContent!.includes(t('创建独立容器')))throw Error('New APK can select a shared container');button('取消',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.method==='apk.review.drop'));if(calls.some(c=>c.params.operation==='install'))throw Error('Cancelled review installed APK');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));await until(()=>document.querySelector('[aria-label="安装 APK"] .apk-meta'));document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="显示方式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));button('VR 应用',document.querySelector('[role=listbox]')!).click();await until(()=>document.querySelector('.apk-dialog button[aria-label="显示方式"]')!.textContent?.includes('VR 应用'));button('确认安装',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='install'));if(calls.find(c=>c.params.operation==='install')!.params.showWindow!==false)throw Error('VR display mode was not submitted');await until(()=>!document.querySelector('.apk-dialog'));
|
||||
configureLanguage('en-US',[]);apps.push({id:'unknown/com.example.unknown',context:'unknown',metadata:meta('com.example.unknown','Unknown app'),installed:false,stateKnown:false,managed:false,size:10,pending:'Container unavailable; data has not been deleted'},{id:'steam/com.example.steam',context:'steam',metadata:meta('com.example.steam','Steam app'),installed:true,steam:true,size:10});root.render(<div/>);await wait();render();await until(()=>[...document.querySelectorAll('.apk-row')].some(r=>r.textContent?.includes('Unknown app')));if([...document.querySelectorAll('.apk-row')].some(r=>r.textContent?.includes('Steam app')))throw Error('Steam app exposed in side-loading app list');const unknown=[...document.querySelectorAll('.apk-row')].find(r=>r.textContent?.includes('Unknown app'))!;if(!unknown.textContent?.includes(t('安装状态未知'))||[...unknown.querySelectorAll('button')].some(b=>b.textContent==='Open'))throw Error('Unknown state presented as launchable or uninstalled');button('Manage',unknown).click();await until(()=>document.querySelector('.apk-dialog'));if([...document.querySelectorAll('.apk-dialog button')].some(b=>b.textContent==='Reinstall APK'))throw Error('Unknown app offers reinstallation');if(!button(t('清除保留数据'),document.querySelector('.apk-dialog')!).disabled)throw Error('Unknown app can delete retained data');button('Done',document.querySelector('.apk-dialog')!).click();
|
||||
// Frame-local selection cancels CEF's File result and starts inspection by path.
|
||||
button('Install APK').click();window.dispatchEvent(new CustomEvent('framely.fileDialog',{detail:{id:'native-cancel',accept:['.apk'],multiple:false,directory:false}}));await until(()=>!!document.querySelector('.frame-files'));button('Cancel',document.querySelector('.frame-files')!).click();await until(()=>!document.querySelector('.frame-files'));if(calls.some(c=>c.method==='/host/files/apk-inspect'))throw Error('Cancelling local picker inspected APK');
|
||||
@@ -63,5 +81,22 @@ async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIE
|
||||
steamSwitch().click();await until(()=>steamSwitch().getAttribute('aria-checked')==='true'&&!steamSwitch().disabled);
|
||||
if(calls.filter(c=>c.method==='apk.operation.start'&&c.params.operation==='steam.settings').length!==2)throw Error('Steam launch switch did not use the APK operation API');
|
||||
button('Done',document.querySelector('.apk-dialog')!).click();
|
||||
// Shared containers include retained apps, so purge must never select container deletion.
|
||||
apps.push({...apps[0],id:'framely-wolvic/com.example.other',metadata:meta('com.example.other','Other retained app'),installed:false});
|
||||
root.render(<div/>);await wait();render();await until(()=>document.querySelectorAll('.apk-row').length>=3);
|
||||
button('Manage',[...document.querySelectorAll('.apk-row')].find(r=>r.textContent?.includes('Wolvic'))!).click();await until(()=>document.querySelector('.apk-dialog'));button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>!!document.querySelector('.apk-uninstall'));
|
||||
purgeSwitch().click();await until(()=>purgeSwitch().getAttribute('aria-checked')==='true');if(!deleteSwitch().disabled||deleteSwitch().getAttribute('aria-checked')!=='false')throw Error('Shared container selected for deletion');button('Cancel',document.querySelector('.apk-dialog')!).click();await wait();button('Done',document.querySelector('.apk-dialog')!).click();apps.pop();
|
||||
root.render(<div/>);await wait();render();await until(()=>document.querySelectorAll('.apk-row').length>=3);
|
||||
button('Manage',[...document.querySelectorAll('.apk-row')].find(r=>r.textContent?.includes('Wolvic'))!).click();await until(()=>document.querySelector('.apk-dialog'));button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>!!document.querySelector('.apk-uninstall'));purgeSwitch().click();await until(()=>deleteSwitch().getAttribute('aria-checked')==='true');
|
||||
deleteSwitch().click();purgeSwitch().click();await until(()=>purgeSwitch().getAttribute('aria-checked')==='false');
|
||||
uninstallError='Android PackageManager did not become ready; export logs and retry.';
|
||||
button('Uninstall',document.querySelector('.apk-dialog')!).click();await until(()=>!!document.querySelector('.apk-uninstall .error'));
|
||||
if(!apps[0].installed)throw Error('Failed uninstall removed app');
|
||||
if(!document.querySelector('.apk-uninstall .error')?.textContent?.includes('PackageManager'))throw Error('Missing specific readiness error');
|
||||
button('Export logs',document.querySelector('.apk-dialog')!).click();await until(()=>[...document.querySelectorAll('.apk-uninstall [role=status]')].some(e=>e.textContent?.includes('/home/steamos/Downloads/logs.zip')));
|
||||
if(!calls.some(c=>c.method==='diagnostics.export'))throw Error('Uninstall diagnostics not exported');
|
||||
button('Retry',document.querySelector('.apk-dialog')!).click();await wait();await until(()=>!document.querySelector('progress')&&!!document.querySelector('.apk-uninstall .error')&&!button('Retry',document.querySelector('.apk-dialog')!).disabled);
|
||||
uninstallError=null;purgeSwitch().click();await until(()=>deleteSwitch().getAttribute('aria-checked')==='true');
|
||||
button('Retry',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='uninstall'&&c.params.deleteContainer));if(!calls.find(c=>c.params.operation==='uninstall'&&c.params.deleteContainer)!.params.purge)throw Error('Container deletion omitted purge');
|
||||
console.log('FRAMELY_APK_MANAGER_PASS');console.log('FRAMELY_BRIDGE_PASS');
|
||||
})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e));
|
||||
})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+(e.stack??e)));
|
||||
@@ -42,9 +42,9 @@ int main(int argc,char** argv){
|
||||
cef_main_args_t args{argc,argv};if(std::strcmp(cef_api_hash(CEF_API_VERSION,0),CEF_API_HASH_PLATFORM))return 2;int child=cef_execute_process(&args,nullptr,nullptr);if(child>=0)return child;if(url.empty()||runtime.empty()||cef.empty())return 2;
|
||||
cef_settings_t settings{};settings.size=sizeof(settings);settings.windowless_rendering_enabled=1;str(settings.root_cache_path,runtime+"/cache");str(settings.log_file,runtime+"/cef.log");str(settings.resources_dir_path,cef+"/Resources");str(settings.locales_dir_path,cef+"/Resources/locales");
|
||||
if(!cef_initialize(&args,&settings,nullptr,nullptr))return 3;
|
||||
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,3);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:600,std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?4320:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?2400:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);// Semi-transparent charcoal panels still need a readable surface and clear rounded corners.
|
||||
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,1.5f);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:600,std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?2160:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?1200:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);// Semi-transparent charcoal panels still need a readable surface and clear rounded corners.
|
||||
if(!notification_mode&&w>0&&h>0){auto alpha=[&](int x,int y){return pixels[(y*w+x)*4+3];};if(alpha(0,0)==0&&alpha(w-1,0)==0&&alpha(0,h-1)==0&&alpha(w-1,h-1)==0&&alpha(w/2,40)>200)rounded_paint=true;}};
|
||||
display.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* m,const cef_string_t*,int){auto s=text(m);std::cout<<s<<"\n";if(s.rfind("FRAMELY_PAGE_WHEEL_",0)==0){int x=0,y=0,dx=0,dy=0;if(std::sscanf(s.c_str(),"FRAMELY_PAGE_WHEEL_%d_%d_%d_%d",&x,&y,&dx,&dy)==4){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;host->send_mouse_move_event(host,&mouse,0);host->send_mouse_wheel_event(host,&mouse,dx,dy);host->base.release(&host->base);}}if(s.rfind("FRAMELY_NATIVE_KEYBOARD_",0)==0){auto* frame=b->get_focused_frame(b);auto command=s.substr(24);if(command=="TEXT")keyboard_edit(frame,"汉😃");else if(command=="BACKSPACE")keyboard_edit(frame,"\b");else if(command=="ENTER")keyboard_edit(frame,"\n");else if(command=="X")keyboard_edit(frame,"x");else if(command=="Y")keyboard_edit(frame,"y");else if(command=="LEFT")keyboard_edit(frame,"\x1b[D");else if(command=="RIGHT")keyboard_edit(frame,"\x1b[C");frame->base.release(&frame->base);}if(s=="FRAMELY_LAUNCHER_CANCEL_PRESS"){auto* pointer_host=b->get_host(b);cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_move_event(pointer_host,&outside,0);outside.modifiers=0;pointer_host->send_mouse_click_event(pointer_host,&outside,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;auto* frame=b->get_main_frame(b);cef_string_t code{},source{};str(code,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->execute_java_script(frame,&code,&source,1);cef_string_utf16_clear(&code);frame->base.release(&frame->base);pointer_host->base.release(&pointer_host->base);}if(s.rfind("FRAMELY_LAUNCHER_DRAG_",0)==0){int x=0,y=0;if(press_pending&&std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_DRAG_%d_%d",&x,&y)==2){press_dx=x-test_pointer.x;press_dy=y-test_pointer.y;}}if(s.rfind("FRAMELY_LAUNCHER_PRESS_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_PRESS_%d_%d_%d_%d_%d",&x,&y,&press_dx,&press_dy,&press_duration)==5){auto* pointer_host=b->get_host(b);test_pointer.begin(x,y,std::chrono::steady_clock::now());press_pending=true;cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;pointer_host->set_focus(pointer_host,1);pointer_host->send_mouse_move_event(pointer_host,&mouse,0);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_click_event(pointer_host,&mouse,MBT_LEFT,0,1);pointer_host->base.release(&pointer_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_HOVER_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_HOVER_%d_%d",&x,&y)==2){auto* hover_host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;hover_host->send_mouse_move_event(hover_host,&mouse,0);hover_host->base.release(&hover_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_REGION_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_REGION_%d_%d_%d_%d",&x,&y,&w,&h)==4){const int scale=launcher_mode?3:4;int ink=0;for(int py=std::max(0,y*scale);py<std::min(pixel_height,(y+h)*scale);py++)for(int px=std::max(0,x*scale);px<std::min(pixel_width,(x+w)*scale);px++)if(pixels[(size_t(py)*pixel_width+px)*4+3]>128)ink++;if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted control "<<x<<","<<y<<"\n";}}}if(s.rfind("FRAMELY_LAUNCHER_INK_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_INK_%d_%d_%d_%d",&x,&y,&w,&h)==4){const int scale=launcher_mode?3:4;int ink=0;for(int py=std::max(0,y*scale);py<std::min(pixel_height,(y+h)*scale);py++)for(int px=std::max(0,x*scale);px<std::min(pixel_width,(x+w)*scale);px++){size_t i=(size_t(py)*pixel_width+px)*4;if(pixels[i+3]>128&&pixels[i]>180&&pixels[i+1]>180&&pixels[i+2]>180)ink++;}if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted label "<<x<<","<<y<<" ink="<<ink<<"\n";}}}
|
||||
display.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* m,const cef_string_t*,int){auto s=text(m);std::cout<<s<<"\n";if(s.rfind("FRAMELY_PAGE_WHEEL_",0)==0){int x=0,y=0,dx=0,dy=0;if(std::sscanf(s.c_str(),"FRAMELY_PAGE_WHEEL_%d_%d_%d_%d",&x,&y,&dx,&dy)==4){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;host->send_mouse_move_event(host,&mouse,0);host->send_mouse_wheel_event(host,&mouse,dx,dy);host->base.release(&host->base);}}if(s.rfind("FRAMELY_NATIVE_KEYBOARD_",0)==0){auto* frame=b->get_focused_frame(b);auto command=s.substr(24);if(command=="TEXT")keyboard_edit(frame,"汉😃");else if(command=="BACKSPACE")keyboard_edit(frame,"\b");else if(command=="ENTER")keyboard_edit(frame,"\n");else if(command=="X")keyboard_edit(frame,"x");else if(command=="Y")keyboard_edit(frame,"y");else if(command=="LEFT")keyboard_edit(frame,"\x1b[D");else if(command=="RIGHT")keyboard_edit(frame,"\x1b[C");frame->base.release(&frame->base);}if(s=="FRAMELY_LAUNCHER_CANCEL_PRESS"){auto* pointer_host=b->get_host(b);cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_move_event(pointer_host,&outside,0);outside.modifiers=0;pointer_host->send_mouse_click_event(pointer_host,&outside,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;auto* frame=b->get_main_frame(b);cef_string_t code{},source{};str(code,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->execute_java_script(frame,&code,&source,1);cef_string_utf16_clear(&code);frame->base.release(&frame->base);pointer_host->base.release(&pointer_host->base);}if(s.rfind("FRAMELY_LAUNCHER_DRAG_",0)==0){int x=0,y=0;if(press_pending&&std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_DRAG_%d_%d",&x,&y)==2){press_dx=x-test_pointer.x;press_dy=y-test_pointer.y;}}if(s.rfind("FRAMELY_LAUNCHER_PRESS_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_PRESS_%d_%d_%d_%d_%d",&x,&y,&press_dx,&press_dy,&press_duration)==5){auto* pointer_host=b->get_host(b);test_pointer.begin(x,y,std::chrono::steady_clock::now());press_pending=true;cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;pointer_host->set_focus(pointer_host,1);pointer_host->send_mouse_move_event(pointer_host,&mouse,0);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_click_event(pointer_host,&mouse,MBT_LEFT,0,1);pointer_host->base.release(&pointer_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_HOVER_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_HOVER_%d_%d",&x,&y)==2){auto* hover_host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;hover_host->send_mouse_move_event(hover_host,&mouse,0);hover_host->base.release(&hover_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_REGION_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_REGION_%d_%d_%d_%d",&x,&y,&w,&h)==4){const float scale=launcher_mode?1.5f:4.f;int ink=0;for(int py=std::max(0,int(y*scale));py<std::min(pixel_height,int((y+h)*scale));py++)for(int px=std::max(0,int(x*scale));px<std::min(pixel_width,int((x+w)*scale));px++)if(pixels[(size_t(py)*pixel_width+px)*4+3]>128)ink++;if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted control "<<x<<","<<y<<"\n";}}}if(s.rfind("FRAMELY_LAUNCHER_INK_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_INK_%d_%d_%d_%d",&x,&y,&w,&h)==4){const float scale=launcher_mode?1.5f:4.f;int ink=0;for(int py=std::max(0,int(y*scale));py<std::min(pixel_height,int((y+h)*scale));py++)for(int px=std::max(0,int(x*scale));px<std::min(pixel_width,int((x+w)*scale));px++){size_t i=(size_t(py)*pixel_width+px)*4;if(pixels[i+3]>128&&pixels[i]>180&&pixels[i+1]>180&&pixels[i+2]>180)ink++;}if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted label "<<x<<","<<y<<" ink="<<ink<<"\n";}}}
|
||||
if(s=="FRAMELY_DPI_CLICK_REQUEST"){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=50;mouse.y=45;host->set_focus(host,1);host->send_mouse_move_event(host,&mouse,0);host->send_mouse_click_event(host,&mouse,MBT_LEFT,0,1);host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);host->base.release(&host->base);}if(s=="FRAMELY_DPI_CLICK_PASS")dpi_click=true;if(s.rfind("FRAMELY_PREVIEW_INSTALL_",0)==0)snapshot(s.substr(16));if(s=="FRAMELY_PREVIEW_STORE")snapshot("store");if(s=="FRAMELY_PREVIEW_FAVORITES")snapshot("favorites");if(s=="FRAMELY_PREVIEW_INSTALLED")snapshot("installed");if(s=="FRAMELY_PREVIEW_SETTINGS")snapshot("settings");if(s=="FRAMELY_VIEW_READY")ready=true;if(s=="FRAMELY_BRIDGE_PASS")passed=true;if(s.rfind("FRAMELY_BRIDGE_FAIL",0)==0)failed=true;return 1;};life.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t*){closed=true;};
|
||||
client.api.get_render_handler=[](cef_client_t*){return render.acquire();};client.api.get_display_handler=[](cef_client_t*){return display.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life.acquire();};
|
||||
cef_window_info_t win{};win.size=sizeof(win);win.windowless_rendering_enabled=1;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=60;cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&win,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);if(!browser)return 4;auto* host=browser->get_host(browser);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Verify actual compiled GLES entry points without needing a graphics context.
|
||||
#include <assert.h>
|
||||
#include <dlfcn.h>
|
||||
#include <string.h>
|
||||
static int calls, queries;
|
||||
static void attachment(unsigned t, unsigned a, unsigned tt, unsigned id, int l) {
|
||||
assert(t==1 && a==2 && tt==3 && id==4 && l==5); ++calls;
|
||||
}
|
||||
static unsigned status(unsigned t) { assert(t==1 && calls==queries+1); ++queries; return 0x8cd5; }
|
||||
static void *lookup(void *id, const char *name) {
|
||||
assert(id==(void*)42);
|
||||
if (!strcmp(name,"glFramebufferTexture2D")) return attachment;
|
||||
if (!strcmp(name,"glCheckFramebufferStatus")) return status;
|
||||
return 0;
|
||||
}
|
||||
static void *missing(void *id, const char *name) { (void)id; (void)name; return 0; }
|
||||
int main(int argc, char **argv) {
|
||||
assert(argc==2);
|
||||
void *lib=dlopen(argv[1],RTLD_NOW); assert(lib);
|
||||
void (*init)(void*,void*(*)(void*,const char*))=dlsym(lib,"AndroidGLESLayer_Initialize");
|
||||
void *(*get)(const char*,void*)=dlsym(lib,"AndroidGLESLayer_GetProcAddress");
|
||||
assert(init && get);
|
||||
init((void*)42,lookup);
|
||||
assert(get("glDrawArrays",(void*)123)==(void*)123);
|
||||
assert(get("glFramebufferTexture2D",0)==0);
|
||||
void (*hook)(unsigned,unsigned,unsigned,unsigned,int)=get("glFramebufferTexture2D",attachment);
|
||||
hook(1,2,3,4,5); assert(calls==1 && queries==1);
|
||||
init(0,missing);
|
||||
assert(get("glFramebufferTexture2D",attachment)==attachment);
|
||||
dlclose(lib);
|
||||
}
|
||||
@@ -1,8 +1,33 @@
|
||||
#include "gamepad_mapping.h"
|
||||
#include <cassert>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
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]);
|
||||
// The custom host identity must retain Android's expected ABXY/axis layout.
|
||||
std::ifstream file("native/input/gamepad/Vendor_0001_Product_f001.kl");assert(file);std::map<int,std::string> entries;std::string line;
|
||||
while(std::getline(file,line)){std::istringstream row(line);std::string kind,name;int code;if(row>>kind>>code>>name&&kind=="key")entries[code]=name;}
|
||||
const char* names[]={"BUTTON_A","BUTTON_B","BUTTON_X","BUTTON_Y","BUTTON_L1","BUTTON_R1","BUTTON_SELECT","BUTTON_START","BUTTON_THUMBL","BUTTON_THUMBR"};
|
||||
for(int i=0;i<10;i++)assert(entries[keys[i]]==names[i]);
|
||||
assert(trigger_keys[0]==312&&trigger_keys[1]==313);
|
||||
assert(entries[trigger_keys[0]]=="BUTTON_L2"&&entries[trigger_keys[1]]=="BUTTON_R2");
|
||||
GamepadTriggers triggers;
|
||||
assert(!triggers.update(0,0));assert(!triggers.update(0,203));
|
||||
assert(triggers.update(0,204));assert(triggers.update(0,192));
|
||||
assert(!triggers.down[1]);assert(triggers.update(1,255));
|
||||
assert(!triggers.update(0,191));assert(triggers.down[1]);
|
||||
assert(!triggers.update(1,0));assert(!triggers.down[0]&&!triggers.down[1]);
|
||||
assert(triggers.configure(50));assert(!triggers.update(0,127));assert(triggers.update(0,128));
|
||||
assert(triggers.update(0,115));assert(!triggers.update(0,114));
|
||||
// Changing the setting recalculates a held trigger without axis movement.
|
||||
assert(triggers.update(0,180));assert(triggers.configure(80));assert(!triggers.update(0,180));
|
||||
assert(triggers.configure(60));assert(triggers.update(0,180));assert(!triggers.update(0,0));
|
||||
assert(triggers.configure(1));assert(!triggers.update(0,2));assert(triggers.update(0,3));assert(!triggers.update(0,0));
|
||||
assert(triggers.configure(100));assert(!triggers.update(1,254));assert(triggers.update(1,255));assert(!triggers.update(1,242));
|
||||
assert(!triggers.configure(0));assert(!triggers.configure(101));assert(triggers.press==255);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#include "gamepad_steam.h"
|
||||
#include "gamepad_feedback.h"
|
||||
#include <cassert>
|
||||
#include <sstream>
|
||||
int main(){
|
||||
std::istringstream info("[slot 0]\nname=Steam Frame Controllers\nVID=0x28de\nPID=0x11e0\n[slot 1]\nVID=0x0001\nPID=0xf001\n[slot 2]\nVID=0x045e\nPID=0x028e\n");
|
||||
auto rows=read_steam_pad_info(info);assert(allowed_steam_slot(rows,0));assert(!allowed_steam_slot(rows,1));assert(allowed_steam_slot(rows,2));assert(!allowed_steam_slot(rows,3));
|
||||
std::istringstream missing("[slot 0]\nVID=0x28de\n");assert(!allowed_steam_slot(read_steam_pad_info(missing),0));
|
||||
std::istringstream invalid("[slot 99]\nVID=0x28de\nPID=0x11e0\n");assert(read_steam_pad_info(invalid).empty());
|
||||
std::array<bool,15> b{};std::array<int,6>a{};for(int k=0;k<4;k++){b[k]=true;auto s=steam_pad_state(b,a);assert(s[k]==1);b[k]=false;}
|
||||
b[4]=b[6]=b[7]=b[8]=b[9]=b[10]=true;b[11]=b[14]=true;a={-32768,32767,-15000,8000,32767,16384};auto s=steam_pad_state(b,a);for(int k=4;k<10;k++)assert(s[k]);assert(s[10]==-32768&&s[11]==32767&&s[12]==-15000&&s[13]==8000);assert(s[14]==255&&s[15]==127);assert(s[16]==1&&s[17]==-1);
|
||||
GamepadFeedback f;ff_effect e{};e.id=0;e.type=FF_RUMBLE;e.replay.delay=50;e.replay.length=100;e.u.rumble.strong_magnitude=60000;e.u.rumble.weak_magnitude=12000;assert(f.upload(e)==0);f.play(0,1,1000);assert(f.value(1049,true)[0]==0);assert(f.value(1050,true)[0]==32768);assert(f.value(1149,true)[1]==12000);assert(f.value(1150,true)[0]==0);
|
||||
f.play(0,2,1000);assert(f.value(1150,true)[0]==0);assert(f.value(1200,true)[0]==32768);assert(f.value(1300,true)[0]==0);
|
||||
f.play(0,1,2000);f.value(2050,false);assert(f.value(2051,true)[0]==0);f.play(0,1,3000);f.erase(0);assert(f.value(3050,true)[0]==0);
|
||||
e.id=32;assert(f.upload(e)==-EINVAL);e.id=0;e.type=FF_PERIODIC;assert(f.upload(e)==-EINVAL);e.type=FF_RUMBLE;e.replay.delay=0;e.replay.length=65535;f.upload(e);f.play(0,100,0);assert(f.value(9999,true)[0]>0&&f.value(10000,true)[0]==0);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {SteamLaunchChoice} from '../ui/src/steam-launch-choice';
|
||||
import {configureLanguage,t} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
import '../ui/src/launcher.css';
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
const wait=()=>new Promise(r=>setTimeout(r,450));
|
||||
let chosen:string|undefined='unset',closed=false;
|
||||
async function show(locale:string){configureLanguage(locale,[]);root.render(<div className="app launcher-shell" style={{height:'100%',background:'#161618'}}><SteamLaunchChoice name="Hades" local targets={[{client:'12',name:'TOORU-PC'},{client:'13',name:'Living Room PC'}]} onChoose={client=>chosen=client} onClose={()=>closed=true}/></div>);await wait();return document.querySelector('.launch-choice')!;}
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
let dialog=await show('zh-CN');
|
||||
if(!dialog.textContent?.includes('选择启动方式')||!dialog.textContent.includes('本机启动')||!dialog.textContent.includes('TOORU-PC')||!dialog.textContent.includes('Living Room PC'))throw Error('Missing localized labels or device names');
|
||||
if(dialog.scrollWidth>dialog.clientWidth+1)throw Error('Horizontal overflow');
|
||||
let buttons=dialog.querySelectorAll<HTMLButtonElement>('.launch-choice-target');buttons[2].click();if(chosen!=='13')throw Error('Wrong remote host selected');buttons[0].click();if(chosen!==undefined)throw Error('Local launch still passes remote host');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_CHINESE');await wait();
|
||||
dialog=await show('en-US');if(!dialog.textContent?.includes('Choose how to launch'))throw Error('English missing');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_ENGLISH');await wait();
|
||||
dialog.querySelector<HTMLButtonElement>('.icon-button')!.click();if(!closed)throw Error('Close action broken');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e)}};
|
||||
show('zh-CN').then(()=>console.log('FRAMELY_VIEW_READY'));
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
const evaluate=eval(`(${await readFile('src/steam/remote.js','utf8')})`);
|
||||
const launched=[];
|
||||
globalThis.window={RemotePlayStore_SteamUI:{devices:[{clientId:'12',clientName:'Gaming PC',status:'Connected'},{clientId:'13',clientName:'Laptop',status:'Connected'},{clientId:'14',clientName:'Offline',status:'Disconnected'}]},appStore:{allApps:[{appid:42,display_name:'Game',app_type:1,remote_per_client_data:['12','13','14'].map(clientid=>({clientid,installed:true})),BIsPerClientDataLocal:()=>false}]}};
|
||||
globalThis.SteamClient={Apps:{StreamGame:async(...args)=>launched.push(args)}};
|
||||
const rows=await evaluate({mode:'list'});
|
||||
assert.deepEqual(rows.map(r=>[r.client,r.deviceName]),[['12','Gaming PC'],['13','Laptop']]);
|
||||
assert.equal(await evaluate({mode:'launch',id:42,client:'13'}),true);
|
||||
assert.deepEqual(launched,[[42,'13',-1]]);
|
||||
window.RemotePlayStore_SteamUI.devices[1].status='Disconnected';
|
||||
assert.equal(await evaluate({mode:'launch',id:42,client:'13'}),false);
|
||||
assert.equal(launched.length,1);
|
||||
console.log('Remote device names, selection and disconnected-host validation passed.');
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
const lookup=eval(`(${await readFile('src/apk/steam_shortcut_lookup.js','utf8')})`);
|
||||
const id=0xf1234567, exe='/home/steamos/devkit-game/owned/base.apk';
|
||||
let targets=new Map([[id,{strShortcutExe:`"${exe}"`}]]),unregistered=0;
|
||||
globalThis.appStore={m_mapApps:new Map([[id,{appid:id,display_name:'GamePad Tester'}]])};
|
||||
globalThis.SteamClient={Apps:{RegisterForAppDetails:(id,cb)=>{queueMicrotask(()=>cb(targets.get(id)??{}));return {unregister(){unregistered++}}}}};
|
||||
assert.deepEqual(await lookup({game:'owned',exe}),{id});
|
||||
assert.ok(unregistered>0);
|
||||
assert.deepEqual(await lookup({game:'owned',exe,previous:id}),{id});
|
||||
// Never accept an identically named entry belonging to a different executable.
|
||||
appStore.m_mapApps.set(id,{appid:id,display_name:'Devkit Game: owned'});
|
||||
targets.set(id,{strShortcutExe:'/another/app.apk'});
|
||||
await assert.rejects(lookup({game:'owned',exe}),/target does not match/);
|
||||
appStore.m_mapApps.set(id,{appid:id,display_name:'GamePad Tester'});
|
||||
await assert.rejects(lookup({game:'owned',exe}),/Missing or ambiguous/);
|
||||
targets.set(id,{strShortcutExe:exe});
|
||||
appStore.m_mapApps.set(id+1,{appid:id+1,display_name:'Another copy'});targets.set(id+1,{strShortcutExe:exe});
|
||||
await assert.rejects(lookup({game:'owned',exe}),/ambiguous/);
|
||||
// Cached details can call back before the subscription is returned.
|
||||
appStore.m_mapApps.delete(id+1);
|
||||
SteamClient.Apps.RegisterForAppDetails=(id,cb)=>{cb(targets.get(id));return {unregister(){unregistered++}}};
|
||||
assert.deepEqual(await lookup({game:'owned',exe,previous:id}),{id});
|
||||
console.log('Renamed registration recovery, executable ownership, ambiguity and cached callback checks passed.');
|
||||
@@ -5,6 +5,9 @@ import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from types import SimpleNamespace
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
spec = importlib.util.spec_from_file_location('diagnostics', pathlib.Path(__file__).parents[1] / 'tools/export-diagnostics.py')
|
||||
@@ -56,5 +59,66 @@ class DiagnosticsTests(unittest.TestCase):
|
||||
self.assertLessEqual(len(data), diag.MAX_FILE)
|
||||
self.assertTrue(data.endswith(b'LAST ERROR'))
|
||||
|
||||
def test_apk_logs_summary_are_bounded_and_exclude_application_files(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = pathlib.Path(temp)
|
||||
home = root / 'user'
|
||||
apk = home / '.local/share/framely/apk-manager'
|
||||
(apk / 'logs').mkdir(parents=True)
|
||||
(apk / 'apks').mkdir()
|
||||
(apk / 'apks/private.apk').write_text('PRIVATE_APK_CONTENT')
|
||||
(apk / 'state.json').write_text(json.dumps({'records': {'ctx/pkg': {
|
||||
'context':'ctx', 'steamToken':'PRIVATE_BINDING_TOKEN',
|
||||
'metadata': {'package':'test.pkg', 'name':'Test app', 'icon':'PRIVATE_ICON'},
|
||||
'removed':False, 'pending':'uninstall'}}}))
|
||||
for i in range(14):
|
||||
path = apk / 'logs' / f'{i:02}.log'
|
||||
path.write_text(f'operation {i} token=PRIVATE_LOG_TOKEN')
|
||||
import os
|
||||
os.utime(path, (i + 1, i + 1))
|
||||
saved = home / '.local/share/Steam/logs/lepton-logcats/ctx'
|
||||
saved.mkdir(parents=True)
|
||||
(saved / 'logcat-crash.log').write_text('Android boot failure password=PRIVATE_PASSWORD')
|
||||
(saved / 'video.mp4').write_text('PRIVATE_MEDIA')
|
||||
(saved / 'logcat-symlink.log').symlink_to(saved / 'video.mp4')
|
||||
result = diag.collect(root, commands=False, runtime=root/'runtime', steam_home=home)
|
||||
archive = zipfile.ZipFile(io.BytesIO(base64.b64decode(result['data'])))
|
||||
self.assertIn('apk/summary.json', archive.namelist())
|
||||
self.assertIn('apk/android-saved/ctx/logcat-crash.log', archive.namelist())
|
||||
self.assertNotIn('apk/operations/00.log', archive.namelist())
|
||||
self.assertIn('apk/operations/13.log', archive.namelist())
|
||||
text = '\n'.join(archive.read(n).decode() for n in archive.namelist())
|
||||
self.assertIn('Android boot failure', text)
|
||||
self.assertIn('Test app', text)
|
||||
self.assertNotIn('PRIVATE_', text)
|
||||
|
||||
def test_active_container_diagnostics_use_session_user_and_keep_timeout_output(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = pathlib.Path(temp)
|
||||
(root/'steam-user').write_text('teststeam')
|
||||
home = root/'user'
|
||||
user = SimpleNamespace(pw_name='teststeam', pw_dir=str(home), pw_uid=1000)
|
||||
calls = []
|
||||
def run(args, **kwargs):
|
||||
calls.append(args)
|
||||
if 'ps' in args:
|
||||
kwargs['stdout'].write(b'lepton-active\tUp 1 minute\nlepton-stopped\tExited (0)\n')
|
||||
elif 'exec' in args:
|
||||
kwargs['stdout'].write(b'=== boot ===\n0\n=== user ===\nRUNNING_LOCKED\n')
|
||||
raise subprocess.TimeoutExpired(args, 8)
|
||||
return SimpleNamespace(returncode=0)
|
||||
with mock.patch.object(diag.pwd,'getpwnam',return_value=user), mock.patch.object(diag.os,'geteuid',return_value=0), mock.patch.object(diag.subprocess,'run',side_effect=run):
|
||||
result = diag.collect(root, runtime=root/'runtime')
|
||||
archive = zipfile.ZipFile(io.BytesIO(base64.b64decode(result['data'])))
|
||||
text = archive.read('apk/android-live/lepton-active.log').decode()
|
||||
self.assertIn('RUNNING_LOCKED', text)
|
||||
executions = [a for a in calls if 'exec' in a]
|
||||
self.assertEqual(len(executions), 1)
|
||||
self.assertEqual(executions[0][:4], ['runuser','-u','teststeam','--'])
|
||||
self.assertIn('HOME='+str(home), executions[0])
|
||||
self.assertIn('XDG_RUNTIME_DIR=/run/user/1000', executions[0])
|
||||
self.assertNotIn('start', executions[0])
|
||||
self.assertTrue(any('partial output' in w for w in json.loads(archive.read('report.json'))['warnings']))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Steam mode must not register an OpenVR action application."""
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@unittest.skipUnless(platform.machine() == 'aarch64' and os.geteuid() != 0 and shutil.which('g++'), 'ARM64 native bridge requires a non-root user and g++')
|
||||
class GamepadStartup(unittest.TestCase):
|
||||
def test_steam_skips_openvr_but_frame_direct_initializes_it(self):
|
||||
with tempfile.TemporaryDirectory(dir=ROOT / 'target', prefix='gamepad-startup-') as directory:
|
||||
work = pathlib.Path(directory)
|
||||
vr = work / 'vr.cpp'
|
||||
symbols = ['VR_InitInternal', 'VR_InitInternal2', 'VR_ShutdownInternal', 'VR_GetGenericInterface', 'VR_GetInitToken', 'VR_IsInterfaceVersionValid']
|
||||
vr.write_text('#include <cstdio>\n#include <cstdlib>\n' + '\n'.join('extern "C" void ' + name + '(){fputs("OPENVR_USED\\n",stderr);exit(91);}' for name in symbols))
|
||||
subprocess.run(['g++', '-shared', '-fPIC', str(vr), '-o', str(work / 'libopenvr_api.so')], check=True)
|
||||
sdl = work / 'sdl.cpp'
|
||||
sdl.write_text('''#include <cstdio>
|
||||
#include <cstdint>
|
||||
extern "C" {
|
||||
bool SDL_Init(uint32_t){fputs("SDL_USED\\n",stderr);return true;}
|
||||
void SDL_Quit(){} void SDL_UpdateGamepads(){}
|
||||
void SDL_PumpEvents(){fputs("SDL_PUMPED\\n",stderr);}
|
||||
void SDL_FlushEvents(uint32_t,uint32_t){fputs("SDL_FLUSHED\\n",stderr);}
|
||||
uint32_t* SDL_GetGamepads(int* n){*n=0;return nullptr;}
|
||||
void SDL_free(void*){} void* SDL_OpenGamepad(uint32_t){return nullptr;}
|
||||
void SDL_CloseGamepad(void*){} const char* SDL_GetGamepadPathForID(uint32_t){return nullptr;}
|
||||
bool SDL_GetGamepadButton(void*,int){return false;} int16_t SDL_GetGamepadAxis(void*,int){return 0;}
|
||||
bool SDL_RumbleGamepad(void*,uint16_t,uint16_t,uint32_t){return true;}
|
||||
}
|
||||
''')
|
||||
subprocess.run(['g++', '-shared', '-fPIC', str(sdl), '-o', str(work / 'libsdl-test.so')], check=True)
|
||||
loop = work / 'loop.cpp'
|
||||
loop.write_text('#include "gamepad_steam.h"\nint main(int argc,char** argv){SteamGamepad pad;if(!pad.start(argv[1],argv[2]))return 1;pad.read(0,false);pad.read(501,false);}')
|
||||
subprocess.run(['g++', '-std=c++17', '-I'+str(ROOT/'native'), str(loop), '-ldl', '-o', str(work/'loop')], check=True)
|
||||
result = subprocess.run([str(work/'loop'), str(work/'libsdl-test.so'), str(work/'info')], capture_output=True, text=True, check=True)
|
||||
self.assertEqual(result.stderr.count('SDL_PUMPED'), 2)
|
||||
self.assertEqual(result.stderr.count('SDL_FLUSHED'), 2)
|
||||
helper = work / 'bridge'
|
||||
subprocess.run(['g++', '-std=c++17', '-O2', '-I'+str(ROOT/'native/vendor/openvr'), str(ROOT/'native/gamepad.cpp'), '-L'+str(ROOT/'native/vendor/openvr/lib/linuxarm64'), '-lopenvr_api', '-ldl', '-pthread', '-o', str(helper)], check=True)
|
||||
env = dict(os.environ, LD_LIBRARY_PATH=str(work), FRAMELY_STEAM_SDL_LIBRARY=str(work/'libsdl-test.so'), FRAMELY_STEAM_GAMEPAD_INFO=str(work/'info'))
|
||||
args = [str(helper), '/nonexistent/actions.json', '/nonexistent/app.vrmanifest', 'framely.test']
|
||||
steam = subprocess.run(args, env=dict(env, FRAMELY_GAMEPAD_SOURCE='steam'), input='', capture_output=True, text=True, timeout=10)
|
||||
self.assertIn('SDL_USED', steam.stderr)
|
||||
self.assertNotIn('OPENVR_USED', steam.stderr)
|
||||
self.assertNotIn('Invalid gamepad action', steam.stderr)
|
||||
frame = subprocess.run(args, env=dict(env, FRAMELY_GAMEPAD_SOURCE='frame'), input='', capture_output=True, text=True, timeout=10)
|
||||
self.assertIn('OPENVR_USED', frame.stderr)
|
||||
self.assertNotIn('SDL_USED', frame.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Execute the Steam cleanup expression against a simulated Steam client."""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class ArtworkCleanup(unittest.TestCase):
|
||||
def run_cleanup(self, target):
|
||||
source = (ROOT / 'src/apk/steam_ui.rs').read_text().split('pub(super) fn clear_artwork', 1)[1]
|
||||
template = re.search(r'r#"(.*?)"#', source, re.S).group(1)
|
||||
expression = template.replace('{parameters}', '__PARAMETERS__').replace('{{', '{').replace('}}', '}')
|
||||
expression = expression.replace('__PARAMETERS__', json.dumps({'id': 2452903544, 'exe': '/owned/base.apk'}))
|
||||
setup = f'''
|
||||
const calls=[];
|
||||
const appStore={{m_mapApps:new Map([[2452903544,{{appid:2452903544}}]])}};
|
||||
const SteamClient={{Apps:{{
|
||||
RegisterForAppDetails(id,cb){{cb({{strShortcutExe:{json.dumps(target)}}});return {{unregister(){{}}}};}},
|
||||
async ClearCustomArtworkForApp(id,kind){{calls.push(['clear',id,kind]);}},
|
||||
SetShortcutIcon(id,path){{calls.push(['icon',id,path]);}}
|
||||
}}}};
|
||||
'''
|
||||
script = setup + f'''Promise.resolve({expression}).then(()=>console.log(JSON.stringify({{calls,ok:true}})))
|
||||
.catch(()=>console.log(JSON.stringify({{calls,ok:false}})));'''
|
||||
result = subprocess.run(['node', '-e', script], capture_output=True, text=True, check=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_clears_every_injected_image_and_shortcut_icon(self):
|
||||
result = self.run_cleanup('"/owned/./base.apk"')
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertEqual(result['calls'], [['clear', 2452903544, kind] for kind in [0, 1, 3, 4]]
|
||||
+ [['icon', 2452903544, '']])
|
||||
|
||||
def test_refuses_another_shortcut_with_the_same_id(self):
|
||||
result = self.run_cleanup('/other/game.exe')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertEqual(result['calls'], [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Exercise cached audio with a fake SDL, without speakers/Steam/CEF."""
|
||||
import pathlib, subprocess, tempfile
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = pathlib.Path(tmp)
|
||||
(tmp / 'steamrtarm64').mkdir()
|
||||
fake = tmp / 'fake.cpp'
|
||||
fake.write_text(r'''
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
static int loads=0, writes=0, streams=0, cleared=0;
|
||||
extern "C" {
|
||||
bool SDL_InitSubSystem(uint32_t flags){assert(flags==0x10);return true;}
|
||||
void SDL_QuitSubSystem(uint32_t flags){assert(flags==0x10);assert(streams==0);}
|
||||
bool SDL_LoadWAV(const char* path,void* spec,uint8_t** data,uint32_t* len){assert(strstr(path,"/steamui/sounds/deck_ui_typing.wav"));++loads;*data=(uint8_t*)malloc(4);*len=4;return true;}
|
||||
void* SDL_OpenAudioDeviceStream(uint32_t id,const void*,void* callback,void* userdata){assert(id==0xffffffffu&&!callback&&!userdata);++streams;return malloc(1);}
|
||||
bool SDL_SetAudioStreamGain(void*,float gain){assert(gain==.30f||gain==.45f);return true;}
|
||||
bool SDL_ResumeAudioStreamDevice(void*){return true;}
|
||||
bool SDL_PutAudioStreamData(void*,const void*,int len){assert(loads==2&&len==4);++writes;return true;}
|
||||
bool SDL_ClearAudioStream(void*){++cleared;return true;}
|
||||
void SDL_DestroyAudioStream(void* stream){--streams;free(stream);}
|
||||
void SDL_free(void* data){free(data);}
|
||||
int counts(int which){return which==0?loads:which==1?writes:cleared;}
|
||||
}
|
||||
''')
|
||||
library=tmp/'steamrtarm64/libSDL3.so.0'
|
||||
subprocess.run(['g++','-shared','-fPIC',str(fake),'-o',str(library)],check=True)
|
||||
test=tmp/'test.cpp'
|
||||
test.write_text(r'''
|
||||
#include "ui_feedback.h"
|
||||
#include <cassert>
|
||||
int main(int argc,char** argv){
|
||||
SteamUiSounds sounds;assert(!sounds.prepare("/missing"));sounds.play();sounds.silence();
|
||||
assert(sounds.prepare(argv[1]));
|
||||
auto lib=dlopen((std::string(argv[1])+"/steamrtarm64/libSDL3.so.0").c_str(),RTLD_NOW);
|
||||
auto counts=reinterpret_cast<int(*)(int)>(dlsym(lib,"counts"));
|
||||
sounds.play();sounds.play(true);sounds.silence();
|
||||
assert(counts(0)==2&&counts(1)==2&&counts(2)==6);
|
||||
sounds.shutdown();sounds.play();assert(counts(1)==2);dlclose(lib);
|
||||
}
|
||||
''')
|
||||
subprocess.run(['g++','-std=c++17','-I'+str(root/'native'),str(test),'-ldl','-o',str(tmp/'test')],check=True)
|
||||
subprocess.run([str(tmp/'test'),str(tmp)],check=True)
|
||||
print('Steam UI cached sound playback: ok')
|
||||
@@ -4,10 +4,10 @@ import {build} from 'esbuild';
|
||||
const result=await build({entryPoints:['ui/src/upload.ts'],bundle:true,write:false,platform:'node',format:'esm'});
|
||||
const {uploadFile}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
test('250 MiB selection uses bounded blobs and never reads the entire file',async()=>{
|
||||
const size=250*1024*1024,chunk=4*1024*1024;let received=0,calls=0,last=0,nextOffset=0;
|
||||
const size=250*1024*1024,chunk=16*1024*1024;let received=0,calls=0,last=0,nextOffset=0;
|
||||
const file={size,arrayBuffer(){throw Error('whole file read');},slice(start,end){assert.equal(start,nextOffset);nextOffset+=chunk;return new Blob([new Uint8Array(Math.min(end,size)-start)]);}};
|
||||
const id=await uploadFile(file,new AbortController().signal,p=>last=p,{start:async n=>{assert.equal(n,size);return {upload:'ticket',chunkSize:chunk};},chunk:async(id,offset,blob)=>{assert.equal(id,'ticket');assert.equal(offset,received);assert.ok(blob.size<=chunk);received+=blob.size;calls++;},abort:async()=>assert.fail('unexpected abort')});
|
||||
assert.equal(id,'ticket');assert.equal(received,size);assert.equal(calls,63);assert.equal(last,100);
|
||||
assert.equal(id,'ticket');assert.equal(received,size);assert.equal(calls,16);assert.equal(last,100);
|
||||
});
|
||||
test('cancellation stops scheduling new chunks and clears the upload',async()=>{
|
||||
const controller=new AbortController();let calls=0,aborted='';
|
||||
@@ -15,7 +15,7 @@ test('cancellation stops scheduling new chunks and clears the upload',async()=>{
|
||||
assert.equal(calls,4);assert.equal(aborted,'partial');
|
||||
});
|
||||
test('invalid server chunk size and failed requests clean up uploads',async()=>{
|
||||
for(const chunkSize of [0,8*1024*1024,1]){let aborted=false;await assert.rejects(uploadFile({size:2,slice:()=>new Blob(['a'])},new AbortController().signal,()=>{},{start:async()=>({upload:'partial',chunkSize}),chunk:async()=>{throw Error('network');},abort:async()=>{aborted=true;}}));assert.ok(aborted);}
|
||||
for(const chunkSize of [0,16*1024*1024+1,1]){let aborted=false;await assert.rejects(uploadFile({size:2,slice:()=>new Blob(['a'])},new AbortController().signal,()=>{},{start:async()=>({upload:'partial',chunkSize}),chunk:async()=>{throw Error('network');},abort:async()=>{aborted=true;}}));assert.ok(aborted);}
|
||||
});
|
||||
|
||||
test('four workers allow out-of-order completion with monotonic acknowledged progress',async()=>{
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reproduce the checked-in tiny DEX helper; normal builds need no JDK/download.
|
||||
set -euo pipefail
|
||||
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
work="$base/target/android-window-build"
|
||||
mkdir -p "$work"
|
||||
url=https://dl.google.com/dl/android/maven2/com/android/tools/r8/8.3.37/r8-8.3.37.jar
|
||||
curl -fsSL "$url" -o "$work/r8.jar"
|
||||
printf '%s %s\n' 59753e70a74f918389cc87f1b7d66b5c0862932559167425708ded159e3de439 "$work/r8.jar" | sha256sum -c -
|
||||
javac --release 8 -Xlint:-options -d "$work" "$base/native/android/FramelyWindow.java"
|
||||
java -cp "$work/r8.jar" com.android.tools.r8.D8 --release --min-api 30 --output "$work" "$work/FramelyWindow.class"
|
||||
cp "$work/classes.dex" "$base/native/android/framely-window.dex"
|
||||
@@ -6,8 +6,11 @@ out=$(realpath -m "${2:-$base/target/native}")
|
||||
mkdir -p "$out"
|
||||
(cd "$base" && node tools/build-scroll-gestures.mjs "$out")
|
||||
openvr="$base/native/vendor/openvr"
|
||||
g++ -std=c++17 -O2 -I"$cef" -I"$openvr" -I"$base/native" -I"$out" "$base/native/host.cpp" -L"$cef/Release" -L"$openvr/lib/linuxarm64" -lcef -lopenvr_api -lGL -lX11 -pthread -Wl,-rpath,'$ORIGIN:$ORIGIN/../openvr' -o "$out/framely-vr"
|
||||
g++ -std=c++17 -O2 -I"$cef" -I"$openvr" -I"$base/native" -I"$out" "$base/native/host.cpp" -L"$cef/Release" -L"$openvr/lib/linuxarm64" -lcef -lopenvr_api -lGL -lX11 -ldl -pthread -Wl,-rpath,'$ORIGIN:$ORIGIN/../openvr' -o "$out/framely-vr"
|
||||
|
||||
# Independent of CEF; both binaries ship in the core/update archive.
|
||||
g++ -std=c++17 -O2 -I"$openvr" "$base/native/gamepad.cpp" -L"$openvr/lib/linuxarm64" -lopenvr_api -pthread -Wl,-rpath,'$ORIGIN' -o "$out/framely-gamepad"
|
||||
g++ -std=c++17 -O2 -I"$openvr" "$base/native/gamepad.cpp" -L"$openvr/lib/linuxarm64" -lopenvr_api -ldl -pthread -Wl,-rpath,'$ORIGIN' -o "$out/framely-gamepad"
|
||||
gcc -shared -fPIC -nostdlib -fno-stack-protector -Wl,--hash-style=both "$base/native/gamepad_grab.c" -o "$out/libframely-gamepad-grab.so"
|
||||
|
||||
# Android GLES layer uses loader-supplied symbols; no host glibc dependency.
|
||||
gcc -shared -fPIC -nostdlib -fno-stack-protector -fvisibility=hidden -Wl,-z,defs,--hash-style=both,-soname,libFramelyFBOCompat.so "$base/native/fbo_compat.c" -o "$out/libFramelyFBOCompat.so"
|
||||
+112
-8
@@ -8,6 +8,7 @@ import json
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import pwd
|
||||
import re
|
||||
import subprocess
|
||||
import zipfile
|
||||
@@ -23,7 +24,7 @@ def redact(text):
|
||||
text = re.sub(r'(/boot/)[a-f0-9]{32,}', r'\1[redacted]', text)
|
||||
return URL_CREDENTIALS.sub(r'\1[redacted]@', text)
|
||||
|
||||
def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_log=None):
|
||||
def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_log=None, steam_home=None):
|
||||
report = {'format': 1, 'createdAt': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'warnings': []}
|
||||
entries = {}
|
||||
budget = MAX_TOTAL
|
||||
@@ -43,7 +44,10 @@ def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_l
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return
|
||||
with path.open('rb') as source:
|
||||
source.seek(max(0, os.fstat(source.fileno()).st_size - MAX_FILE))
|
||||
size = os.fstat(source.fileno()).st_size
|
||||
if size > MAX_FILE:
|
||||
report['warnings'].append(name + ': truncated to recent output')
|
||||
source.seek(max(0, size - MAX_FILE))
|
||||
add(name, source.read(MAX_FILE).decode('utf-8', errors='replace'))
|
||||
except (OSError, ValueError) as error:
|
||||
report['warnings'].append(name + ': ' + str(error))
|
||||
@@ -52,12 +56,22 @@ def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_l
|
||||
# Temporary files avoid buffering unbounded journal output in memory.
|
||||
import tempfile
|
||||
with tempfile.TemporaryFile() as output:
|
||||
result = subprocess.run(args, stdout=output, stderr=subprocess.STDOUT, timeout=8, check=False)
|
||||
try:
|
||||
result = subprocess.run(args, stdout=output, stderr=subprocess.STDOUT, timeout=8, check=False)
|
||||
code = result.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
code = None
|
||||
report['warnings'].append(name + ': command timed out; partial output included')
|
||||
output.seek(0, 2)
|
||||
output.seek(max(0, output.tell() - MAX_FILE))
|
||||
add(name, output.read(MAX_FILE).decode('utf-8', errors='replace'))
|
||||
if result.returncode:
|
||||
report['warnings'].append(name + ': command exited ' + str(result.returncode))
|
||||
size = output.tell()
|
||||
if size > MAX_FILE:
|
||||
report['warnings'].append(name + ': truncated to recent output')
|
||||
output.seek(max(0, size - MAX_FILE))
|
||||
text = output.read(MAX_FILE).decode('utf-8', errors='replace')
|
||||
add(name, text)
|
||||
if code:
|
||||
report['warnings'].append(name + ': command exited ' + str(code))
|
||||
return text if code == 0 else None
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
report['warnings'].append(name + ': ' + str(error))
|
||||
if installer_log is not None:
|
||||
@@ -77,6 +91,96 @@ def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_l
|
||||
]}, ensure_ascii=False, indent=2))
|
||||
except (OSError, ValueError, TypeError, AttributeError) as error:
|
||||
report['warnings'].append('plugins.json: ' + str(error))
|
||||
# APK management runs as the configured Steam user, not the root daemon.
|
||||
# Resolve that user explicitly: root's HOME and rootful Podman are wrong.
|
||||
steam_user = None
|
||||
try:
|
||||
if steam_home is None:
|
||||
user_file = state / 'steam-user'
|
||||
if user_file.is_file() and not user_file.is_symlink():
|
||||
name = user_file.read_text()[:256].strip()
|
||||
steam_user = pwd.getpwnam(name)
|
||||
elif os.geteuid() != 0:
|
||||
steam_user = pwd.getpwuid(os.geteuid())
|
||||
if steam_user is not None and steam_user.pw_uid >= 1000:
|
||||
steam_home = pathlib.Path(steam_user.pw_dir)
|
||||
if steam_home is None:
|
||||
report['warnings'].append('apk: Steam session user unavailable; APK logs not collected')
|
||||
else:
|
||||
steam_home = pathlib.Path(steam_home)
|
||||
apk = steam_home / '.local/share/framely/apk-manager'
|
||||
try:
|
||||
with (apk / 'state.json').open('rb') as source:
|
||||
raw = source.read(16 * 1024 * 1024 + 1)
|
||||
if len(raw) > 16 * 1024 * 1024:
|
||||
raise ValueError('APK state exceeds diagnostic size limit')
|
||||
database = json.loads(raw)
|
||||
records = database.get('records', {})
|
||||
# Never include tokens, bindings, icons or the complete state.
|
||||
summary = {'apps': [
|
||||
{'id': key, 'context': item.get('context'), 'removed': item.get('removed'),
|
||||
'pending': item.get('pending'), 'steamAppId': item.get('steamAppId'),
|
||||
'steamLaunch': item.get('steamLaunch'),
|
||||
'package': item.get('metadata', {}).get('package'),
|
||||
'name': item.get('metadata', {}).get('name'),
|
||||
'version': item.get('metadata', {}).get('version')}
|
||||
for key, item in records.items()
|
||||
], 'gamepadEnabled': database.get('gamepad_enabled'),
|
||||
'gamepadSource': database.get('gamepad_source')}
|
||||
add('apk/summary.json', json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
except (OSError, ValueError, TypeError, AttributeError) as error:
|
||||
report['warnings'].append('apk/summary.json: ' + str(error))
|
||||
|
||||
def recent_logs(directory, pattern, limit, prefix):
|
||||
try:
|
||||
if directory.is_symlink():
|
||||
raise ValueError('Diagnostic log directory must not be a symlink')
|
||||
if not directory.is_dir():
|
||||
report['warnings'].append(prefix + ': log directory unavailable')
|
||||
return
|
||||
paths = [p for p in directory.glob(pattern)
|
||||
if not p.is_symlink() and not p.parent.is_symlink() and p.is_file()]
|
||||
paths.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
for path in paths[:limit]:
|
||||
tail(path, prefix + '/' + path.relative_to(directory).as_posix())
|
||||
if len(paths) > limit:
|
||||
report['warnings'].append(prefix + ': only the ' + str(limit) + ' newest logs included')
|
||||
except (OSError, ValueError) as error:
|
||||
report['warnings'].append(prefix + ': ' + str(error))
|
||||
|
||||
recent_logs(apk / 'logs', '*.log', 12, 'apk/operations')
|
||||
steam = steam_home / '.local/share/Steam'
|
||||
if not steam.is_dir():
|
||||
steam = steam_home / '.steam/steam'
|
||||
# Only known diagnostic files, never media, APKs, saves or tombstones.
|
||||
recent_logs(steam / 'logs/lepton-logcats', '*/logcat-*.log', 12, 'apk/android-saved')
|
||||
if commands and steam_user is not None:
|
||||
prefix = []
|
||||
if os.geteuid() == 0:
|
||||
prefix = ['runuser', '-u', steam_user.pw_name, '--']
|
||||
prefix += ['env', 'HOME=' + str(steam_home),
|
||||
'XDG_RUNTIME_DIR=/run/user/' + str(steam_user.pw_uid), 'podman']
|
||||
listing = command('apk/containers.txt', prefix + ['ps', '-a', '--filter', 'name=^lepton-', '--format', '{{.Names}}\t{{.Status}}'])
|
||||
if listing:
|
||||
active = []
|
||||
for line in listing.splitlines():
|
||||
name, _, status = line.partition('\t')
|
||||
if re.fullmatch(r'lepton-[A-Za-z0-9_.-]+', name) and status.startswith('Up '):
|
||||
active.append(name)
|
||||
for name in active[:4]:
|
||||
# A single read-only exec per active container bounds export
|
||||
# latency and works even when PackageManager is not ready.
|
||||
probe = ('echo "=== boot ==="; getprop sys.boot_completed; '
|
||||
'echo "=== user ==="; am get-started-user-state 0; '
|
||||
'echo "=== storage ==="; ls -ld /storage/emulated/0; dumpsys mount; '
|
||||
'echo "=== package manager ==="; pm path android; '
|
||||
'echo "=== recent Android logs ==="; logcat -d -b main -b system -b crash -t 500')
|
||||
command('apk/android-live/' + name + '.log', prefix + ['exec', name, 'sh', '-c', probe])
|
||||
if len(active) > 4:
|
||||
report['warnings'].append('apk/android-live: only four active containers queried')
|
||||
except (OSError, ValueError, KeyError) as error:
|
||||
report['warnings'].append('apk: ' + str(error))
|
||||
|
||||
try:
|
||||
for path in sorted((state / 'logs').glob('*.log'))[:64]:
|
||||
tail(path, 'logs/' + path.name)
|
||||
@@ -114,7 +218,7 @@ def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_l
|
||||
if commands:
|
||||
command('services.txt', ['systemctl', 'status', 'framely.service', 'framely-session.service', '--no-pager', '--full'])
|
||||
command('journal.txt', ['journalctl', '-u', 'framely.service', '-u', 'framely-session.service', '-u', 'framely-plugin-*', '-u', 'framely-backend-*', '-u', 'framely-hook-*', '--since=-24h', '-n', '3000', '--no-pager', '-o', 'short-iso'])
|
||||
add('README.txt', 'Framely diagnostics. Recent log tails, service state and installed version summary only. Missing sources are listed in report.json. Logs may contain application data; inspect before sharing.\n')
|
||||
add('README.txt', 'Framely diagnostics. Recent Framely and APK operation logs, saved/active Lepton Android log tails, boot/unlock/storage diagnostics, service state and installed version summary only. Application files, media, APK archives and raw memory dumps are excluded. Missing sources are listed in report.json. Logs may contain application data; inspect before sharing.\n')
|
||||
entries['report.json'] = json.dumps(report, ensure_ascii=False, indent=2).encode()
|
||||
memory = io.BytesIO()
|
||||
with zipfile.ZipFile(memory, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
|
||||
@@ -23,7 +23,7 @@ rm -f "$stage/lib/cef/framely-probe" "$stage/lib/cef/framely-browser-probe"
|
||||
# Remove distribution debug information; browser functionality is unchanged.
|
||||
strip --strip-debug "$stage/lib/cef/libcef.so"
|
||||
cp native/vendor/openvr/lib/linuxarm64/libopenvr_api.so "$stage/lib/openvr/"
|
||||
cp target/native/framely-gamepad target/native/libframely-gamepad-grab.so "$stage/lib/openvr/"
|
||||
cp target/native/framely-gamepad target/native/libframely-gamepad-grab.so target/native/libFramelyFBOCompat.so "$stage/lib/openvr/"
|
||||
cp -a ui/dist/. "$stage/share/ui/"
|
||||
cp -a assets/search "$stage/share/"
|
||||
cp assets/search/COPYING "$stage/share/licenses/ipadic.txt"
|
||||
|
||||
@@ -3,7 +3,9 @@ set -euo pipefail
|
||||
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$base"
|
||||
mkdir -p target/tests
|
||||
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"
|
||||
for test in gamepad_mapping gamepad_steam 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" -ldl -o "target/tests/$test"
|
||||
"target/tests/$test"
|
||||
done
|
||||
|
||||
python3 "$base/tests/test_ui_feedback.py"
|
||||
+52
-2
@@ -3,6 +3,29 @@
|
||||
"locale": "en-US",
|
||||
"name": "English",
|
||||
"messages": {
|
||||
"L2 / R2 按键触发行程": "L2 / R2 button activation threshold",
|
||||
"默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。": "Default: 80%. Buttons activate at this trigger travel. Analog axes retain their full range; saving applies immediately.",
|
||||
"更新应用并保留现有存档和设置。": "Update the app while keeping your saves and settings.",
|
||||
"核对应用信息,选择合适的显示方式。": "Check the app details and choose how to display it.",
|
||||
"保留原容器": "Keep existing container",
|
||||
"创建独立容器": "Create a dedicated container",
|
||||
"隐藏二维窗口,应用本身需要支持 VR。": "Hide the 2D window. The app must support VR.",
|
||||
"通过二维窗口显示并操作应用。": "Display and interact with the app in a 2D window.",
|
||||
"查看 APK 详情": "View APK details",
|
||||
"版本代码": "Version code",
|
||||
"APK 包名与原应用不一致,请选择同一应用的更新文件。": "The APK package does not match the original app. Choose an update for the same app.",
|
||||
"确认更新": "Confirm update",
|
||||
"对应的 Steam 库条目、图标和封面也会移除。": "The associated Steam library entry, icon and artwork will also be removed.",
|
||||
"卸载选项": "Uninstall options",
|
||||
"删除存档、设置及应用下载的数据。": "Delete saves, settings and data downloaded by the app.",
|
||||
"容器包含其他应用或状态未知,无法同时删除。": "The container contains other apps or its state is unknown, so it cannot be deleted here.",
|
||||
"开启删除应用数据后可选择。": "Available when app data deletion is enabled.",
|
||||
"此容器仅包含该应用,同时清理 APK 缓存及关联文件。": "This container contains only this app. Its APK cache and associated files will also be removed.",
|
||||
"将永久删除应用及容器数据。历史备份保留。": "App and container data will be permanently deleted. Historical backups will be kept.",
|
||||
"将永久删除存档和应用数据。容器保留。": "Saves and app data will be permanently deleted. The container will be kept.",
|
||||
"存档和设置会保留,之后可以重新安装。": "Saves and settings will be kept so you can reinstall later.",
|
||||
"卸载并删除容器": "Uninstall and delete container",
|
||||
"删除数据和容器": "Delete data and container",
|
||||
"Application state is temporarily unavailable. Refresh or reconcile it in APK management.": "Application state is temporarily unavailable. Refresh or reconcile it in APK management.",
|
||||
"No enabled launch activity is currently available. Check the application in APK management.": "No enabled launch activity is currently available. Check the application in APK management.",
|
||||
"每个新应用使用独立容器,更新保留原容器。": "Each new app uses its own container. Updates retain the existing container.",
|
||||
@@ -478,7 +501,6 @@
|
||||
"日志已保存到:{0}": "Logs saved to: {0}",
|
||||
"日志包已生成,请查看浏览器下载。": "Log archive created. Check your browser downloads.",
|
||||
"日志包缺少数据": "Log archive data is missing",
|
||||
"导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "Export Framely, plugin and renderer logs with service status. Plugin settings and access password files are excluded.",
|
||||
"发现可用发行版本": "Release available",
|
||||
"稍后": "Later",
|
||||
"全部包含的分类": "Categories included in All",
|
||||
@@ -509,6 +531,7 @@
|
||||
"将永久删除该应用保留的存档和设置,无法撤销。": "Retained saved data and settings will be permanently deleted. This cannot be undone.",
|
||||
"此操作影响以下应用:": "This operation affects these apps:",
|
||||
"容器内全部数据将永久删除。": "All data in this container will be permanently deleted.",
|
||||
"容器数据、关联 APK 缓存和 Steam 库条目将永久删除,历史备份保留。": "Container data, associated APK caches and Steam library entries will be permanently deleted. Historical backups will be kept.",
|
||||
"未检测到 Lepton,请先通过 Steam 安装。": "Lepton was not found. Install it through Steam first.",
|
||||
"搜索 APK": "Search APKs",
|
||||
"搜索名称、包名或容器": "Search name, package or container",
|
||||
@@ -933,6 +956,33 @@
|
||||
"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"
|
||||
"本机启动": "Launch locally",
|
||||
"手柄输入": "Gamepad input",
|
||||
"默认关闭。通过 Steam Input 将手柄转发为 Android 标准游戏手柄;也可选择 Frame 直连。开启后下次打开 APK 生效,关闭立即停止输入。": "Off by default. Forward Steam Input as a standard Android gamepad, or select Frame direct input. Enabling applies on the next APK launch; disabling stops input immediately.",
|
||||
"手柄输入来源": "Input source",
|
||||
"Steam Input(推荐)": "Steam Input (recommended)",
|
||||
"Frame 直连(兼容模式)": "Frame direct (compatibility)",
|
||||
"来源切换需关闭容器后重新启动。Steam Input 使用 Steam 的手柄配置,并支持 Steam 已识别的外接手柄。": "Close and restart the container to change sources. Steam Input uses Steam controller configurations and supports external controllers recognized by Steam.",
|
||||
"手柄震动": "Gamepad rumble",
|
||||
"将游戏的手柄震动反馈回传给当前输入手柄;应用失去 Android 前台状态或手柄断连时停止。": "Send game rumble to the current source controller; stop when the app loses Android foreground status or the controller disconnects.",
|
||||
"交互反馈": "Interaction feedback",
|
||||
"振动反馈": "Haptic feedback",
|
||||
"提示音反馈": "Sound feedback",
|
||||
"指向可交互控件时振动,不影响 APK 游戏振动。": "Vibrate when pointing at interactive controls. APK game rumble is unaffected.",
|
||||
"使用 Steam 提示音反馈聚焦和点击。": "Use Steam sounds for focus and clicks.",
|
||||
"导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。": "Export Framely, plugin, renderer and APK/Lepton logs with service status. Plugin settings and access password files are excluded.",
|
||||
"Android startup timed out; export logs and retry.": "Android startup timed out; export logs and retry.",
|
||||
"Android PackageManager did not become ready; export logs and retry.": "Android PackageManager did not become ready; export logs and retry.",
|
||||
"Android user did not unlock; export logs and retry.": "Android user did not unlock; export logs and retry.",
|
||||
"Android external storage did not mount; export logs and retry.": "Android external storage did not mount; export logs and retry.",
|
||||
"Android container stopped before becoming ready; export logs and retry.": "Android container stopped before becoming ready; export logs and retry.",
|
||||
"Android readiness probe did not respond; export logs and retry.": "Android readiness probe did not respond; export logs and retry.",
|
||||
"在此 Frame 上运行": "Run on this Frame",
|
||||
"远程设备 {0}": "Remote device {0}",
|
||||
"架构": "Architecture",
|
||||
"帧缓冲兼容修复": "Framebuffer compatibility fix",
|
||||
"仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。": "For specific games with sound but a black screen. Off by default. Restart the app after changing this option; system drivers and game files are unchanged.",
|
||||
"Invalid framebuffer compatibility setting": "Invalid framebuffer compatibility setting",
|
||||
"Framebuffer compatibility runtime is missing; install a complete Framely build": "Framebuffer compatibility runtime is missing; install a complete Framely build"
|
||||
}
|
||||
}
|
||||
+64
-2
@@ -3,6 +3,31 @@
|
||||
"locale": "zh-CN",
|
||||
"name": "简体中文",
|
||||
"messages": {
|
||||
"L2 / R2 按键触发行程": "L2 / R2 按键触发行程",
|
||||
"默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。": "默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。",
|
||||
"安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。": "安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。",
|
||||
"关闭容器": "关闭容器",
|
||||
"更新应用并保留现有存档和设置。": "更新应用并保留现有存档和设置。",
|
||||
"核对应用信息,选择合适的显示方式。": "核对应用信息,选择合适的显示方式。",
|
||||
"保留原容器": "保留原容器",
|
||||
"创建独立容器": "创建独立容器",
|
||||
"隐藏二维窗口,应用本身需要支持 VR。": "隐藏二维窗口,应用本身需要支持 VR。",
|
||||
"通过二维窗口显示并操作应用。": "通过二维窗口显示并操作应用。",
|
||||
"查看 APK 详情": "查看 APK 详情",
|
||||
"版本代码": "版本代码",
|
||||
"APK 包名与原应用不一致,请选择同一应用的更新文件。": "APK 包名与原应用不一致,请选择同一应用的更新文件。",
|
||||
"确认更新": "确认更新",
|
||||
"对应的 Steam 库条目、图标和封面也会移除。": "对应的 Steam 库条目、图标和封面也会移除。",
|
||||
"卸载选项": "卸载选项",
|
||||
"删除存档、设置及应用下载的数据。": "删除存档、设置及应用下载的数据。",
|
||||
"容器包含其他应用或状态未知,无法同时删除。": "容器包含其他应用或状态未知,无法同时删除。",
|
||||
"开启删除应用数据后可选择。": "开启删除应用数据后可选择。",
|
||||
"此容器仅包含该应用,同时清理 APK 缓存及关联文件。": "此容器仅包含该应用,同时清理 APK 缓存及关联文件。",
|
||||
"将永久删除应用及容器数据。历史备份保留。": "将永久删除应用及容器数据。历史备份保留。",
|
||||
"将永久删除存档和应用数据。容器保留。": "将永久删除存档和应用数据。容器保留。",
|
||||
"存档和设置会保留,之后可以重新安装。": "存档和设置会保留,之后可以重新安装。",
|
||||
"卸载并删除容器": "卸载并删除容器",
|
||||
"删除数据和容器": "删除数据和容器",
|
||||
"Application state is temporarily unavailable. Refresh or reconcile it in APK management.": "暂时无法确认应用状态,请在 APK 管理中刷新或核对。",
|
||||
"No enabled launch activity is currently available. Check the application in APK management.": "当前没有可用的启动入口,请在 APK 管理中检查。",
|
||||
"每个新应用使用独立容器,更新保留原容器。": "每个新应用使用独立容器,更新保留原容器。",
|
||||
@@ -478,7 +503,6 @@
|
||||
"日志已保存到:{0}": "日志已保存到:{0}",
|
||||
"日志包已生成,请查看浏览器下载。": "日志包已生成,请查看浏览器下载。",
|
||||
"日志包缺少数据": "日志包缺少数据",
|
||||
"导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。",
|
||||
"发现可用发行版本": "发现可用发行版本",
|
||||
"稍后": "稍后",
|
||||
"全部包含的分类": "全部包含的分类",
|
||||
@@ -509,6 +533,7 @@
|
||||
"将永久删除该应用保留的存档和设置,无法撤销。": "将永久删除该应用保留的存档和设置,无法撤销。",
|
||||
"此操作影响以下应用:": "此操作影响以下应用:",
|
||||
"容器内全部数据将永久删除。": "容器内全部数据将永久删除。",
|
||||
"容器数据、关联 APK 缓存和 Steam 库条目将永久删除,历史备份保留。": "容器数据、关联 APK 缓存和 Steam 库条目将永久删除,历史备份保留。",
|
||||
"未检测到 Lepton,请先通过 Steam 安装。": "未检测到 Lepton,请先通过 Steam 安装。",
|
||||
"搜索 APK": "搜索 APK",
|
||||
"搜索名称、包名或容器": "搜索名称、包名或容器",
|
||||
@@ -921,6 +946,43 @@
|
||||
"通过 Steam 启动": "通过 Steam 启动",
|
||||
"保存 Steam 启动设置": "保存 Steam 启动设置",
|
||||
"默认开启,自动包装已有和新安装的侧载应用。注册不启动应用或重启容器;首次通过 Steam 启动前请先关闭运行中的容器。": "默认开启,自动包装已有和新安装的侧载应用。注册不启动应用或重启容器;首次通过 Steam 启动前请先关闭运行中的容器。",
|
||||
"等待 Steam 注册,Steam 就绪后会自动重试。": "等待 Steam 注册,Steam 就绪后会自动重试。"
|
||||
"等待 Steam 注册,Steam 就绪后会自动重试。": "等待 Steam 注册,Steam 就绪后会自动重试。",
|
||||
"手柄输入": "手柄输入",
|
||||
"默认关闭。通过 Steam Input 将手柄转发为 Android 标准游戏手柄;也可选择 Frame 直连。开启后下次打开 APK 生效,关闭立即停止输入。": "默认关闭。通过 Steam Input 将手柄转发为 Android 标准游戏手柄;也可选择 Frame 直连。开启后下次打开 APK 生效,关闭立即停止输入。",
|
||||
"手柄输入来源": "手柄输入来源",
|
||||
"Steam Input(推荐)": "Steam Input(推荐)",
|
||||
"Frame 直连(兼容模式)": "Frame 直连(兼容模式)",
|
||||
"来源切换需关闭容器后重新启动。Steam Input 使用 Steam 的手柄配置,并支持 Steam 已识别的外接手柄。": "来源切换需关闭容器后重新启动。Steam Input 使用 Steam 的手柄配置,并支持 Steam 已识别的外接手柄。",
|
||||
"手柄震动": "手柄震动",
|
||||
"将游戏的手柄震动反馈回传给当前输入手柄;应用失去 Android 前台状态或手柄断连时停止。": "将游戏的手柄震动反馈回传给当前输入手柄;应用失去 Android 前台状态或手柄断连时停止。",
|
||||
"交互反馈": "交互反馈",
|
||||
"振动反馈": "振动反馈",
|
||||
"提示音反馈": "提示音反馈",
|
||||
"指向可交互控件时振动,不影响 APK 游戏振动。": "指向可交互控件时振动,不影响 APK 游戏振动。",
|
||||
"使用 Steam 提示音反馈聚焦和点击。": "使用 Steam 提示音反馈聚焦和点击。",
|
||||
"导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。": "导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。",
|
||||
"Android startup timed out; export logs and retry.": "Android 启动超时,请导出日志后重试。",
|
||||
"Android PackageManager did not become ready; export logs and retry.": "Android 包管理器尚未就绪,请导出日志后重试。",
|
||||
"Android user did not unlock; export logs and retry.": "Android 用户未解锁,请导出日志后重试。",
|
||||
"Android external storage did not mount; export logs and retry.": "Android 外部存储未挂载,请导出日志后重试。",
|
||||
"Android container stopped before becoming ready; export logs and retry.": "Android 容器在就绪前退出,请导出日志后重试。",
|
||||
"Android readiness probe did not respond; export logs and retry.": "Android 就绪状态查询无响应,请导出日志后重试。",
|
||||
"选择启动方式": "选择启动方式",
|
||||
"远程畅玩": "远程畅玩",
|
||||
"保存 Steam 入口设置": "保存 Steam 入口设置",
|
||||
"保留安装的 APK 到 Steam": "保留安装的 APK 到 Steam",
|
||||
"Steam UI operation failed": "Steam 界面操作失败,请保持 Steam 唤醒后重试。",
|
||||
"Remote game or host is no longer available": "远程游戏或设备已不可用,请检查电脑连接后重试。",
|
||||
"本机启动": "本机启动",
|
||||
"通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。": "通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。",
|
||||
"空容器": "空容器",
|
||||
"默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。": "默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。",
|
||||
"在此 Frame 上运行": "在此 Frame 上运行",
|
||||
"远程设备 {0}": "远程设备 {0}",
|
||||
"架构": "架构",
|
||||
"帧缓冲兼容修复": "帧缓冲兼容修复",
|
||||
"仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。": "仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。",
|
||||
"Invalid framebuffer compatibility setting": "无效的帧缓冲兼容设置",
|
||||
"Framebuffer compatibility runtime is missing; install a complete Framely build": "缺少帧缓冲兼容修复组件,请安装完整的 Framely 更新包。"
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -6,10 +6,21 @@ export function viewKey(){const p=location.pathname.split('/');return p[1]==='ma
|
||||
installKeyboard(params=>api('host.keyboard',{view:viewKey(),...params}));
|
||||
|
||||
|
||||
installHoverFeedback(()=>{void api('host.haptic',{view:viewKey()}).catch(console.error);});
|
||||
installHoverFeedback(feedback=>{void api('host.haptic',{view:viewKey(),feedback}).catch(console.error);});
|
||||
|
||||
export async function uploadChunk(upload:string,offset:number,chunk:Blob,signal:AbortSignal):Promise<void>{
|
||||
const r=await fetch(`/api/upload/${encodeURIComponent(upload)}/${offset}`,{method:'POST',body:chunk,signal});
|
||||
if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));
|
||||
if(v.result?.received!==offset+chunk.size)throw new Error('Upload offset mismatch');
|
||||
}
|
||||
|
||||
export async function exportDiagnosticLogs():Promise<string>{
|
||||
const v=await api<{name:string;path?:string;data?:string}>('diagnostics.export');
|
||||
if(v.path)return t('日志已保存到:{0}',{0:v.path});
|
||||
if(!v.data)throw Error(t('日志包缺少数据'));
|
||||
const bytes=Uint8Array.from(atob(v.data),c=>c.charCodeAt(0));
|
||||
const url=URL.createObjectURL(new Blob([bytes],{type:'application/zip'}));
|
||||
const link=document.createElement('a');link.href=url;link.download=v.name;
|
||||
document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);
|
||||
return t('日志包已生成,请查看浏览器下载。');
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import {IconFolder,IconInfoCircle} from '@tabler/icons-react';
|
||||
import {PluginImage} from './store';
|
||||
import {Select} from './localized-select';
|
||||
import {t} from './i18n';
|
||||
|
||||
type Metadata={name:string;package:string;version:string;versionCode:number;minSdk:number;icon?:string;activities:string[];abis:string[]};
|
||||
type Props={metadata:Metadata;size:string;previous?:Metadata;updating:boolean;containerName:string;running:boolean;displayMode:string;busy:boolean;error:string;onDisplayMode:(value:string)=>void;onStop:()=>void;onCancel:()=>void;onConfirm:()=>void};
|
||||
export function ApkInstallReview({metadata,size,previous,updating,containerName,running,displayMode,busy,error,onDisplayMode,onStop,onCancel,onConfirm}:Props){
|
||||
const mismatch=!!previous&&previous.package!==metadata.package;
|
||||
return <div className="apk-install-review">
|
||||
<h2>{t(updating?'确认更新 APK':'确认安装 APK')}</h2>
|
||||
<p className="apk-install-intro">{t(updating?'更新应用并保留现有存档和设置。':'核对应用信息,选择合适的显示方式。')}</p>
|
||||
<header className="apk-install-app"><PluginImage kind="lepton" name={metadata.name} src={metadata.icon} size={60}/><div><h3>{metadata.name}</h3><code>{metadata.package}</code><p>{t('版本')} {previous&&`${previous.version} → `}{metadata.version||t('未声明')} <span>·</span> {size}</p></div></header>
|
||||
<section className="apk-install-location" aria-label={t('目标容器')}><IconFolder size={23} aria-hidden="true"/><div><b>{t(updating?'保留原容器':'创建独立容器')}</b>{updating&&<code>{containerName}</code>}<small>{t('每个新应用使用独立容器,更新保留原容器。')}</small></div></section>
|
||||
<div className="apk-install-display"><div><b>{t('显示方式')}</b><small>{t(displayMode==='vr'?'隐藏二维窗口,应用本身需要支持 VR。':'通过二维窗口显示并操作应用。')}</small></div><Select label={t('显示方式')} value={displayMode} disabled={busy} onChange={onDisplayMode} options={[{value:'flat',label:t('平面窗口')},{value:'vr',label:t('VR 应用')}]}/></div>
|
||||
<details className="apk-install-technical"><summary>{t('查看 APK 详情')}</summary><dl className="apk-meta"><dt>{t('版本代码')}</dt><dd>{metadata.versionCode}</dd><dt>{t('最低 Android SDK')}</dt><dd>{metadata.minSdk||t('未声明')}</dd><dt>{t('架构')}</dt><dd>{metadata.abis.join(', ')||t('未声明')}</dd><dt>{t('启动入口')}</dt><dd>{metadata.activities.length||t('未发现启动入口')}</dd></dl></details>
|
||||
{updating&&<div className="apk-install-update-note"><IconInfoCircle size={21} aria-hidden="true"/><div><p>{t('同包名更新保留数据;签名不匹配或降级不会自动卸载重装。')}</p><small>{t('共享容器的备份需手动恢复;独立侧载容器支持一键恢复。')}</small></div></div>}
|
||||
{running&&<div className="apk-notice"><p>{t('安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。')}</p><button disabled={busy} onClick={onStop}>{t('关闭容器')}</button></div>}
|
||||
{mismatch&&<p className="error" role="alert">{t('APK 包名与原应用不一致,请选择同一应用的更新文件。')}</p>}
|
||||
{error&&<div className="error" role="alert"><p>{error}</p>{updating&&!running&&/container|backup|容器|备份/i.test(error)&&<button disabled={busy} onClick={onStop}>{t('关闭容器')}</button>}</div>}
|
||||
<footer><button disabled={busy} onClick={onCancel}>{t('取消')}</button><button className="primary" disabled={busy||mismatch} onClick={onConfirm}>{t(updating?'确认更新':'确认安装')}</button></footer>
|
||||
</div>;
|
||||
}
|
||||
@@ -3,3 +3,11 @@
|
||||
.apk-modal-backdrop{position:fixed;inset:0;z-index:40;background:#0009}.apk-modal-backdrop .modal{box-shadow:0 20px 80px #0008}
|
||||
|
||||
.apk-filters{align-items:center}.apk-filters input:not([type=checkbox]):not([type=file]){margin:0;flex:1;width:auto;min-width:0}.apk-filters>.framely-select{flex:0 0 260px;min-width:0}.apk-filters>.framely-select>button{min-height:56px}.apk-filters>button{flex:none}.apk-dialog label .framely-select{margin:12px 0}@media(max-width:700px){.apk-filters>.framely-select{flex:1 1 100%}}
|
||||
|
||||
.apk-input-source>.framely-select{flex:0 0 300px;min-width:0}@media(max-width:700px){.apk-input-source{flex-wrap:wrap}.apk-input-source>.framely-select{flex:1 1 100%}}
|
||||
|
||||
.apk-uninstall>h2{margin-bottom:8px}.apk-uninstall-intro{font-size:17px;line-height:1.6;color:var(--muted);margin-bottom:24px}.apk-uninstall-app{display:flex;align-items:center;gap:16px;margin:0 0 24px}.apk-uninstall-app>div{min-width:0}.apk-uninstall-app h3{margin:0 0 5px;font-size:23px}.apk-uninstall-app code{display:block;background:none;padding:0;font-size:15px;color:var(--muted);overflow-wrap:anywhere}.apk-uninstall-app small{display:block;margin-top:6px;font-size:15px}.apk-uninstall-options{border-top:1px solid var(--line);padding:8px 0}.apk-uninstall-options .switch-row{padding:16px 0;gap:20px}.apk-uninstall-options .switch-row small{font-size:16px;line-height:1.55}.apk-uninstall-result{display:flex;align-items:flex-start;gap:10px;padding:14px 16px;background:var(--control-hover);border-radius:5px;font-size:17px;line-height:1.55}.apk-uninstall-result p{margin:0}.apk-uninstall-result svg{margin-top:2px}.apk-uninstall-result small{display:block;font-size:15px;margin-top:4px}.apk-uninstall-result.destructive{color:#efa9a8}.apk-uninstall-result.destructive small{color:inherit}.apk-uninstall footer button{white-space:nowrap}.apk-uninstall footer .danger{font-weight:600;background:#362627;border-color:#895b5b}.apk-uninstall footer .danger:hover:not(:disabled){background:#493032}@media(max-width:700px){.apk-uninstall-app{align-items:flex-start}.apk-uninstall footer{display:grid;grid-template-columns:1fr}.apk-uninstall footer .danger{grid-row:1}.apk-uninstall-options .switch-row{gap:12px}}
|
||||
|
||||
.apk-install-review>h2{margin-bottom:8px}.apk-install-intro{font-size:17px;line-height:1.6;color:var(--muted);margin-bottom:24px}.apk-install-app{display:flex;align-items:center;gap:16px;margin-bottom:24px}.apk-install-app>div{min-width:0}.apk-install-app h3{font-size:23px;margin:0 0 5px}.apk-install-app code,.apk-install-location code{background:none;padding:0;font-size:15px;color:var(--muted)}.apk-install-app p{margin:6px 0 0;font-size:16px;color:var(--muted)}.apk-install-app p span{margin:0 6px}.apk-install-location{display:flex;gap:12px;padding:18px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.apk-install-location svg{flex:none;margin-top:3px;color:var(--muted)}.apk-install-location b,.apk-install-display b{font-size:19px;font-weight:500}.apk-install-location small,.apk-install-display small{display:block;font-size:16px;line-height:1.55;color:var(--muted);margin-top:5px}.apk-install-location code{margin-top:6px;overflow-wrap:anywhere}.apk-install-display{display:flex;gap:20px;align-items:center;padding:22px 0}.apk-install-display>div:first-child{flex:1;min-width:0}.apk-install-display>.framely-select{flex:0 0 200px}.apk-install-technical{font-size:17px;color:var(--muted)}.apk-install-technical summary{cursor:pointer;padding:8px 0}.apk-install-technical .apk-meta{font-size:16px;margin:16px 0}.apk-install-technical dd{color:var(--text)}.apk-install-update-note{display:flex;gap:10px;margin-top:20px;font-size:16px;line-height:1.55;color:var(--muted)}.apk-install-update-note svg{flex:none;margin-top:3px}.apk-install-update-note p{margin:0}.apk-install-update-note small{display:block;font-size:15px;margin-top:6px}.apk-install-review footer button{white-space:nowrap}@media(max-width:700px){.apk-install-app{align-items:flex-start}.apk-install-display{flex-wrap:wrap;gap:12px}.apk-install-display>.framely-select{flex:1 1 100%}.apk-install-review footer{display:grid;grid-template-columns:1fr}.apk-install-review footer .primary{grid-row:1}}
|
||||
|
||||
.apk-trigger-setting{display:flex;align-items:center;gap:24px;padding:18px 0}.apk-trigger-setting>div:first-child{flex:1;min-width:0}.apk-trigger-setting label{margin:0}.apk-trigger-setting b{font-size:19px}.apk-trigger-setting small{display:block;font-size:17px;color:var(--muted);line-height:1.55;margin-top:5px}.apk-trigger-controls{display:flex;align-items:center;gap:16px;flex:0 0 390px}.apk-trigger-controls>div{display:flex;align-items:center;gap:12px;flex:1;min-width:0}.apk-trigger-controls input[type=range]{width:100%;min-width:0;accent-color:var(--blue);padding:0;margin:0;min-height:44px;cursor:pointer}.apk-trigger-controls output{font-size:18px;min-width:48px;text-align:right;font-variant-numeric:tabular-nums}.apk-trigger-controls button{margin:0}@media(max-width:700px){.apk-trigger-setting{flex-wrap:wrap;gap:12px}.apk-trigger-setting>div:first-child{flex-basis:100%}.apk-trigger-controls{flex:1 1 100%}}
|
||||
+16
-11
@@ -9,13 +9,16 @@ import {Select} from './localized-select';
|
||||
import {Switch,SwitchRow} from './switch';
|
||||
import {t} from './i18n';
|
||||
import './apk-manager.css';
|
||||
import {ApkUninstall} from './apk-uninstall';
|
||||
import {ApkInstallReview} from './apk-install-review';
|
||||
import {GamepadTriggerSetting} from './gamepad-trigger-setting';
|
||||
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 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;framebufferCompatibility?:boolean};
|
||||
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;retainSteamEntries?:boolean;gamepadEnabled?:boolean;autoStopContainer?:boolean;stopContainerOnClose?:boolean};
|
||||
type Data={apps:App[];containers:Container[];warnings:string[];roots:string[];available:boolean;retainSteamEntries?:boolean;gamepadEnabled?:boolean;gamepadSource?:string;gamepadRumble?:boolean;gamepadTriggerThreshold?:number;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};
|
||||
type Confirm={operation:string;app?:App;container?:Container;backup?:string;items?:string[];title:string;description:string;purge?:boolean;deleteContainer?: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 启动设置','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':'移除数据位置'};
|
||||
@@ -25,7 +28,7 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
|
||||
useEffect(()=>{if(view==='cleanup')void loadCleanup();},[view]);
|
||||
const input=useRef<HTMLInputElement>(null),controller=useRef<AbortController|null>(null),selection=useRef<{app:string;uninstall:boolean}|null>(null),alive=useRef(true),busyRef=useRef(false);
|
||||
const updateJob=(v:Job)=>{if(alive.current)setJob(v);};
|
||||
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');if(!active.job||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean;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);}}}
|
||||
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(t(value.error??'操作失败'));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.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();};},[]);
|
||||
@@ -36,13 +39,14 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
|
||||
async function inspect(file:File|string,selectedApp:App|null=updating){if(!(typeof file==='string'?file:file.name).toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob(typeof file==='string'?{id:'local',kind:'apk',phase:'inspecting',received:0,cancellable:false}:{id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{let result;if(typeof file==='string'){const response=await fetch('/host/files/apk-inspect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[file]}),signal:c.signal});if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));const data=await response.json();if(data.error)throw new Error(t(data.error));result=await waitJob(data.result.job,updateJob);}else{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});result=await runJob('apk.inspect.start',{upload},updateJob);}if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setReview(result.result);setDisplayMode((selectedApp?.showWindow??!result.result.metadata.vr)?'flat':'vr');setTarget(selectedApp?.context??'');setJob(null);}catch(e){setJob(null);setError(String(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}}
|
||||
function choose(a?:App){setUpdating(a??null);if(input.current)openLocalFileInput(input.current,path=>{if(alive.current)void inspect(path,a??null);});}
|
||||
async function perform(operation:string,params:Record<string,unknown>={}){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(String(e));await refresh();}finally{setBusy(false);busyRef.current=false;}}
|
||||
function askUninstall(a:App){setConfirm({operation:a.installed?'uninstall':'purge',app:a,title:a.installed?t('卸载应用'):t('清除保留数据'),description:a.installed?t('默认保留存档和设置。此操作只影响该应用。'):t('将永久删除该应用保留的存档和设置,无法撤销。'),purge:!a.installed});}
|
||||
function canDeleteContainer(a:App){const members=data.apps.filter(other=>other.context===a.context);const container=data.containers.find(c=>c.id===a.context);return !!container&&!container.steam&&members.length===1&&members[0].id===a.id&&a.stateKnown!==false&&!a.pending;}
|
||||
function askUninstall(a:App){setError('');setConfirm({operation:a.installed?'uninstall':'purge',app:a,title:a.installed?t('卸载应用'):t('清除保留数据'),description:'',purge:!a.installed,deleteContainer:!a.installed&&canDeleteContainer(a)});}
|
||||
async function loadCleanup(){try{const items=await api<Cleanup[]>('apk.cleanup.list');setCleanup(items);setSelected([]);}catch(e){setError(String(e));}}
|
||||
async function showLog(a:App|string){try{setLog(await api('apk.logs',{app:typeof a==='string'?a:a.id}));}catch(e){setError(String(e));}}
|
||||
function downloadLog(){if(!log)return;const url=URL.createObjectURL(new Blob([log.text],{type:'text/plain;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download=log.name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
||||
const listed=data.apps.filter(a=>(filter==='all'||(filter==='installed'?(a.installed||a.stateKnown===false):(!a.installed&&a.stateKnown!==false)))&&!a.steam&&`${a.metadata.name} ${a.metadata.package} ${a.context}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const contextFor=(id:string)=>data.containers.find(c=>c.id===id);
|
||||
function containerAsk(c:Container,operation:string){setConfirm({operation,container:c,title:t(operation==='container.delete'?'删除容器':operation==='container.stop'?'停止容器':'启动容器'),description:`${t('此操作影响以下应用:')} ${data.apps.filter(a=>a.context===c.id).map(a=>a.metadata.name).join(', ')||c.name}${operation==='container.delete'?`\n${t('容器内全部数据将永久删除。')}`:''}`});}
|
||||
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('容器数据、关联 APK 缓存和 Steam 库条目将永久删除,历史备份保留。')}`:''}`});}
|
||||
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));
|
||||
@@ -53,14 +57,15 @@ 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('保留安装的 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==='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('手柄输入')} description={t('默认关闭。通过 Steam Input 将手柄转发为 Android 标准游戏手柄;也可选择 Frame 直连。开启后下次打开 APK 生效,关闭立即停止输入。')} checked={!!data.gamepadEnabled} disabled={busy||loading||!data.available&&!data.gamepadEnabled} onChange={enabled=>void perform('gamepad.settings',{enabled})}/><div className="switch-row apk-input-source"><div><b>{t('手柄输入来源')}</b><small>{t('来源切换需关闭容器后重新启动。Steam Input 使用 Steam 的手柄配置,并支持 Steam 已识别的外接手柄。')}</small></div><Select label={t('手柄输入来源')} value={data.gamepadSource??'steam'} disabled={busy||loading} onChange={source=>void perform('gamepad.settings',{enabled:!!data.gamepadEnabled,source})} options={[{value:'steam',label:t('Steam Input(推荐)')},{value:'frame',label:t('Frame 直连(兼容模式)')}]}/></div><GamepadTriggerSetting value={data.gamepadTriggerThreshold??80} busy={busy||loading} onSave={triggerThreshold=>void perform('gamepad.settings',{enabled:!!data.gamepadEnabled,triggerThreshold})}/><SwitchRow label={t('手柄震动')} description={t('将游戏的手柄震动反馈回传给当前输入手柄;应用失去 Android 前台状态或手柄断连时停止。')} checked={data.gamepadRumble??true} disabled={busy||loading} onChange={rumble=>void perform('gamepad.settings',{enabled:!!data.gamepadEnabled,rumble})}/></>}
|
||||
{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 kind="lepton" 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}/>{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 启动,可由 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)}
|
||||
{details&&!confirm&&!review&&!log&&!job&&dialog(<><header className="apk-dialog-heading"><PluginImage kind="lepton" 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><SwitchRow label={t('帧缓冲兼容修复')} description={t('仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。')} checked={details.framebufferCompatibility??false} disabled={busy} onChange={enabled=>void perform('settings',{app:details.id,framebufferCompatibility:enabled})}/><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(<ApkInstallReview metadata={review.metadata} size={bytes(review.bytes)} previous={updating?.metadata} displayMode={displayMode} onDisplayMode={setDisplayMode} containerName={installContainer?containerTitle(installContainer):target} updating={!!updating} running={!!installContainer?.running&&!installContainer.steam} busy={busy} error={error} onStop={()=>installContainer&&containerAsk(installContainer,'container.stop')} onCancel={()=>void discard()} onConfirm={()=>void perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true})}/>,t('安装 APK'))}
|
||||
{confirm?.app&&!job&&['uninstall','purge'].includes(confirm.operation)&&dialog(<ApkUninstall app={confirm.app} size={bytes(confirm.app.size)} purge={!!confirm.purge} deleteContainer={!!confirm.deleteContainer&&!!confirm.purge&&canDeleteContainer(confirm.app)} exclusive={canDeleteContainer(confirm.app)} busy={busy} error={error} onPurge={purge=>setConfirm(c=>c?.app?{...c,purge,deleteContainer:purge&&canDeleteContainer(c.app)}:c)} onDeleteContainer={deleteContainer=>setConfirm(c=>c?{...c,deleteContainer}:c)} onCancel={()=>setConfirm(null)} onConfirm={()=>void perform(confirm.operation,{app:confirm.app!.id,purge:confirm.purge,deleteContainer:!!confirm.deleteContainer&&!!confirm.purge&&canDeleteContainer(confirm.app!),approve:true})}/>,confirm.title)}
|
||||
{confirm&&!job&&!(confirm.app&&['uninstall','purge'].includes(confirm.operation))&&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>}{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 日志'))}
|
||||
</section>;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import React,{useState} from 'react';
|
||||
import {exportDiagnosticLogs} from './api';
|
||||
import {IconAlertTriangle} from '@tabler/icons-react';
|
||||
import {PluginImage} from './store';
|
||||
import {SwitchRow} from './switch';
|
||||
import {t} from './i18n';
|
||||
|
||||
type Props={
|
||||
app:{installed:boolean;metadata:{name:string;package:string;icon?:string}};
|
||||
size:string;purge:boolean;deleteContainer:boolean;exclusive:boolean;
|
||||
busy:boolean;error:string;onPurge:(value:boolean)=>void;
|
||||
onDeleteContainer:(value:boolean)=>void;onCancel:()=>void;onConfirm:()=>void;
|
||||
};
|
||||
|
||||
export function ApkUninstall({app,size,purge,deleteContainer,exclusive,busy,error,onPurge,onDeleteContainer,onCancel,onConfirm}:Props){
|
||||
const [exporting,setExporting]=useState(false),[diagnostic,setDiagnostic]=useState('');
|
||||
async function exportLogs(){setExporting(true);try{setDiagnostic(await exportDiagnosticLogs());}catch(e){setDiagnostic(String(e));}finally{setExporting(false);}}
|
||||
const message=deleteContainer?'将永久删除应用及容器数据。历史备份保留。':purge?'将永久删除存档和应用数据。容器保留。':'存档和设置会保留,之后可以重新安装。';
|
||||
return <div className="apk-uninstall">
|
||||
<h2>{t(app.installed?'卸载应用':'清除保留数据')}</h2>
|
||||
<p className="apk-uninstall-intro">{t('对应的 Steam 库条目、图标和封面也会移除。')}</p>
|
||||
<header className="apk-uninstall-app">
|
||||
<PluginImage kind="lepton" name={app.metadata.name} src={app.metadata.icon} size={60}/>
|
||||
<div><h3>{app.metadata.name}</h3><code>{app.metadata.package}</code><small>{t('空间占用')} {size}</small></div>
|
||||
</header>
|
||||
<div className="apk-uninstall-options" role="group" aria-label={t('卸载选项')}>
|
||||
<SwitchRow label={t('删除应用数据')} description={t('删除存档、设置及应用下载的数据。')} checked={purge} disabled={busy||!app.installed} onChange={onPurge}/>
|
||||
<SwitchRow label={t('删除容器')} description={t(!exclusive?'容器包含其他应用或状态未知,无法同时删除。':!purge?'开启删除应用数据后可选择。':'此容器仅包含该应用,同时清理 APK 缓存及关联文件。')} checked={deleteContainer} disabled={busy||!exclusive||!purge} onChange={onDeleteContainer}/>
|
||||
</div>
|
||||
<div className={`apk-uninstall-result ${purge?'destructive':''}`} role="status">
|
||||
{purge&&<IconAlertTriangle size={22} aria-hidden="true"/>}<p>{t(message)}{purge&&<small>{t('永久删除的数据无法撤销。')}</small>}</p>
|
||||
</div>
|
||||
{error&&<p className="error" role="alert">{error}</p>}
|
||||
{diagnostic&&<p role="status">{diagnostic}</p>}
|
||||
<footer>{error&&<button disabled={busy||exporting} onClick={()=>void exportLogs()}>{t(exporting?'正在导出日志…':'导出日志')}</button>}<button disabled={busy} onClick={onCancel}>{t('取消')}</button><button className="danger" disabled={busy} onClick={onConfirm}>{error?t('重试'):t(app.installed?(deleteContainer?'卸载并删除容器':'卸载'):(deleteContainer?'删除数据和容器':'删除'))}</button></footer>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import {IconApps,IconBrandAndroid,IconBrandSteam,IconDeviceDesktop,IconPuzzle} from '@tabler/icons-react';
|
||||
|
||||
// One neutral placeholder per application type, independent of its display name.
|
||||
export function AppPlaceholder({kind='plugin',size=28}:{kind?:string;size?:number}){
|
||||
const Glyph=kind==='plugin'?IconPuzzle:kind==='steam'?IconBrandSteam:kind==='lepton'||kind==='apk'?IconBrandAndroid:kind==='desktop'?IconDeviceDesktop:IconApps;
|
||||
return <Glyph className="app-placeholder" data-app-kind={kind} size={size} stroke={1.5} aria-hidden="true" style={{color:'#b7bdc4',flexShrink:0,pointerEvents:'none'}}/>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import React,{useEffect,useId,useState} from 'react';
|
||||
import {t} from './i18n';
|
||||
export function GamepadTriggerSetting({value,busy,onSave}:{value:number;busy:boolean;onSave:(value:number)=>void}){
|
||||
const[draft,setDraft]=useState(value);const id=useId();
|
||||
useEffect(()=>setDraft(value),[value]);
|
||||
return <div className="apk-trigger-setting"><div><label htmlFor={id}><b>{t('L2 / R2 按键触发行程')}</b></label><small id={`${id}-description`}>{t('默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。')}</small></div><div className="apk-trigger-controls"><div><input id={id} type="range" min={1} max={100} step={1} value={draft} disabled={busy} aria-describedby={`${id}-description`} aria-valuetext={`${draft}%`} onChange={e=>setDraft(Number(e.target.value))}/><output htmlFor={id}>{draft}%</output></div><button disabled={busy||draft===value} onClick={()=>onSave(draft)}>{t('保存')}</button></div></div>;
|
||||
}
|
||||
+6
-3
@@ -17,7 +17,7 @@
|
||||
.launch-disc{position:relative;display:flex;align-items:center;justify-content:center;width:96px;height:96px;flex:none;border-radius:50%;background:#242424b8;color:#dcebf6;box-shadow:inset 0 1px 1px #edf7ff38,inset 0 -2px 3px #06122150,0 9px 20px #08162533;overflow:hidden;transition:transform .23s var(--launch-ease),box-shadow .23s var(--launch-ease)}
|
||||
.launch-disc img{position:absolute;inset:0;display:block;width:100%;height:100%;max-width:none;max-height:none;object-fit:cover;object-position:center;border:0;pointer-events:none;filter:none}
|
||||
.launch-disc.no-art{background:#303030bb}
|
||||
.launch-monogram{font-size:32px;font-weight:520;letter-spacing:-.035em;color:#d9e9f3;line-height:1;pointer-events:none}
|
||||
.launch-disc>.app-placeholder{width:50%;height:50%;}
|
||||
.launch-icon:hover:not(:disabled) .launch-disc,.launch-icon:focus-visible .launch-disc{transform:translateY(-3px) scale(1.055);box-shadow:inset 0 1px 2px #edf7ff60,0 14px 28px #07172b40}
|
||||
.launch-icon:active:not(:disabled) .launch-disc{transform:scale(.97);box-shadow:inset 0 1px 2px #edf7ff38,0 5px 12px #07172b28}
|
||||
.launch-name{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-align:center;font-size:16px;font-weight:450;line-height:1.28;letter-spacing:.005em;width:100%;max-width:164px;height:47px;color:#eff4f8;text-shadow:0 1px 3px #0c172cd9,0 2px 10px #0c172c70;pointer-events:none}
|
||||
@@ -40,7 +40,7 @@
|
||||
.launcher-shell button:disabled{opacity:.4;cursor:default}.launcher-shell button:not(.launch-icon):focus-visible{outline:2px solid var(--launch-blue);outline-offset:4px}
|
||||
.launcher-shell .modal-backdrop{position:absolute;inset:0;background:#00000065;z-index:5;display:grid;place-items:center}.launcher-shell .modal{position:relative;transform:none;inset:auto;width:560px;max-width:90%;margin:0;border-radius:22px}
|
||||
@keyframes launcher-menu-in{from{opacity:0;translate:0 6px}to{opacity:1;translate:0 0}}
|
||||
@media(max-width:900px){.launcher-shell{padding:65px 22px 80px 96px}.launcher-grid{width:calc(100% - 176px)}.launcher-page,.launcher-adjacent-page{gap:8px}.launch-icon{width:92px;height:92px;min-height:92px}.launch-disc{width:82px;height:82px}.launch-name{font-size:15px;height:39px}.launch-monogram{font-size:26px}.launcher-error,.launcher-shell>.banner{left:96px;right:22px}.launcher-pages{left:50%}}
|
||||
@media(max-width:900px){.launcher-shell{padding:65px 22px 80px 96px}.launcher-grid{width:calc(100% - 176px)}.launcher-page,.launcher-adjacent-page{gap:8px}.launch-icon{width:92px;height:92px;min-height:92px}.launch-disc{width:82px;height:82px}.launch-name{font-size:15px;height:39px}.launcher-error,.launcher-shell>.banner{left:96px;right:22px}.launcher-pages{left:50%}}
|
||||
@media(prefers-reduced-transparency:reduce){.launch-disc{background:#242424}.launcher-tools{background:#242424}.launch-menu{background:#181818}}
|
||||
@media(prefers-reduced-motion:reduce){.launch-disc,.launch-favorite,.launcher-category-label,:is(.launcher-filters,.launcher-shortcuts) button,.launcher-category-active,.launcher-pages button span{transition:none}.launch-menu,.launch-item{animation:none}}
|
||||
.launcher-settings label{display:flex;align-items:center;justify-content:space-between;padding:16px 0;width:100%;gap:24px;border-bottom:1px solid #ffffff10}.launcher-settings label small{display:block;color:#a1adb7;margin-top:6px}.launcher-settings input[type=checkbox]{width:26px;height:26px}.launcher-settings input[type=number]{width:100px;padding:10px;background:#141414;color:#fff;border:1px solid #44515c;border-radius:8px;font:inherit}
|
||||
@@ -199,4 +199,7 @@
|
||||
.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}
|
||||
.launcher-shell .launch-choice{width:600px;padding:28px;border:1px solid #ffffff20;background:rgba(24,24,26,.96);box-shadow:inset 0 1px 0 #ffffff0c,0 24px 72px #00000055;max-height:90%;overflow:auto}
|
||||
.launch-choice-heading{display:flex;align-items:center;gap:16px;margin-bottom:26px}.launch-choice-heading>div{flex:1;min-width:0}.launch-choice-heading h2{margin:0 0 4px;font-size:25px;overflow-wrap:anywhere}.launch-choice-heading p{margin:0;color:var(--muted);font-size:18px}.launch-choice-heading .icon-button{align-self:flex-start;min-width:40px;min-height:40px;padding:8px}
|
||||
.launch-choice-targets{display:flex;flex-direction:column;gap:10px}.launch-choice-target{width:100%;min-height:86px;padding:16px 18px;text-align:left;gap:18px;border:1px solid #ffffff12;border-radius:13px;background:#ffffff05;transition:background 140ms,border-color 140ms}.launch-choice-target:hover:not(:disabled){background:#ffffff10;border-color:#ffffff28}.launch-choice-symbol{display:flex;align-items:center;justify-content:center;width:44px;height:44px;flex:none;color:#d6d9de}.launch-choice-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.launch-choice-copy b{font-size:21px;font-weight:550;overflow-wrap:anywhere}.launch-choice-copy small{font-size:16px}.launch-choice-arrow{color:#b7bbc2}
|
||||
@media(prefers-reduced-motion:reduce){.launch-choice-target{transition:none}}
|
||||
+9
-8
@@ -1,3 +1,5 @@
|
||||
import {SteamLaunchChoice} from './steam-launch-choice';
|
||||
import {AppPlaceholder} from './app-placeholder';
|
||||
import {SwitchRow} from './switch';
|
||||
import React,{useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
|
||||
import {api} from './api';
|
||||
@@ -12,7 +14,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={remote?:boolean;remoteClient?:string;id:number;name:string;icon?:string;iconFit?:string;kind?:string};
|
||||
type SteamApp={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name: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})));
|
||||
@@ -20,9 +22,8 @@ function launcherCells(total:number){
|
||||
function LauncherArtwork({src,name,kind,fit}:{src?:string|null;name:string;kind:string;fit?:string}){
|
||||
const [failed,setFailed]=useState(false);
|
||||
useEffect(()=>setFailed(false),[src]);
|
||||
const initials=name.trim().split(/\s+/).map(word=>Array.from(word)[0]).slice(0,2).join('').toUpperCase();
|
||||
return <span className={`launch-disc ${kind} ${fit==='cover'?'cover-art':''} ${src&&!failed?'has-art':'no-art'}`}>
|
||||
{src&&!failed?<img src={src} alt={t('{0}图标',{0:name})} draggable={false} onError={()=>setFailed(true)}/>:<span className="launch-monogram" aria-hidden="true">{initials}</span>}
|
||||
{src&&!failed?<img src={src} alt={t('{0}图标',{0:name})} draggable={false} onError={()=>setFailed(true)}/>:<AppPlaceholder kind={kind} size={48}/>}
|
||||
</span>;
|
||||
}
|
||||
export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConfig;refresh:()=>Promise<void>}){
|
||||
@@ -32,7 +33,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
|
||||
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={remote?:boolean;remoteClient?:string;key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
|
||||
type LaunchItem={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name: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>{
|
||||
@@ -70,7 +71,7 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
|
||||
// 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 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,remoteTargets:a.remoteTargets,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]);
|
||||
@@ -107,8 +108,8 @@ 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 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 openItem(a:LaunchItem){setMenu(null);if(a.key.startsWith('steam:')&&a.remoteClient&&(!a.remote||(a.remoteTargets?.length??0)>1)){setLaunchChoice(a);return;}void run(a.key,()=>launch(a),true);}
|
||||
function chooseLaunch(client?:string){if(!launchChoice)return;const item={...launchChoice,remoteClient:client};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}>());
|
||||
@@ -128,5 +129,5 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
|
||||
{a.launchUnavailable&&<small role="status">{t(a.launchUnavailable)}</small>}{busyKeys.has(a.key)&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&¤t+panView.direction>=0&¤t+panView.direction<pages&&<div className="launcher-adjacent-page" aria-hidden="true" style={{translate:`${panView.offset+panView.direction*panView.width}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}}>{items.slice((current+panView.direction)*pageSize,(current+panView.direction+1)*pageSize).map((a,index)=>{const count=Math.min(pageSize,items.length-(current+panView.direction)*pageSize),{row,column:slot}=launcherCells(count)[index];return <div key={a.key} className="launch-item" style={{gridColumn:`${slot} / span 4`,gridRow:`${1+row*2} / span 2`}}><span className="launch-icon"><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/>{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>}
|
||||
{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>;
|
||||
{launchChoice&&<SteamLaunchChoice name={launchChoice.name} icon={launchChoice.icon} local={!launchChoice.remote} targets={launchChoice.remoteTargets?.length?launchChoice.remoteTargets:[{client:launchChoice.remoteClient!,name:''}]} onChoose={chooseLaunch} onClose={()=>setLaunchChoice(null)}/>}{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>;
|
||||
}
|
||||
+5
-8
@@ -22,11 +22,11 @@ import {InstallReview} from './install-review';
|
||||
import {MemoryLimit} from './memory-limit';
|
||||
import {installScrollbars} from '../../sdk/src/scrollbars';
|
||||
installScrollbars();
|
||||
import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';if(location.pathname==='/launcher')document.documentElement.classList.add('launcher-view');if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view');
|
||||
import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey,exportDiagnosticLogs}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';if(location.pathname==='/launcher')document.documentElement.classList.add('launcher-view');if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view');
|
||||
export type Manifest=Presentation&{id:string;name:string;version:string;author:string;description:string;backend?:{runAs:string;autostart:boolean;memoryLimitMiB?:number};lifecycle?:{runAs?:string};dependencies?:Record<string,unknown>;optionalDependencies?:Record<string,unknown>;conflicts?:Record<string,string>;exclusiveResources?:string[];engines?:{framely?:string};ui:{quickPage?:string;windows:Record<string,unknown>;launch?:Record<string,{type:string;window?:string}>;launcherActions?:{id:string;label:string;target:{type:string;entry?:string;window?:string}}[]}};
|
||||
export type Plugin={manifest:Manifest;enabled:boolean;favorite:boolean;order:number;source?:string;error?:string};
|
||||
type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:boolean};
|
||||
type Status={agreement?:AgreementState;version:string;build?:string;systemUpdate?:any;previousRelease?:string;database:{launcher?:LauncherConfig;launcherFavorites?:string[];steamFavorites?:number[];launcherOrder?:string[];notificationSettings?:NotificationConfig;language?:string;proxy?:ProxyConfig;networkPanel?:NetworkPanel;plugins:Record<string,Plugin>;sources:Source[];subscriptions?:Subscription[];sourceOrigins?:Record<string,{manual:boolean;subscriptions:Record<string,string>}>;safeMode:boolean;updateSource?:{url:string}|null;updateChannel?:'stable'|'testing'};notifications:NotificationEntry[];inbox?:NotificationEntry[];notificationBadge?:number;runtime?:Record<string,{phase:string;detail:any}>;running:string[]};
|
||||
type Status={agreement?:AgreementState;version:string;build?:string;systemUpdate?:any;previousRelease?:string;database:{feedback?:{haptics:boolean;sounds:boolean};launcher?:LauncherConfig;launcherFavorites?:string[];steamFavorites?:number[];launcherOrder?:string[];notificationSettings?:NotificationConfig;language?:string;proxy?:ProxyConfig;networkPanel?:NetworkPanel;plugins:Record<string,Plugin>;sources:Source[];subscriptions?:Subscription[];sourceOrigins?:Record<string,{manual:boolean;subscriptions:Record<string,string>}>;safeMode:boolean;updateSource?:{url:string}|null;updateChannel?:'stable'|'testing'};notifications:NotificationEntry[];inbox?:NotificationEntry[];notificationBadge?:number;runtime?:Record<string,{phase:string;detail:any}>;running:string[]};
|
||||
const managerMode=location.pathname==='/manager';
|
||||
const empty:Status={version:'0.4.0',database:{plugins:{},sources:[],safeMode:false},notifications:[],running:[]};
|
||||
|
||||
@@ -43,10 +43,7 @@ function App(){
|
||||
function switchManager(section:ManagerSection){let target: string=({plugins:'installed',apk:'apk',terminal:'terminal',files:'files',settings:'settings'})[section];try{const remembered=localStorage.getItem('framely.manager.'+section);if(remembered&§ionFor(remembered)===section&&managerTab('#'+remembered)===remembered)target=remembered;}catch{}setPage(null);setManage(null);setTab(target);history.replaceState(null,'',location.pathname+location.search+'#'+target);}
|
||||
async function exportLogs(){
|
||||
setExportingLogs(true);setLogExport('');setError('');
|
||||
try{const v=await api<{name:string;path?:string;data?:string}>('diagnostics.export');
|
||||
if(v.path)setLogExport(t('日志已保存到:{0}',{0:v.path}));
|
||||
else if(v.data){const bytes=Uint8Array.from(atob(v.data),c=>c.charCodeAt(0));const url=URL.createObjectURL(new Blob([bytes],{type:'application/zip'}));const link=document.createElement('a');link.href=url;link.download=v.name;document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);setLogExport(t('日志包已生成,请查看浏览器下载。'));}
|
||||
else throw Error(t('日志包缺少数据'));
|
||||
try{setLogExport(await exportDiagnosticLogs());
|
||||
}catch(e){setError(String(e));}finally{setExportingLogs(false);}
|
||||
}
|
||||
async function refreshLanguages(){const v=await api('language.list');localePacks.current=v.packs??[];setLanguageFiles(v.invalidFiles??[]);await refresh();}
|
||||
@@ -92,7 +89,7 @@ function App(){
|
||||
if(parts[1]==='launcher')return <Launcher plugins={status.database.plugins} safeMode={status.database.safeMode} config={status.database.launcher} favorites={status.database.steamFavorites??[]} localFavorites={status.database.launcherFavorites??[]} order={status.database.launcherOrder??[]} refresh={refresh}/>;
|
||||
if(parts[1]==='notifications')return <NotificationCenter items={status.notifications} plugins={status.database.plugins} safeMode={status.database.safeMode} refresh={refresh}/>;
|
||||
if(windowPlugin){const p=status.database.plugins[windowPlugin];return <div className="app plugin-window">{p?.enabled&&!status.database.safeMode?<PluginFrame plugin={windowPlugin} entry={parts[3]}/>:<div className="empty"><p>{t("插件已停用或正在启动。")}</p><button onClick={()=>void action(()=>api('window.close',{plugin:windowPlugin,window:parts[3]}))}>{t("关闭")}</button></div>}{error&&<p className="error">{error}</p>}</div>;}
|
||||
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong>{managerMode?<><ManagerSwitcher section={managerSection} onChange={switchManager}/>{managerSection==='plugins'&&<button className="primary" onClick={()=>setImporting(true)}><Icon name="download"/>{t("安装插件")}</button>}<button className="icon-button" aria-label={t("关闭管理窗口")} onClick={()=>void api('host.manager.close')}><Icon name="close"/></button></>:<><button className="icon-button quick-library-button" aria-label={t("插件库")} onClick={()=>void action(()=>api('host.manager.open',{page:'catalog'}))}><Icon name="store"/><span>{t("插件库")}</span></button><button className="icon-button" aria-label={t("关闭菜单")} onClick={()=>void api('host.menu.close')}><Icon name="close"/></button></>}</header>{error&&<p className="error" role="alert">{error}<button onClick={()=>setError('')}>×</button></p>}{status.database.safeMode&&<p className="banner">{t("安全模式:第三方插件暂停运行。")}</p>}
|
||||
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong>{managerMode?<>{managerSection==='plugins'&&<button className="primary manager-install-button" onClick={()=>setImporting(true)}><Icon name="download" size={20}/>{t("安装插件")}</button>}<ManagerSwitcher section={managerSection} onChange={switchManager}/><button className="icon-button" aria-label={t("关闭管理窗口")} onClick={()=>void api('host.manager.close')}><Icon name="close"/></button></>:<><button className="icon-button quick-library-button" aria-label={t("插件库")} onClick={()=>void action(()=>api('host.manager.open',{page:'catalog'}))}><Icon name="store"/><span>{t("插件库")}</span></button><button className="icon-button" aria-label={t("关闭菜单")} onClick={()=>void api('host.menu.close')}><Icon name="close"/></button></>}</header>{error&&<p className="error" role="alert">{error}<button onClick={()=>setError('')}>×</button></p>}{status.database.safeMode&&<p className="banner">{t("安全模式:第三方插件暂停运行。")}</p>}
|
||||
{selected?<><div className="page-title"><button onClick={()=>setPage(null)}>{t("‹ 返回")}</button><strong>{selected.manifest.name}</strong><button onClick={()=>void action(()=>api('plugin.favorite',{plugin:page,favorite:!selected.favorite}))}>{selected.favorite?'★':'☆'}</button>{managerMode&&<button onClick={()=>setManage(selected)}>{t("管理")}</button>}</div>{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?<PluginFrame plugin={page!}/>:<p className="empty">{t("插件已停用、处于安全模式或没有快捷页面。")}</p>}</>:<div className={`workspace ${managerMode&&(tab==='terminal'||tab==='files')?'tools-workspace':''}`}><nav aria-label={t("主导航")}>{(managerMode?(managerSection==='terminal'||managerSection==='files'?[]:managerSection==='settings'?[['settings',t('通用'),'settings'],['launcher-settings',t('启动台'),'launcher'],['updates',t('更新'),'refresh'],['about',t('关于'),'info']]:apkPanel?[['apk',t('应用'),'download'],['apk-containers',t('容器'),'plugins'],['apk-cleanup',t('数据清理'),'settings'],['apk-settings',t('设置'),'settings']]:[['installed',t("已安装"),'plugins'],['catalog',t("插件库"),'download'],['sources',t("插件源导航"),'link'],['notification-settings',t("通知设置导航"),'bell']]):[['favorites',t("常用"),'star'],['installed',t("已安装"),'plugins'],['notifications',t("通知"),'bell'],['settings',t("设置"),'settings']]).map(([key,label,icon])=><button className={tab===key?'active':''} aria-current={tab===key?'page':undefined} key={key} onClick={()=>key==='catalog'?void loadCatalog():(setTab(key),history.replaceState(null,'',location.pathname+location.search+'#'+key))}>{icon==='bell'?<IconBell size={24} stroke={1.8}/>:icon==='launcher'?<IconLayoutGrid size={24} stroke={1.8}/>:<Icon name={icon as any}/>}<span>{label}{key==='notifications'&&!!status.notificationBadge&&<small className="notification-count">{status.notificationBadge!>99?'99+':status.notificationBadge}</small>}</span></button>)}{managerMode&&<div className="nav-foot"><span>{apkPanel?t("Lepton 侧载管理"):<>{plugins.length}{t("个已安装插件")}</>}</span><small>{status.version}</small></div>}</nav><main className={`content ${managerMode&&tab==='files'?'file-content':managerMode&&tab==='terminal'?'terminal-content':''}`}>
|
||||
{(tab==='favorites'||tab==='installed')&&<>
|
||||
{managerMode&&<div className="list-heading"><div><h1>{t("已安装")}</h1><p>{plugins.length}{t("个插件,")}{plugins.filter(p=>p.enabled).length}{t("个已启用")}</p></div><div className="search-field"><Icon name="search"/><input aria-label={t("搜索已安装插件")} placeholder={t("搜索插件")} value={query} onChange={e=>setQuery(e.target.value)}/></div></div>}
|
||||
@@ -112,7 +109,7 @@ function App(){
|
||||
{tab==='sources'&&managerMode&&<><div className="list-heading"><div><h1>{t("插件源")}</h1><p>{t("管理插件目录及订阅。")}</p></div></div><section className="settings-card"><Sources origins={status.database.sourceOrigins} sources={status.database.sources} save={async s=>{await api('sources.save',{sources:s});await refresh();}}/></section><Subscriptions subscriptions={status.database.subscriptions??[]} refresh={refresh}/></>}
|
||||
{tab==='updates'&&managerMode&&<SystemUpdates status={status} refresh={refresh}/>}
|
||||
{tab==='about'&&managerMode&&<><div className="list-heading"><div><h1>{t("关于 Framely")}</h1></div></div><div className="about"><Brand/><b>Framely</b><small>{status.build??status.version}</small></div><section className="settings-card"><h2>{t("用户协议与隐私声明")}</h2><div className="row"><button onClick={()=>setReviewAgreement(true)}>{t("查看协议与隐私声明")}</button></div></section></>}
|
||||
{tab==='settings'&&(!managerMode?<div className="quick-settings"><button className="setting-entry" onClick={()=>void api('host.manager.open')}><span className="setting-symbol"><Icon name="plugins" size={28}/></span><span><b>{t("管理插件")}</b><small>{t("安装、更新与管理插件源")}</small></span><span className="forward">›</span></button><section className="settings-card"><h2>{t("运行模式")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/></section><LanguageSettings selectionOnly value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><button className="setting-entry quick-update-entry" onClick={()=>void action(()=>api('host.manager.open',{page:'updates'}))}><span className="setting-symbol"><Icon name="refresh" size={28}/></span><span><b>{t("检查更新")}</b></span><span className="forward">›</span></button><div className="about"><Brand/><b>Framely</b><small>{t("版本")}{status.build??status.version}</small></div></div>:<><div className="list-heading"><div><h1>{t("设置")}</h1><p>{t("运行模式与诊断。")}</p></div></div><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><NetworkSettings config={status.database.networkPanel??{enabled:true,port:15915}} refresh={refresh}/><ProxySettings config={status.database.proxy??{http:"",github:""}} refresh={refresh}/><section className="settings-card"><h2>{t("恢复与诊断")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/><p className="sub">{t("导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。")}</p><div className="row"><button onClick={()=>void action(async()=>setLogs(JSON.stringify(await api('status'),null,2)))}>{t("诊断信息")}</button><button disabled={exportingLogs||busy} onClick={()=>void exportLogs()}>{exportingLogs?t("正在导出日志…"):t("导出日志")}</button></div>{logExport&&<p role="status">{logExport}</p>}</section><section className="settings-card"><h2>{t("运行用户")}</h2><p className="sub">{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}</p></section></>)}
|
||||
{tab==='settings'&&(!managerMode?<div className="quick-settings"><button className="setting-entry" onClick={()=>void api('host.manager.open')}><span className="setting-symbol"><Icon name="plugins" size={28}/></span><span><b>{t("管理插件")}</b><small>{t("安装、更新与管理插件源")}</small></span><span className="forward">›</span></button><section className="settings-card"><h2>{t("运行模式")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/></section><LanguageSettings selectionOnly value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><button className="setting-entry quick-update-entry" onClick={()=>void action(()=>api('host.manager.open',{page:'updates'}))}><span className="setting-symbol"><Icon name="refresh" size={28}/></span><span><b>{t("检查更新")}</b></span><span className="forward">›</span></button><div className="about"><Brand/><b>Framely</b><small>{t("版本")}{status.build??status.version}</small></div></div>:<><div className="list-heading"><div><h1>{t("设置")}</h1><p>{t("运行模式与诊断。")}</p></div></div><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><NetworkSettings config={status.database.networkPanel??{enabled:true,port:15915}} refresh={refresh}/><section className="settings-card"><h2>{t("交互反馈")}</h2>{(['haptics','sounds'] as const).map(key=><SwitchRow key={key} label={t(key==='haptics'?'振动反馈':'提示音反馈')} description={t(key==='haptics'?'指向可交互控件时振动,不影响 APK 游戏振动。':'使用 Steam 提示音反馈聚焦和点击。')} checked={status.database.feedback?.[key]??(key==='haptics')} disabled={busy} onChange={enabled=>void action(()=>api('feedback.settings.save',{haptics:true,sounds:false,...status.database.feedback,[key]:enabled}))}/>)}</section><ProxySettings config={status.database.proxy??{http:"",github:""}} refresh={refresh}/><section className="settings-card"><h2>{t("恢复与诊断")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/><p className="sub">{t("导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。")}</p><div className="row"><button onClick={()=>void action(async()=>setLogs(JSON.stringify(await api('status'),null,2)))}>{t("诊断信息")}</button><button disabled={exportingLogs||busy} onClick={()=>void exportLogs()}>{exportingLogs?t("正在导出日志…"):t("导出日志")}</button></div>{logExport&&<p role="status">{logExport}</p>}</section><section className="settings-card"><h2>{t("运行用户")}</h2><p className="sub">{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}</p></section></>)}
|
||||
{tab==='catalog'&&<Store target={catalogTarget} plugins={status.database.plugins} sources={status.database.sources} onInspect={inspect} onSources={()=>setTab('sources')} busy={busy}/>}
|
||||
</main></div>}
|
||||
{importing&&<div className="modal" role="dialog" aria-modal="true" aria-label={t("安装插件")}><button className="icon-button modal-close" aria-label={t("关闭导入")} onClick={()=>setImporting(false)}><Icon name="close"/></button><InstallInput inspect={async p=>{await inspect(p);setImporting(false);}} busy={busy}/></div>}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function PluginFrame({plugin,entry='quick',actionContext,onActionDone}:{p
|
||||
else result=await api('window.close',{plugin,window:entry});
|
||||
}
|
||||
else if(op==='ui.visibility.get')result=await api('ui.visibility.get',{view:viewKey()});
|
||||
else if(op==='haptic')result=await api('host.haptic',{view:viewKey()});
|
||||
else if(op==='haptic')result=await api('host.haptic',{view:viewKey(),feedback:params?.feedback});
|
||||
else if(op==='keyboard')result=await api('host.keyboard',{...params,view:viewKey()});
|
||||
else{const method=methods[op];if(!method)throw new Error(t('插件请求不支持的能力'));result=await api(method,{...params,plugin,...(op==='window.open'&&actionContext?{launchContext:actionContext}:{})});}
|
||||
if(live)ref.current?.contentWindow?.postMessage({channel:'framely.reply',id,result},'*');
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import React,{useEffect,useRef} from 'react';
|
||||
import {IconArrowRight,IconDeviceDesktop,IconDeviceDesktopShare,IconX} from '@tabler/icons-react';
|
||||
import {PluginImage} from './store';
|
||||
import {t} from './i18n';
|
||||
export type RemoteLaunchTarget={client:string;name:string};
|
||||
export function SteamLaunchChoice({name,icon,local,targets,onChoose,onClose}:{name:string;icon?:string|null;local:boolean;targets:RemoteLaunchTarget[];onChoose:(client?:string)=>void;onClose:()=>void}){
|
||||
const dialog=useRef<HTMLDivElement>(null);
|
||||
useEffect(()=>{const previous=document.activeElement as HTMLElement|null;dialog.current?.querySelector<HTMLButtonElement>('.launch-choice-target')?.focus();return()=>previous?.focus();},[]);
|
||||
return <div className="modal-backdrop" onPointerDown={e=>e.stopPropagation()} onClick={e=>e.stopPropagation()}><div ref={dialog} className="modal launch-choice" role="dialog" aria-modal="true" aria-labelledby="launch-choice-title" onKeyDown={e=>{if(e.key!=='Tab')return;const buttons=Array.from(dialog.current?.querySelectorAll<HTMLButtonElement>('button')??[]);const first=buttons[0],last=buttons.at(-1);if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus();}}}>
|
||||
<header className="launch-choice-heading"><PluginImage kind="steam" src={icon} name={name} size={56}/><div><h2 id="launch-choice-title">{name}</h2><p>{t('选择启动方式')}</p></div><button className="icon-button" aria-label={t('取消')} onClick={onClose}><IconX size={22}/></button></header>
|
||||
<div className="launch-choice-targets">{local&&<button className="launch-choice-target" onClick={()=>onChoose()}><span className="launch-choice-symbol"><IconDeviceDesktop size={26} stroke={1.6}/></span><span className="launch-choice-copy"><b>{t('本机启动')}</b><small>{t('在此 Frame 上运行')}</small></span><IconArrowRight className="launch-choice-arrow" size={22} stroke={1.6}/></button>}{targets.map(target=><button key={target.client} className="launch-choice-target" onClick={()=>onChoose(target.client)}><span className="launch-choice-symbol"><IconDeviceDesktopShare size={26} stroke={1.6}/></span><span className="launch-choice-copy"><b>{target.name||t('远程设备 {0}',{0:target.client})}</b><small>{t('远程畅玩')}</small></span><IconArrowRight className="launch-choice-arrow" size={22} stroke={1.6}/></button>)}</div>
|
||||
</div></div>;
|
||||
}
|
||||
+3
-2
@@ -6,6 +6,7 @@ import {Select} from './localized-select';
|
||||
import {api} from './api';
|
||||
import {RunUserBadge,RunUserNotice,RuntimeUser} from './run-user';
|
||||
import {Icon} from './icons';
|
||||
import {AppPlaceholder} from './app-placeholder';
|
||||
export type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:boolean};
|
||||
export type Presentation={authorUrl?:string|null;documentationUrl?:string|null;homepage?:string|null;icon?:string|null;details?:string;tags?:string[];screenshots?:string[];changelog?:string;dependencies?:Record<string,string|{version:string;source:string}>;optionalDependencies?:Record<string,string|{version:string;source:string}>;conflicts?:Record<string,string>;exclusiveResources?:string[]};
|
||||
export type Entry=Presentation&{id:string;name:string;author:string;description:string;version:string;apiVersion:number;compatible?:boolean;engines?:{framely?:string};url:string;sha256:string;runAs?:RuntimeUser|null};
|
||||
@@ -18,10 +19,10 @@ export async function runJob(method:string,params:unknown={},onUpdate?:(job:Job)
|
||||
export async function waitJob(id:string,onUpdate?:(job:Job)=>void,statusMethod='job.status'):Promise<{job:string;result:any}>{
|
||||
const started={job:id};
|
||||
const deadline=Date.now()+35*60*1000;
|
||||
while(Date.now()<deadline){const job=await api<Job>(statusMethod,{job:started.job});onUpdate?.(job);if(job.phase==='done')return{job:started.job,result:job.result};if(job.phase==='failed')throw new Error(job.error??t("操作失败"));if(job.phase==='cancelled')throw new Error(t("操作已取消"));await new Promise(r=>setTimeout(r,300));}
|
||||
while(Date.now()<deadline){const job=await api<Job>(statusMethod,{job:started.job});onUpdate?.(job);if(job.phase==='done')return{job:started.job,result:job.result};if(job.phase==='failed')throw new Error(t(job.error??"操作失败"));if(job.phase==='cancelled')throw new Error(t("操作已取消"));await new Promise(r=>setTimeout(r,300));}
|
||||
throw new Error(t("操作超时,请重新检查任务状态"));
|
||||
}
|
||||
export function PluginImage({src,name,size=48}:{src?:string|null;name:string;size?:number}){const[failed,setFailed]=useState(false);useEffect(()=>setFailed(false),[src]);return <span className="plugin-avatar" style={{width:size,height:size}}>{src&&!failed?<img src={src} alt={t("{0}图标", {"0": name})} onError={()=>setFailed(true)}/>:<Icon name="plugins" size={Math.round(size*.55)}/>}</span>}
|
||||
export function PluginImage({src,name,size=48,kind='plugin'}:{src?:string|null;name:string;size?:number;kind?:string}){const[failed,setFailed]=useState(false);useEffect(()=>setFailed(false),[src]);return <span className="plugin-avatar" style={{width:size,height:size}}>{src&&!failed?<img src={src} alt={t("{0}图标", {"0": name})} onError={()=>setFailed(true)}/>:<AppPlaceholder kind={kind} size={Math.round(size*.55)}/>}</span>}
|
||||
function WebLink({url,children,className}:{url:string;children:React.ReactNode;className?:string}){
|
||||
const[error,setError]=useState(''),[busy,setBusy]=useState(false);
|
||||
return <><a className={className} href={url} aria-disabled={busy} onClick={e=>{e.preventDefault();e.stopPropagation();if(busy)return;setBusy(true);setError('');void runJob('host.external.open.start',{url}).catch(e=>setError(String(e.message??e))).finally(()=>setBusy(false));}}>{children}</a>{error&&<span className="link-error" role="alert">{error}</span>}</>;
|
||||
|
||||
@@ -193,3 +193,5 @@ button{overflow-wrap:anywhere}
|
||||
|
||||
/* Tint layers preserve the shell alpha; do not add opaque scrolling surfaces. */
|
||||
@media(prefers-reduced-transparency:reduce){:root{--bg:#121212;--surface:#1a1a1a;--overlay-bg:#121212;--manager-content-tint:#1a1a1c;--manager-chrome-tint:#202022}}
|
||||
|
||||
.manager .shell-header .manager-install-button{min-height:44px;padding:8px 14px;font-size:18px;gap:8px;white-space:nowrap;flex:none}
|
||||
+1
-1
@@ -7,7 +7,7 @@ export async function uploadFile(file:Pick<File,'size'|'slice'>,signal:AbortSign
|
||||
try{
|
||||
signal.throwIfAborted();const start=await transport.start(file.size);ticket=start.upload;
|
||||
signal.throwIfAborted();
|
||||
if(!Number.isSafeInteger(start.chunkSize)||start.chunkSize<=0||start.chunkSize>4*1024*1024)throw new Error('Invalid upload chunk size');
|
||||
if(!Number.isSafeInteger(start.chunkSize)||start.chunkSize<=0||start.chunkSize>16*1024*1024)throw new Error('Invalid upload chunk size');
|
||||
const worker=async()=>{
|
||||
try{
|
||||
while(nextOffset<file.size){
|
||||
|
||||
Reference in new issue
Block a user