Compare commits

...
28 Commits
Author SHA1 Message Date
toorux e8e5ab3d77 Release v0.6.0 2026-10-07 20:00:50 +08:00
toorux dc1bd07145 Release APK operation locks explicitly and isolate integration tests 2026-10-07 20:00:50 +08:00
toorux 4005216a42 Enlarge file manager icons across all grid sizes 2026-10-07 19:33:44 +08:00
toorux 425983a5fb Redesign media viewer with floating bottom controls and blank-space dismissal 2026-10-07 19:32:54 +08:00
toorux cb67ac5607 Add fullscreen media viewer and download unsupported files on open 2026-10-07 19:10:55 +08:00
toorux 6cb70b8098 Add lazy image and video thumbnails to file icon views 2026-10-07 18:59:36 +08:00
toorux 79db4a80fc Show complete folder names in file sidebar tree 2026-10-07 18:44:54 +08:00
toorux 6a0e302fd6 Remember folder sorting and add global file icon views 2026-10-07 18:43:37 +08:00
toorux cbf285d957 Update bilingual README feature overview 2026-10-07 18:37:10 +08:00
toorux a3d751541a Release v0.5.1-preview.4 2026-10-07 18:12:30 +08:00
toorux 9c7dc06bad Add opt-in per-app framebuffer compatibility fix 2026-10-07 18:07:23 +08:00
toorux be36694719 feat(gamepad): add configurable trigger button threshold defaulting to 80 percent 2026-10-07 17:43:35 +08:00
toorux 786c8b7928 Increase upload chunks to 16 MiB 2026-10-07 16:43:15 +08:00
toorux 2c7863dcfb Release Framely 0.5.1-preview.3 2026-10-07 16:36:13 +08:00
toorux 7e374a669a Emit Android L2 and R2 button events alongside trigger axes 2026-10-07 16:27:04 +08:00
toorux b1d0c739b2 Recover renamed Steam APK shortcuts after partial registration 2026-10-07 16:21:27 +08:00
toorux 73fa970021 Allocate independent APK contexts around legacy occupied names 2026-10-07 16:01:03 +08:00
toorux 73d2d4289b Release Framely 0.5.1-preview.2 2026-10-07 15:54:58 +08:00
toorux 3b7382e1a0 Pin installed release version in update notification tests 2026-10-07 15:49:00 +08:00
toorux d24aa708a7 Reduce launcher rendering scale to 1.5x 2026-10-07 15:37:38 +08:00
toorux c64bdfb374 Release Framely 0.5.1-preview.1 2026-10-07 15:25:20 +08:00
toorux 1fc2e32f26 Handle empty Lepton sm output when checking Android external storage 2026-10-07 15:24:19 +08:00
toorux 3d2899fb6e Use quiet Steam keyboard clicks for UI sound feedback 2026-10-07 14:32:57 +08:00
toorux 6589f85ec6 List named Remote Play hosts in redesigned localized launch chooser 2026-10-07 14:32:50 +08:00
toorux e8c406ffca Disable UI sound feedback by default while preserving preferences 2026-10-07 14:31:56 +08:00
toorux 16fb76c579 Move compact plugin install button before manager tabs 2026-10-07 14:16:47 +08:00
toorux c75896541c Release Framely 0.5.0-preview.2 2026-10-07 14:12:48 +08:00
toorux 36e1c04758 Fix native host poll regression test request order 2026-10-07 14:11:15 +08:00
63 changed files with 1735 additions and 136 deletions

No files matched your search

Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.5.0-preview.1"
version = "0.6.0"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.5.0-preview.1"
version = "0.6.0"
edition = "2021"
license = "AGPL-3.0-only"
+10 -2
View File
@@ -7,9 +7,17 @@
[简体中文](README.zh-CN.md)
> Framely is still under rapid development. Preview releases may change frequently; features and interactions are still being refined.
> Framely is still under rapid development. Features and interfaces may change between preview releases. Android app and game compatibility depends on Lepton and the app itself.
A React plugin manager for Steam Frame, with a Rust core service, a separate CEF/OpenVR host, a network management panel, and a desktop installer. Manage plugins, sources, windows, notifications, and updates.
Framely provides a unified spatial launcher for Steam Frame and a management panel available both inside the headset and in a PC browser.
- **Unified launcher**: launch Android apps in Lepton, Linux desktop applications, local Steam games, and games available through Steam Remote Play.
- **APK sideloading and management**: install, update, and uninstall APKs, manage application containers and data, and provide controller input mapping and rumble feedback for Android games that support controllers.
- **Plugin installation and management**: install and manage Framely plugins, browse the plugin library, and extend Frame with additional features.
- **Remote management from your PC**: access Frame's management panel in a PC browser to manage files, APKs, and plugins. Upload, download, and edit files, and preview common image and video formats.
- **Web terminal**: use an interactive terminal running on Frame, execute shell commands, and connect to other devices with `ssh`. The web terminal does not require an SSH connection to Frame.
Framely uses a Rust core service and a separate CEF/OpenVR host, with a desktop installer for setup. It also manages plugin sources, windows, notifications, and updates.
![Framely spatial launcher](docs/images/launcher.png)
+10 -2
View File
@@ -7,9 +7,17 @@
[English](README.md)
> Framely 目前仍在快速迭代中,Preview 版本可能频繁更新,功能与交互仍在持续完善。
> Framely 目前仍在快速迭代中,功能和界面可能随预览版本调整。安卓应用与游戏的兼容性取决于 Lepton 和应用本身。
Steam Frame 的 React 插件管理器:Rust 核心服务、独立 CEF/OpenVR 宿主、网络管理面板和电脑端安装器。可管理插件、来源、窗口、通知和更新。
Framely 为 Steam Frame 提供统一的空间启动台,以及可在头显内和 PC 浏览器中使用的管理面板。
- **统一启动入口**:启动 Lepton 中的安卓应用、Linux 桌面程序、Steam 本地游戏和 Steam 远程畅玩游戏。
- **APK 侧载与管理**:安装、更新和卸载 APK,管理应用容器与数据;为支持手柄的安卓游戏提供手柄输入映射和振动反馈。
- **插件安装与管理**:安装和管理 Framely 插件,通过插件库发现并扩展功能。
- **PC 网页远程管理**:在 PC 浏览器中访问 Frame 的管理面板,管理设备文件、APK 和插件,支持文件上传、下载、编辑及常见图片和视频预览。
- **网页终端**:通过管理面板操作 Frame 的交互式终端,支持 Shell 命令和使用 `ssh` 连接其他设备,无需先通过 SSH 连接 Frame。
Framely 使用 Rust 核心服务和独立的 CEF/OpenVR 宿主,并提供电脑端安装器,也可管理插件来源、窗口、通知和更新。
![Framely 空间启动台](docs/images/launcher.png)
+6 -6
View File
@@ -38,8 +38,8 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven
Device and installer share the repository with independent versions/tags/workflows:
- Device: root Cargo version, `v<version>` (currently `v0.4.2-preview.12`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.8`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
- Device: root Cargo version, `v<version>` (currently `v0.6.0`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.10`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
Versions need not match or ship together. Update the relevant Cargo.lock with version changes. Installer package/macOS versions use the installer version.
@@ -47,11 +47,11 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
```bash
# After committing and pushing the intended source:
git tag v0.4.2-preview.12
git push origin v0.4.2-preview.12
git tag v0.6.0
git push origin v0.6.0
# Independent installer release:
git tag installer-v0.4.1-preview.8
git push origin installer-v0.4.1-preview.8
git tag installer-v0.4.1-preview.10
git push origin installer-v0.4.1-preview.10
```
Prerelease suffixes create GitHub Prereleases without Latest. Installer releases are never Latest. Only stable device releases become Latest, keeping default installer/update URLs on the device product. The desktop installer filters device archive names and does not mistake ARM64 installer packages for device runtimes. Download desktop installers from their explicit installer tags.
+6
View File
@@ -174,3 +174,9 @@ Container list headings show installed application names; the data path identifi
### 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.
+9 -3
View File
@@ -12,7 +12,7 @@ Plugin contains installed plugins, the plugin library, sources and notification
## Interaction feedback
In **Settings → General → Interaction feedback**, Haptic feedback and Sound feedback are enabled by default and 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 navigation and activation sounds for focus and clicks. It requires the local Steam sound files and audio runtime; no sound files are bundled or downloaded.
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
@@ -34,7 +34,7 @@ Use the address bar, breadcrumbs, parent button or expandable folder tree. Quick
Drag the divider between the sidebar and file list to resize it; the width is remembered in this browser and limited automatically on narrow windows. Quick access stays in a fixed-height upper section. The folder tree fills the remaining sidebar height; both lists scroll independently, with their headings kept visible. Opening a folder in the file list or address bar expands the tree to its location and highlights it.
The file list provides hidden-file visibility, sorting, checkboxes for multiple selection, current-folder filtering and cancellable recursive filename search. Recursive search does not follow directory links. Lists are paged in groups of 200; recursive results are limited to 5,000 matches and 500,000 visited entries. Media contents are never scanned to create thumbnails.
The file list provides hidden-file visibility, sorting, checkboxes for multiple selection, current-folder filtering and cancellable recursive filename search. Recursive search does not follow directory links. Lists are paged in groups of 200; recursive results are limited to 5,000 matches and 500,000 visited entries. Media contents are not scanned in the background.
**Actions** provides copy, cut, paste, rename, delete, properties, permission editing, compression, extraction and opening files. Right-click a file or folder to open a menu at the pointer; right-clicking a selected item preserves the multi-selection. Right-click the empty list area for new file/folder, paste, upload and refresh actions. Escape or clicking elsewhere closes the menu. Symbolic links have an arrow marker and their target in Properties. Copy preserves links; deleting a link does not delete its target. Special filesystem objects cannot be copied or archived.
@@ -62,8 +62,14 @@ 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.
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. Full previews and downloads read content after explicit selection; icon views read only visible media to generate lazy thumbnails. There is no background directory media scan. Test media are generated fixtures; development and validation do not read or upload private 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.
File sorting (name, size or modification time, including ascending/descending order) is remembered separately for each folder. The **View** menu switches between a list and small, medium or large icons; this choice applies to all folders. Preferences are saved on Frame and restored when reopening the panel in a browser or headset. Icon views lazily generate thumbnails for visible images and videos using browser decoding, with at most two concurrent jobs. Videos use a frame around one second. Thumbnails are cached in bounded memory while the page remains open and refreshed when file metadata changes. Scrolling out of view cancels pending work; unsupported formats or decoding failures retain the file-type icon. No FFmpeg or whole-folder media scan is used.
Clicking an image or video opens a full-panel viewer. Use the wheel or zoom buttons to zoom, drag the content to pan, and use Previous/Next or the arrow keys to browse the image/video files in the current displayed list. Fit to window resets the view; Esc closes it. Video playback controls remain available. If browser decoding fails, download the file from the viewer. UTF-8 text up to 5 MiB still opens in the editor; binary files, unsupported encodings and larger files download directly when clicked.
The viewer controls are grouped in a floating capsule centered at the bottom, with the filename below. Clicking blank space closes the viewer; clicking media or controls and dragging to pan do not.
+6 -6
View File
@@ -48,8 +48,8 @@ CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.12`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.8`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.6.0`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.10`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
@@ -59,12 +59,12 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
```bash
# 提交并推送代码后,选择需要发布的产品
git tag v0.4.2-preview.12
git push origin v0.4.2-preview.12
git tag v0.6.0
git push origin v0.6.0
# 独立发布安装器,不触发 Framely 构建
git tag installer-v0.4.1-preview.8
git push origin installer-v0.4.1-preview.8
git tag installer-v0.4.1-preview.10
git push origin installer-v0.4.1-preview.10
```
带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。
+6
View File
@@ -148,3 +148,9 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
### 卸载并保留容器
保留应用数据的卸载使用 Android 包管理器就绪检查,不再强制等待用户解锁或外部存储挂载。删除数据仍等待这些条件。Framely 在后台启动已停止的容器,不显示容器桌面;需要时只修复目标 APK 的登记。启动、包管理器、解锁、存储和容器退出分别报错。Android 确认卸载成功后才移除 Steam 入口,先保存 Android 卸载结果,重试 Steam 清理不会重复卸载。启动或卸载失败保留应用记录、Steam 绑定和应用数据。错误弹窗提供重试与导出日志。无法启动的容器仍需诊断,不会自动删除容器或直接修改 Android 包数据库。
在“APK 管理 → 设置”中可调节 L2/R2 按键触发行程,范围 1%~100%,默认 80%。两个扳机共用该设置,Steam Input 和 Frame 直连均适用,不改变模拟轴的完整行程。保存后立即应用到正在运行的转发进程;松开阈值比按下低 5 个百分点,设置低于 5% 时在归零后松开。
## 单应用图形兼容
在**管理应用 → 高级设置**中,“帧缓冲兼容修复”默认关闭。仅为有声音但黑屏的游戏开启(已在当前 Mesa/Zink 驱动下验证洛克王国),修改后需重启应用。GLES 修复层只在所选应用中,于绑定纹理后检查帧缓冲状态,不改变其他应用、系统驱动、游戏文件或存档。关闭后,下次启动恢复正常渲染路径。这是针对特定问题的兼容措施,并非通用画质或性能优化。
+9 -3
View File
@@ -12,7 +12,7 @@
## 交互反馈
在 **设置 → 通用 → 交互反馈** 中,振动反馈和提示音反馈默认开启,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机的导航和点击音效,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
在 **设置 → 通用 → 交互反馈** 中,振动反馈默认开启,提示音反馈默认关闭,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机键盘音效,以较低音量反馈聚焦和点击,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
## 终端
@@ -32,7 +32,7 @@
通过地址栏、面包屑、上级按钮或可展开文件夹树导航。快速访问包含主目录、下载、挂载存储、文件系统根目录、回收站及自定义文件夹。星标按钮添加当前目录,旁边按钮可改名、排序、移除;暂不可用的目录保留记录,打开时显示访问错误。
文件列表支持隐藏文件、排序、多选、当前目录过滤及可取消的递归文件名搜索。递归搜索不跟随目录链接;列表每页 200 项,递归搜索最多返回 5,000 个匹配项、访问 500,000 项。不扫描媒体内容生成缩略图。
文件列表支持隐藏文件、排序、多选、当前目录过滤及可取消的递归文件名搜索。递归搜索不跟随目录链接;列表每页 200 项,递归搜索最多返回 5,000 个匹配项、访问 500,000 项。不在后台扫描媒体内容。
“操作”提供复制、剪切、粘贴、重命名、删除、属性、权限修改、压缩、解压及打开。右键文件会在指针处打开操作菜单。符号链接显示箭头,属性显示目标;复制保留链接,删除链接不会删除目标。特殊文件不能复制或压缩。
@@ -64,8 +64,14 @@
## 访问与隐私
终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 WebSocket 严格匹配 Origin,插件沙箱不可调用。接口运行于 Steam 用户会话服务,不借用 root 本体执行文件操作。预览与下载仅在用户主动选择后读取内容;测试媒体为生成的样本,不自动读取或上传 Arcturus 录制文件。
终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 WebSocket 严格匹配 Origin,插件沙箱不可调用。接口运行于 Steam 用户会话服务,不借用 root 本体执行文件操作。完整预览与下载在用户主动选择后读取内容;图标视图仅为当前可见的媒体按需读取并生成缩略图,不后台扫描目录媒体。测试媒体为生成的样本,开发及验证不读取或上传私人 Arcturus 录制文件。
## 导出诊断日志
**设置 → 通用 → 导出日志** 和安装器的 **导出日志** 使用同一收集流程。日志包包含近期 APK 操作日志、Lepton 已保存的 Android 日志,以及运行中容器的有限快照:Android 启动、用户解锁、存储挂载、包管理器就绪状态和近期 main/system/crash 日志。以配置的 Steam 会话用户检查无 root 容器,不启动已停止的容器。应用状态只导出摘要,排除令牌和图标;不读取 APK、存档、媒体或原始内存转储。单文件及总大小受限,保留近期输出;缺失来源、截断和命令超时记录在 `report.json`。日志可能包含应用信息,分享前请检查。
文件排序(名称、大小或修改时间,以及升序/降序)按文件夹分别记忆。“展示方式”可切换列表、小图标、中图标和大图标,此选项对所有文件夹统一生效。偏好保存在 Frame,重新打开浏览器或头显管理面板时恢复。图标视图仅为当前可见的图片和视频按需生成缩略图,使用浏览器解码,最多同时处理两项。视频截取约第 1 秒的画面。缩略图使用有上限的页面内存缓存,文件元数据变化后重新生成;滚出可视区域会取消待处理任务。不支持的格式或解码失败时保留文件类型图标。不使用 FFmpeg,也不扫描整个文件夹的媒体。
点击图片或视频会打开全屏遮罩预览。使用滚轮或缩放按钮缩放,拖动画面移动,通过上一个/下一个或方向键切换当前展示列表中的图片、视频。“适应窗口”恢复初始视图,Esc 关闭;视频保留播放控制。浏览器无法解码时,可在预览中下载文件。5 MiB 以内的 UTF-8 文本仍在编辑器中打开;二进制文件、不支持的编码及更大的文件点击后直接下载。
查看器操作集中在底部居中的悬浮胶囊条中,文件名在下方居中显示。点击空白区域关闭;点击媒体、操作按钮或拖动画面不会误关闭。
+23
View File
@@ -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
+32
View File
@@ -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;
}
+7 -4
View File
@@ -21,7 +21,8 @@ static void stop(int){stopped=1;}
#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
@@ -58,6 +59,7 @@ int main(int argc,char**argv){
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_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;
@@ -69,8 +71,9 @@ 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]{};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;}if(commands.size()>128)break;}
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.
@@ -101,7 +104,7 @@ int main(int argc,char**argv){
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(memcmp(previous,state,sizeof state)){if(!send(fd,state))break;memcpy(previous,state,sizeof state);}
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));
}
steam.stop();feedback.stop();int neutral[18]{};send(fd,neutral);close(witness);ioctl(fd,UI_DEV_DESTROY);close(fd);shutdown();return 0;
+10
View File
@@ -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];}
};
+6 -6
View File
@@ -60,7 +60,7 @@ 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=true;
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{};
@@ -123,7 +123,7 @@ 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;
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;
@@ -281,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);
@@ -547,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=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",true);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 nLine 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;}}
@@ -588,7 +588,7 @@ int main(int argc,char** argv){
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;}
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",true)}});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 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);
@@ -5,6 +5,8 @@ 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
+1 -1
View File
@@ -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;
}
+4 -3
View File
@@ -20,6 +20,7 @@ class SteamUiSounds {
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;}
@@ -36,13 +37,13 @@ class SteamUiSounds {
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(destroy,"SDL_DestroyAudioStream")&&bind(release,"SDL_free"))){shutdown();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_navigation.wav","deck_ui_default_activation.wav"};
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||!resume(clip.stream)){shutdown();return false;}
if(!clip.stream||!gain(clip.stream,i==0?.30f:.45f)||!resume(clip.stream)){shutdown();return false;}
}
return true;
}
+316 -17
View File
@@ -21,6 +21,7 @@ use std::{
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
mod cleanup;
mod graphics;
mod lifecycle;
mod native;
mod steam_bridge;
@@ -42,6 +43,9 @@ pub fn lifecycle_started() {
lifecycle::start();
}
static MUTATION: Mutex<()> = Mutex::new(());
// Integration tests must serialize all callers of the process-wide APK mutation lock.
#[cfg(test)]
pub(crate) static TEST_SERIAL: Mutex<()> = Mutex::new(());
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Record {
@@ -75,6 +79,8 @@ struct Record {
show_window: Option<bool>,
#[serde(default)]
orientation: Option<String>,
#[serde(default)]
framebuffer_compatibility: bool,
}
#[derive(Serialize, Deserialize)]
struct Database {
@@ -90,6 +96,8 @@ struct Database {
gamepad_source: String,
#[serde(default = "close_container_default")]
gamepad_rumble: bool,
#[serde(default = "gamepad_trigger_threshold_default")]
gamepad_trigger_threshold: u8,
#[serde(default)]
owned_contexts: Vec<String>,
#[serde(default)]
@@ -108,6 +116,7 @@ impl Default for Database {
gamepad_enabled: false,
gamepad_source: gamepad_source_default(),
gamepad_rumble: true,
gamepad_trigger_threshold: gamepad_trigger_threshold_default(),
owned_contexts: Vec::new(),
root_contexts: BTreeMap::new(),
roots: Vec::new(),
@@ -141,6 +150,11 @@ struct App {
activity: Option<String>,
show_window: Option<bool>,
orientation: Option<String>,
#[serde(default)]
framebuffer_compatibility: bool,
}
fn gamepad_trigger_threshold_default() -> u8 {
80
}
fn gamepad_source_default() -> String {
"steam".into()
@@ -545,6 +559,48 @@ fn restriction(c: &Container, package: &str) -> Option<(bool, bool, Vec<String>)
}
None
}
// Legacy installs may have reused a package-derived context for another app.
// Allocate around that occupied context instead of touching its files or records.
fn automatic_install_context(
home: &Path,
db: &Database,
cs: &[Container],
package: &str,
) -> Result<String> {
let base = format!("framely-{}", hash(package));
for suffix in 0..1000 {
let id = if suffix == 0 {
base.clone()
} else {
format!("{base}-{}", suffix + 1)
};
let records: Vec<_> = db.records.values().filter(|r| r.context == id).collect();
if let Some(c) = cs.iter().find(|c| c.id == id) {
let packages = if c.baked.exists() {
installed(home, c)?
} else {
Vec::new()
};
if packages.iter().any(|(m, _)| m.package == package) {
return Ok(id); // Updating an existing app also remains valid in legacy shared containers.
}
if packages.iter().any(|(m, _)| m.package != package)
|| records.iter().any(|r| r.metadata.package != package)
{
continue;
}
return Ok(id);
}
if !records.is_empty()
|| fs::symlink_metadata(home.join(".local/share/lepton/contexts").join(&id)).is_ok()
{
continue; // Preserve retained records and unreadable/dangling paths.
}
return Ok(id);
}
anyhow::bail!("No free independent APK container name is available")
}
fn installed(home: &Path, c: &Container) -> Result<Vec<(Metadata, PathBuf)>> {
let p = c.baked.join("data_overlay/system/packages.xml");
let bytes = fs::read(&p)?;
@@ -737,6 +793,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
activity,
show_window: rec.and_then(|r| r.show_window),
orientation: rec.and_then(|r| r.orientation.clone()),
framebuffer_compatibility: rec.is_some_and(|r| r.framebuffer_compatibility),
});
}
}
@@ -790,6 +847,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
activity: r.activity.clone(),
show_window: r.show_window,
orientation: r.orientation.clone(),
framebuffer_compatibility: r.framebuffer_compatibility,
});
}
}
@@ -834,6 +892,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
activity: None,
show_window: None,
orientation: None,
framebuffer_compatibility: false,
});
}
}
@@ -888,7 +947,7 @@ pub fn list(home: &Path) -> Result<Value> {
}
}
Ok(
json!({"apps":apps.iter().map(|a| { let mut v=serde_json::to_value(a).unwrap(); v["steamLaunch"]=json!(db.records.get(&a.id).is_none_or(steam_shortcuts::wanted)); v["steamRegistered"]=json!(db.records.get(&a.id).is_some_and(|r|r.steam_launch));v["steamRegistrationError"]=json!(db.records.get(&a.id).and_then(|r|r.steam_registration_error.as_ref())); v }).collect::<Vec<_>>(),"containers":cs.iter().map(|c| { let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(false); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok(),"retainSteamEntries":db.retain_steam_entries,"gamepadEnabled":db.gamepad_enabled,"gamepadSource":db.gamepad_source,"gamepadRumble":db.gamepad_rumble,"autoStopContainer":db.auto_stop_container,"stopContainerOnClose":db.stop_container_on_close}),
json!({"apps":apps.iter().map(|a| { let mut v=serde_json::to_value(a).unwrap(); v["steamLaunch"]=json!(db.records.get(&a.id).is_none_or(steam_shortcuts::wanted)); v["steamRegistered"]=json!(db.records.get(&a.id).is_some_and(|r|r.steam_launch));v["steamRegistrationError"]=json!(db.records.get(&a.id).and_then(|r|r.steam_registration_error.as_ref())); v }).collect::<Vec<_>>(),"containers":cs.iter().map(|c| { let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(false); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok(),"retainSteamEntries":db.retain_steam_entries,"gamepadEnabled":db.gamepad_enabled,"gamepadSource":db.gamepad_source,"gamepadRumble":db.gamepad_rumble,"gamepadTriggerThreshold":db.gamepad_trigger_threshold,"autoStopContainer":db.auto_stop_container,"stopContainerOnClose":db.stop_container_on_close}),
)
}
pub fn launcher(home: &Path) -> Vec<Value> {
@@ -1272,7 +1331,14 @@ function setup_props() {
"#;
Ok(source
.replace(directory, "SCRIPT_DIR=\"${FRAMELY_LEPTON_DIR:?}\"")
.replace(include, &format!("{include}\n{}\n{hooks}", storage::HOOKS)))
.replace(
include,
&format!(
"{include}\n{}\n{hooks}\n{}",
storage::HOOKS,
graphics::HOOKS
),
))
}
fn start(home: &Path, c: &Container, show: Option<bool>, log: &Path) -> Result<()> {
start_container(home, c, show, true, log)
@@ -1305,7 +1371,21 @@ fi
user=$(am get-started-user-state 0)
echo FRAMELY_USER=$user
storage=0
if [ -d /storage/emulated/0 ] && sm list-volumes all | grep -Eq '^emulated(;0)? mounted( |$)'; then storage=1; fi
if [ -d /storage/emulated/0 ]; then
volumes=$(sm list-volumes all 2>/dev/null)
if printf '%s\n' "$volumes" | grep -Eq '^emulated(;0)? mounted( |$)'; then
storage=1
elif ! printf '%s\n' "$volumes" | grep -Eq '^emulated(;0)? '; then
# Some Lepton images return success but no emulated volume from sm.
# Inspect only user 0's emulated volume, never the mounted /data volume.
volume_state=$(dumpsys mount 2>/dev/null | awk '
/^[[:space:]]*VolumeInfo\{/ { target = ($0 ~ /^[[:space:]]*VolumeInfo\{emulated(;0)?\}:/) }
target && /state=/ { print; exit }
')
echo "FRAMELY_STORAGE_VOLUME=$volume_state"
if printf '%s\n' "$volume_state" | grep -Eq '(^|[[:space:]])state=MOUNTED([[:space:]]|$)'; then storage=1; fi
fi
fi
echo FRAMELY_STORAGE=$storage
[ "$boot" = 1 ] && [ "$pm_ready" = 1 ] && [ "$user" = RUNNING_UNLOCKED ] && [ "$storage" = 1 ] && echo FRAMELY_ANDROID_READY
exit 0
@@ -1517,6 +1597,7 @@ fn start_oriented_container_for(
let (window_dex, window_script) = window_runtime(home)?;
cmd.env("FRAMELY_WINDOW_DEX", window_dex)
.env("FRAMELY_WINDOW_SCRIPT", window_script);
graphics::configure_command(&mut cmd, false, "")?;
cmd.env_remove("SteamAppId");
storage::configure(
&mut cmd,
@@ -1672,6 +1753,7 @@ fn record_app(db: &mut Database, a: &App) {
activity: a.activity.clone(),
show_window: a.show_window,
orientation: a.orientation.clone(),
framebuffer_compatibility: a.framebuffer_compatibility,
..Default::default()
});
}
@@ -2002,6 +2084,7 @@ fn launch(c: &Container, a: &App, log: &Path) -> Result<()> {
}
fn launch_with_started(c: &Container, a: &App, log: &Path, started: impl FnOnce()) -> Result<()> {
let component = launch_component(a)?;
graphics::apply(c, a, log)?;
podman(
&[
"exec",
@@ -2381,6 +2464,14 @@ pub fn operate(
}
result
}
// Explicitly release flock: concurrent forked children can briefly inherit the
// descriptor before exec, so closing this process's descriptor alone is insufficient.
struct OperationLock(fs::File);
impl Drop for OperationLock {
fn drop(&mut self) {
unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) };
}
}
fn operate_internal(
home: &Path,
kind: &str,
@@ -2394,14 +2485,16 @@ fn operate_internal(
.map_err(|_| anyhow::anyhow!("Another APK operation is running"))?;
cancel.check()?;
let r = init(home)?;
let lock = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(r.join("operation.lock"))?;
let lock = OperationLock(
fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(r.join("operation.lock"))?,
);
ensure!(
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
unsafe { libc::flock(lock.0.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
"Another APK operation is running"
);
let mut db = load(home)?;
@@ -2505,12 +2598,26 @@ fn operate_internal(
} else {
db.gamepad_rumble
};
let threshold = if let Some(value) = p.get("triggerThreshold") {
let value = value
.as_u64()
.context("Invalid gamepad trigger threshold")?;
ensure!(
(1..=100).contains(&value),
"Gamepad trigger threshold must be between 1 and 100 percent"
);
value as u8
} else {
db.gamepad_trigger_threshold
};
cancel.commit(|| {
db.gamepad_enabled = enabled;
db.gamepad_source = source;
db.gamepad_rumble = rumble;
db.gamepad_trigger_threshold = threshold;
save(home, &db)?;
crate::gamepad::set_rumble_enabled(rumble);
crate::gamepad::set_trigger_threshold(threshold);
if !enabled {
crate::gamepad::stop();
}
@@ -2577,7 +2684,7 @@ fn operate_internal(
let requested = p["context"].as_str().unwrap_or("");
let cs = containers(home, &db);
let context = if requested.is_empty() {
format!("framely-{}", hash(&metadata.package))
automatic_install_context(home, &db, &cs, &metadata.package)?
} else {
requested.into()
};
@@ -2745,6 +2852,11 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
ensure!(v.is_boolean() || v.is_null(), "Invalid window setting");
rec.show_window = v.as_bool();
}
if let Some(v) = p.get("framebufferCompatibility") {
rec.framebuffer_compatibility = v
.as_bool()
.context("Invalid framebuffer compatibility setting")?;
}
if let Some(v) = p.get("orientation") {
ensure!(
v.as_str()
@@ -2824,6 +2936,12 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
launch_component(&a)?;
progress(json!({"phase":"starting"}));
let flat = a.show_window.unwrap_or(!a.metadata.vr);
if a.framebuffer_compatibility {
graphics::library(true)?;
if running(&c.name) && !graphics::mounted(&c, &log)? {
stop(&c, &log)?;
}
}
if flat && running(&c.name) {
let name = format!("lepton-{}", c.name);
let headless = podman(
@@ -2872,6 +2990,7 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
&c.name,
&db.gamepad_source,
db.gamepad_rumble,
db.gamepad_trigger_threshold,
)?)
} else {
None
@@ -3256,7 +3375,31 @@ pub fn launch_app_with_progress(
mod tests {
use super::*;
#[test]
fn operation_lock_releases_while_an_inherited_descriptor_remains_open() {
let t = tempfile::tempdir().unwrap();
let path = t.path().join("operation.lock");
let lock = OperationLock(fs::File::create(&path).unwrap());
assert_eq!(
unsafe { libc::flock(lock.0.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
0
);
// dup shares the open-file description, just as a descriptor inherited by fork does.
let inherited = lock.0.try_clone().unwrap();
let observer = fs::File::open(path).unwrap();
assert_ne!(
unsafe { libc::flock(observer.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
0
);
drop(lock);
assert_eq!(
unsafe { libc::flock(observer.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
0
);
drop(inherited);
}
#[test]
fn lifecycle_settings_migrate_persist_and_reject_invalid_values() {
let _guard = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let t = tempfile::tempdir().unwrap();
let home = t.path();
assert!(!load(home).unwrap().auto_stop_container);
@@ -3377,10 +3520,11 @@ mod tests {
}
#[test]
fn gamepad_setting_defaults_off_and_round_trips() {
let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let _guard = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(!load(home).unwrap().gamepad_enabled);
assert_eq!(load(home).unwrap().gamepad_trigger_threshold, 80);
for enabled in [true, false] {
operate(
home,
@@ -3414,7 +3558,23 @@ mod tests {
assert_eq!(saved.gamepad_source, source);
assert!(!saved.gamepad_rumble);
}
for threshold in [1, 65, 80, 100] {
operate(
home,
"gamepad.settings",
&json!({"enabled":false,"triggerThreshold":threshold}),
Cancellation::default(),
Arc::new(|_| {}),
)
.unwrap();
assert_eq!(load(home).unwrap().gamepad_trigger_threshold, threshold);
}
for invalid in [
json!({"enabled":true,"triggerThreshold":0}),
json!({"enabled":true,"triggerThreshold":101}),
json!({"enabled":true,"triggerThreshold":80.5}),
json!({"enabled":true,"triggerThreshold":"80"}),
json!({"enabled":true,"triggerThreshold":-1}),
json!({"enabled":true,"source":"physical"}),
json!({"enabled":true,"source":7}),
json!({"enabled":true,"rumble":"yes"}),
@@ -3428,11 +3588,13 @@ mod tests {
)
.is_err());
assert!(!load(home).unwrap().gamepad_enabled);
assert_eq!(load(home).unwrap().gamepad_trigger_threshold, 100);
}
fs::write(root(home).join("state.json"), "{\"records\":{}}").unwrap();
assert!(!load(home).unwrap().gamepad_enabled);
assert_eq!(load(home).unwrap().gamepad_source, "steam");
assert!(load(home).unwrap().gamepad_rumble);
assert_eq!(load(home).unwrap().gamepad_trigger_threshold, 80);
}
#[test]
fn gamepad_foreground_does_not_match_another_package() {
@@ -3743,7 +3905,7 @@ setup_podman_mounts
}
#[test]
fn local_apk_review_snapshots_source_and_rejects_invalid_selections() {
let _serial = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let source = home.path().join("selected.APK");
crate::apk_metadata::fixture(&source, true, 7);
@@ -3808,7 +3970,6 @@ setup_podman_mounts
.join("com.example.app.framely-preserve-token.legacy-link")
.is_symlink());
}
static SERIAL: Mutex<()> = Mutex::new(());
struct Fixture {
dir: tempfile::TempDir,
home: PathBuf,
@@ -3822,7 +3983,7 @@ setup_podman_mounts
}
impl Fixture {
fn new() -> Self {
let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let guard = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner());
steam_ui::TEST_RESULT.with(|p| *p.borrow_mut() = Some(json!(0x92345678u32)));
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
@@ -4380,7 +4541,8 @@ setup_props
);
assert!(!fs::read_to_string(f.dir.path().join("commands"))
.unwrap()
.contains("stop "));
.lines()
.any(|line| line.starts_with("stop ")));
// Unknown lifecycle output refuses a destructive stop.
fs::write(f.dir.path().join("bad-probe"), "").unwrap();
assert!(steam_bridge::test_request(&f.home, id, "test-token", "stop").is_err());
@@ -5049,6 +5211,71 @@ setup_props
assert!(f.save_file().exists());
assert!(!load(&f.home).unwrap().records["test/com.example.app"].removed);
}
#[test]
fn automatic_install_context_preserves_occupied_legacy_contexts() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let package = "com.example.new";
let base = format!("framely-{}", hash(package));
let baked = home
.join(".local/share/lepton/contexts")
.join(&base)
.join("baked");
fs::create_dir_all(baked.join("data_overlay/system")).unwrap();
let xml = b"<packages><package name=\"com.example.old\" codePath=\"/data/app/old\" version=\"1\"/></packages>";
fs::write(baked.join("data_overlay/system/packages.xml"), xml).unwrap();
let c = Container {
id: base.clone(),
name: base.clone(),
baked: baked.clone(),
running: false,
managed: true,
steam: false,
};
let mut db = Database::default();
assert_eq!(
automatic_install_context(home, &db, &[c.clone()], package).unwrap(),
format!("{base}-2")
);
assert_eq!(
fs::read(baked.join("data_overlay/system/packages.xml")).unwrap(),
xml
);
// An unreadable context must also be skipped, rather than overwritten.
fs::create_dir_all(
home.join(".local/share/lepton/contexts")
.join(format!("{base}-2")),
)
.unwrap();
assert_eq!(
automatic_install_context(home, &db, &[c.clone()], package).unwrap(),
format!("{base}-3")
);
// A retained foreign app reserves a name even if its directory is gone.
db.records.insert(
"retained".into(),
Record {
context: format!("{base}-3"),
metadata: Metadata {
package: "com.example.retained".into(),
..Default::default()
},
removed: true,
..Default::default()
},
);
assert_eq!(
automatic_install_context(home, &db, &[c.clone()], package).unwrap(),
format!("{base}-4")
);
// Same-package updates still reuse the existing legacy shared container.
fs::write(baked.join("data_overlay/system/packages.xml"), format!("<packages><package name=\"{package}\" codePath=\"/data/app/new\" version=\"1\"/><package name=\"com.example.old\" codePath=\"/data/app/old\" version=\"1\"/></packages>")).unwrap();
assert_eq!(
automatic_install_context(home, &db, &[c], package).unwrap(),
base
);
}
#[test]
fn shared_mount_loss_preserves_launcher_identity_until_confirmed_uninstall() {
let f = Fixture::new();
@@ -5265,6 +5492,34 @@ esac
assert!(inspect(&f.home, &f.dir.path().join("upload.apk"), &c).is_err());
}
#[test]
fn framebuffer_preference_defaults_off_validates_and_survives_refresh() {
let f = Fixture::new();
list(&f.home).unwrap();
let id = "test/com.example.app";
assert!(!load(&f.home).unwrap().records[id].framebuffer_compatibility);
assert!(f
.operation(
"settings",
json!({"app":id,"framebufferCompatibility":"yes"})
)
.is_err());
f.operation(
"settings",
json!({"app":id,"framebufferCompatibility":true}),
)
.unwrap();
list(&f.home).unwrap();
let db = load(&f.home).unwrap();
assert!(db.records[id].framebuffer_compatibility);
assert!(app(&f.home, &db, id).unwrap().0.framebuffer_compatibility);
f.operation(
"settings",
json!({"app":id,"framebufferCompatibility":false}),
)
.unwrap();
assert!(!load(&f.home).unwrap().records[id].framebuffer_compatibility);
}
#[test]
fn window_orientation_is_validated_persisted_and_applied() {
let f = Fixture::new();
list(&f.home).unwrap();
@@ -5481,7 +5736,7 @@ esac
fn android_ready_probe_uses_operation_specific_checks_and_accepts_volume_output() {
let dir = tempfile::tempdir().unwrap();
let run = |mode: &str, user: &str, volume: &str| {
let mocks = format!("getprop() {{ echo 1; }}; pm() {{ echo package:/system/framework/framework-res.apk; }}; am() {{ echo {user}; }}; sm() {{ echo '{volume}'; }};\n");
let mocks = format!("getprop() {{ echo 1; }}; pm() {{ echo package:/system/framework/framework-res.apk; }}; am() {{ echo {user}; }}; sm() {{ echo '{volume}'; }}; dumpsys() {{ :; }};\n");
let probe = ANDROID_READY_PROBE
.replace("/storage/emulated/0", &dir.path().display().to_string());
let mut command = Command::new("/bin/sh");
@@ -5504,6 +5759,50 @@ esac
.contains("FRAMELY_ANDROID_READY"));
}
#[test]
fn android_ready_probe_handles_lepton_empty_sm_without_accepting_other_volumes() {
let dir = tempfile::tempdir().unwrap();
let mounted = "Volumes:\n VolumeInfo{private}:\n state=MOUNTED\n VolumeInfo{emulated;0}:\n mountUserId=0 state=MOUNTED \n";
let run = |sm: &str, dump: &str, exists: bool| {
let mocks = format!("getprop() {{ echo 1; }}; pm() {{ echo package:/android; }}; am() {{ echo RUNNING_UNLOCKED; }}; sm() {{ printf '%s\n' '{sm}'; }}; dumpsys() {{ printf '%s\n' '{dump}'; }};\n");
let path = if exists {
dir.path().to_path_buf()
} else {
dir.path().join("missing")
};
let probe =
ANDROID_READY_PROBE.replace("/storage/emulated/0", &path.display().to_string());
let mut command = Command::new("/bin/sh");
command.args(["-c", &(mocks + &probe), "probe", "full"]);
output(command, Duration::from_secs(2), None)
.unwrap()
.contains("FRAMELY_ANDROID_READY")
};
assert!(run("", mounted, true));
assert!(run("", &mounted.replace("emulated;0", "emulated"), true));
assert!(!run("", mounted, false));
assert!(!run("emulated;0 unmounted null", mounted, true));
assert!(!run(
"",
&mounted.replace(
"mountUserId=0 state=MOUNTED",
"mountUserId=0 state=UNMOUNTED"
),
true
));
assert!(!run(
"",
&mounted.replace("emulated;0", "emulated;10"),
true
));
assert!(!run("", "VolumeInfo{private}:\n state=MOUNTED\n", true));
assert!(!run(
"",
"VolumeInfo{emulated;0}:\n state=CHECKING\nVolumeInfo{private}:\n state=MOUNTED\n",
true
));
assert!(!run("", "", true));
}
#[test]
fn android_ready_errors_identify_the_blocked_stage() {
assert!(
android_readiness_failure("probe timed out", AndroidReadiness::Full)
+207
View File
@@ -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());
}
}
+8 -2
View File
@@ -176,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")
@@ -206,8 +207,13 @@ pub(super) fn prepare(
)?;
}
if db.gamepad_enabled {
let m =
crate::gamepad::prepare(&root(home), &c.name, &db.gamepad_source, db.gamepad_rumble)?;
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)),
+32
View File
@@ -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 };
}
+4 -19
View File
@@ -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
+3
View File
@@ -715,6 +715,9 @@ mod tests {
}
#[test]
fn inspect_apk_with_cesu8_resources() {
let _guard = crate::apk::TEST_SERIAL
.lock()
.unwrap_or_else(|e| e.into_inner());
use std::io::Write;
let d = tempfile::tempdir().unwrap();
let p = d.path().join("emoji.apk");
+19 -1
View File
@@ -96,6 +96,12 @@ pub fn set_rumble_enabled(enabled: bool) {
);
}
}
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) {
@@ -129,7 +135,13 @@ pub fn current(context: &str) -> Option<Mount> {
}
bridges.contexts.get(context).map(|b| b.mount.clone())
}
pub fn prepare(storage: &Path, context: &str, source: &str, rumble: bool) -> 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"
@@ -139,6 +151,10 @@ pub fn prepare(storage: &Path, context: &str, source: &str, rumble: bool) -> Res
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();
@@ -148,6 +164,7 @@ pub fn prepare(storage: &Path, context: &str, source: &str, rumble: bool) -> Res
}
if let Some(bridge) = bridges.contexts.get_mut(context) {
bridge.package = None;
writeln!(bridge.input, "trigger-threshold {threshold}")?;
}
}
if let Some(mount) = current {
@@ -194,6 +211,7 @@ pub fn prepare(storage: &Path, context: &str, source: &str, rumble: bool) -> Res
};
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",
+231 -2
View File
@@ -68,6 +68,73 @@ fn save_json(path: &Path, v: &Value) -> Result<()> {
Ok(())
}
use std::os::unix::fs::OpenOptionsExt;
#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ViewPreferences {
#[serde(default = "default_file_view")]
view: String,
#[serde(default)]
folders: BTreeMap<String, FolderSort>,
}
fn default_file_view() -> String {
"list".into()
}
#[derive(Serialize, Deserialize)]
struct FolderSort {
sort: String,
descending: bool,
}
fn view_preferences(home: &Path, p: &Value) -> Result<Value> {
let file = state(home)?.join("view-preferences.json");
let mut preferences: ViewPreferences = if file.exists() {
serde_json::from_slice(&fs::read(&file)?)?
} else {
ViewPreferences {
view: default_file_view(),
..Default::default()
}
};
let mut changed = false;
if let Some(view) = p.get("view") {
let view = view.as_str().context("Invalid file view")?;
ensure!(
matches!(
view,
"list" | "icons-small" | "icons-medium" | "icons-large"
),
"Invalid file view"
);
preferences.view = view.into();
changed = true;
}
if p.get("path").is_some() {
let dir = PathBuf::from(strparam(p, "path")?);
ensure!(dir.is_absolute(), "Use an absolute file path");
let dir = dir.canonicalize()?;
ensure!(dir.is_dir(), "Select a directory");
let sort = strparam(p, "sort")?;
ensure!(
matches!(sort, "name" | "size" | "modified"),
"Invalid file sort"
);
let descending = p["descending"]
.as_bool()
.context("Invalid file sort direction")?;
preferences.folders.insert(
dir.to_str().context("Invalid directory path")?.into(),
FolderSort {
sort: sort.into(),
descending,
},
);
changed = true;
}
let value = serde_json::to_value(preferences)?;
if changed {
save_json(&file, &value)?;
}
Ok(value)
}
#[derive(Default)]
pub struct Files {
preferences: Mutex<()>,
@@ -110,8 +177,28 @@ fn entry(p: &Path) -> Result<Value> {
} else {
m.is_dir()
};
// Revision uses target metadata (including nanoseconds), never media bytes.
let media_revision = if !dir && media_type(p).is_some() {
let target = if link {
fs::metadata(p).ok()
} else {
Some(m.clone())
};
target.filter(|m| m.is_file()).map(|m| {
format!(
"{}:{}:{}:{}:{}",
m.dev(),
m.ino(),
m.len(),
m.mtime(),
m.mtime_nsec()
)
})
} else {
None
};
Ok(
json!({"name":p.file_name().unwrap_or_default().to_string_lossy(),"path":p,"directory":dir,"symlink":link,"size":m.len(),"modified":m.modified().ok().and_then(|t|t.duration_since(UNIX_EPOCH).ok()).map(|t|t.as_secs()),"mode":m.mode()&0o7777,"uid":m.uid(),"gid":m.gid(),"linkTarget":if link{fs::read_link(p).ok()}else{None}}),
json!({"name":p.file_name().unwrap_or_default().to_string_lossy(),"path":p,"directory":dir,"symlink":link,"size":m.len(),"mediaRevision":media_revision,"modified":m.modified().ok().and_then(|t|t.duration_since(UNIX_EPOCH).ok()).map(|t|t.as_secs()),"mode":m.mode()&0o7777,"uid":m.uid(),"gid":m.gid(),"linkTarget":if link{fs::read_link(p).ok()}else{None}}),
)
}
fn list(p: &Value, home: &Path) -> Result<Value> {
@@ -453,6 +540,10 @@ impl Files {
let operation = p["operation"].as_str().unwrap_or("list");
match operation {
"list" => list(p, home),"properties" => entry(&path(p,"path")?),
"view.preferences" => {
let _guard = self.preferences.lock().unwrap();
view_preferences(home, p)
},
"preferences" => {
let _guard = self.preferences.lock().unwrap();
let file = state(home)?.join("preferences.json");
@@ -561,9 +652,15 @@ impl Files {
path:file, inline, owned:false, expires:now()+3600
});
Ok(json!({
"url":format!("/manager-api/file-content/{id}")
"url":format!("/manager-api/file-content/{id}"),"token":if inline{Some(id)}else{None}
}))
},
"preview.release" => {
let id = strparam(p,"token")?;
let mut downloads = self.downloads.lock().unwrap();
if downloads.get(id).is_some_and(|d| d.inline && !d.owned) { downloads.remove(id); }
Ok(json!(true))
},
"upload.start" => {
let parent = PathBuf::from(strparam(p,"directory")?).canonicalize()?;
let relative = Path::new(strparam(p,"name")?);
@@ -1110,6 +1207,70 @@ fn target_resolution(path: &Path, p: &Value) -> Result<Option<PathBuf>> {
mod tests {
use super::*;
#[test]
fn media_revisions_track_replacements_and_symlink_targets() {
let temp = tempfile::tempdir().unwrap();
let image = temp.path().join("sample.png");
fs::write(&image, b"generated").unwrap();
let first = entry(&image).unwrap()["mediaRevision"].clone();
let replacement = temp.path().join("replacement.png");
fs::write(&replacement, b"different").unwrap();
fs::rename(&replacement, &image).unwrap();
let second = entry(&image).unwrap()["mediaRevision"].clone();
assert_ne!(first, second);
let link = temp.path().join("link.png");
symlink(&image, &link).unwrap();
assert_eq!(entry(&link).unwrap()["mediaRevision"], second);
fs::write(temp.path().join("other.png"), b"different").unwrap();
fs::remove_file(&link).unwrap();
symlink(temp.path().join("other.png"), &link).unwrap();
assert_ne!(entry(&link).unwrap()["mediaRevision"], second);
fs::write(temp.path().join("notes.txt"), b"generated").unwrap();
assert!(entry(&temp.path().join("notes.txt")).unwrap()["mediaRevision"].is_null());
}
#[test]
fn thumbnail_grants_can_be_released_without_revoking_downloads() {
let temp = tempfile::tempdir().unwrap();
let image = temp.path().join("sample.png");
fs::write(&image, b"generated").unwrap();
let files = Files::default();
let preview = files
.api(temp.path(), &json!({"operation":"preview","path":image}))
.unwrap();
let token = preview["token"].as_str().unwrap();
assert!(files.content(token).unwrap().1);
files
.api(
temp.path(),
&json!({"operation":"preview.release","token":token}),
)
.unwrap();
assert!(files.content(token).is_err());
let download = files
.api(temp.path(), &json!({"operation":"download","path":image}))
.unwrap();
let token = download["url"]
.as_str()
.unwrap()
.rsplit('/')
.next()
.unwrap();
files
.api(
temp.path(),
&json!({"operation":"preview.release","token":token}),
)
.unwrap();
assert!(!files.content(token).unwrap().1);
let unsafe_file = temp.path().join("active.svg");
fs::write(&unsafe_file, b"generated").unwrap();
assert!(files
.api(
temp.path(),
&json!({"operation":"preview","path":unsafe_file})
)
.is_err());
}
#[test]
fn centered_paths_and_link_safety() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
@@ -1522,6 +1683,74 @@ mod operation_tests {
assert_eq!(fs::read_dir(dest).unwrap().count(), 0);
}
#[test]
fn folder_sort_and_global_view_preferences_persist_independently() {
if unsafe { libc::geteuid() } == 0 {
return;
}
let home = tempfile::tempdir().unwrap();
let a = home.path().join("a");
let b = home.path().join("b");
fs::create_dir(&a).unwrap();
fs::create_dir(&b).unwrap();
let manager = Files::default();
let defaults = manager
.api(home.path(), &json!({"operation":"view.preferences"}))
.unwrap();
assert_eq!(defaults, json!({"view":"list","folders":{}}));
manager
.api(
home.path(),
&json!({"operation":"view.preferences","path":a,"sort":"size","descending":true}),
)
.unwrap();
manager.api(home.path(), &json!({"operation":"view.preferences","path":b,"sort":"modified","descending":false})).unwrap();
manager
.api(
home.path(),
&json!({"operation":"view.preferences","view":"icons-large"}),
)
.unwrap();
// Canonical paths avoid separate preferences for aliases/trailing slashes.
let alias = home.path().join("alias");
symlink(&a, &alias).unwrap();
manager.api(home.path(), &json!({"operation":"view.preferences","path":alias,"sort":"name","descending":false})).unwrap();
let reloaded = Files::default()
.api(home.path(), &json!({"operation":"view.preferences"}))
.unwrap();
assert_eq!(reloaded["view"], "icons-large");
assert_eq!(reloaded["folders"].as_object().unwrap().len(), 2);
assert_eq!(
reloaded["folders"][a.to_str().unwrap()],
json!({"sort":"name","descending":false})
);
assert_eq!(
reloaded["folders"][b.to_str().unwrap()],
json!({"sort":"modified","descending":false})
);
for bad in [
json!({"view":"giant"}),
json!({"path":a,"sort":"invalid","descending":false}),
json!({"path":a,"sort":"size","descending":"true"}),
json!({"path":"relative","sort":"name","descending":false}),
] {
let mut p = bad;
p["operation"] = json!("view.preferences");
assert!(manager.api(home.path(), &p).is_err());
}
assert_eq!(
manager
.api(home.path(), &json!({"operation":"view.preferences"}))
.unwrap(),
reloaded
);
assert_eq!(
manager
.api(home.path(), &json!({"operation":"preferences"}))
.unwrap(),
json!([])
);
}
#[test]
fn bookmarks_survive_missing_directories_and_editor_requires_revision() {
if unsafe { libc::geteuid() } == 0 {
return;
+2 -2
View File
@@ -181,14 +181,14 @@ pub struct LauncherAction {
pub struct FeedbackSettings {
#[serde(default = "yes")]
pub haptics: bool,
#[serde(default = "yes")]
#[serde(default)]
pub sounds: bool,
}
impl Default for FeedbackSettings {
fn default() -> Self {
Self {
haptics: true,
sounds: true,
sounds: false,
}
}
}
+23 -2
View File
@@ -2636,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}));
@@ -2823,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(),
@@ -2876,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(),
@@ -2965,7 +2986,7 @@ mod feedback_tests {
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);
assert!(core.db.feedback.haptics && !core.db.feedback.sounds);
core.handle(
"feedback.settings.save",
json!({"haptics":false,"sounds":true}),
@@ -2990,6 +3011,6 @@ mod feedback_tests {
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);
assert!(old.feedback.haptics && !old.feedback.sounds);
}
}
+7 -2
View File
@@ -2819,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();
@@ -2848,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
View File
@@ -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
View File
@@ -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);
+2
View File
@@ -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
View File
@@ -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 {
+10 -4
View File
@@ -5,7 +5,7 @@ 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 uninstallError:string|null=null;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;
@@ -13,9 +13,9 @@ window.fetch=async(url,init)=>{if(String(url).startsWith('/host/files/')){const
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'&&!uninstallError)app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
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}));};
@@ -34,6 +34,12 @@ async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIE
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'));}
@@ -93,4 +99,4 @@ async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIE
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)));
+2 -2
View File
@@ -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);
+31
View File
@@ -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);
}
+86
View File
@@ -0,0 +1,86 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {FileManager} from '../ui/src/file-manager';
import {configureLanguage} from '../ui/src/i18n';
import {Brand} from '../ui/src/icons';
import '../ui/src/style.css';
const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms));
const until=async(f:()=>unknown)=>{for(let n=0;n<150;n++){if(f())return;await wait();}throw Error('Timeout '+document.body.innerText.slice(-800));};
const home='/home/steamos',downloads=home+'/Downloads';
const longFolder='一个很长的文件夹名称 - Project documents and archived screenshots';
let binaryMode=false;
let preferences:any={view:'list',folders:{[home]:{sort:'size',descending:true},[downloads]:{sort:'modified',descending:false}}};
const calls:any[]=[],downloadsClicked:string[]=[];const anchorClick=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){downloadsClicked.push(this.getAttribute('href')??'');};
const files=(directory:string)=>[
{name:'Photos',path:directory+'/Photos',directory:true,symlink:false,size:0,mode:493,uid:1000,gid:1000},
{name:'A very long photograph name with spaces.png',path:directory+'/picture.png',directory:false,symlink:false,size:8192,modified:1791360000,mode:420,uid:1000,gid:1000},
{name:'notes.txt',path:directory+'/notes.txt',directory:false,symlink:false,size:64,modified:1791350000,mode:420,uid:1000,gid:1000},
{name:'movie.webm',path:directory+'/movie.webm',directory:false,symlink:true,size:2048,modified:1791340000,mode:420,uid:1000,gid:1000}];
window.fetch=async(url,init)=>{
if(String(url)!=='/manager-api/files')throw Error('Unexpected request '+url);
const p=JSON.parse(String(init?.body));calls.push(p);let result:any;
if(p.operation==='preferences')result=[];
else if(p.operation==='view.preferences'){
await wait(40);
if(p.view)preferences.view=p.view;
if(p.path)preferences.folders[p.path]={sort:p.sort,descending:p.descending};
result=structuredClone(preferences);
}else if(p.operation==='list'){
let directory=p.path||home;if(directory==='/alias')directory=home;
let entries=files(directory);if(binaryMode)entries=[{...entries[2],name:'sample.bin',path:directory+'/sample.bin'}];
if(p.hidden){ // Tree requests do not consume the sort checks below.
const child=directory==='/'?{name:'home',path:'/home'}:directory==='/home'?{name:'steamos',path:home}:directory===home?{name:longFolder,path:home+'/'+longFolder}:null;
entries=child?[{...child,directory:true,symlink:false,size:0,mode:493,uid:1000,gid:1000}]:[];
}
result={path:directory,parent:'/',entries,total:entries.length,roots:[{name:'Home',path:home},{name:'Downloads',path:downloads}]};
}else if(p.operation==='preview'){const canvas=document.createElement('canvas');canvas.width=80;canvas.height=60;const ctx=canvas.getContext('2d')!;ctx.fillStyle='#8bb4ce';ctx.fillRect(0,0,80,60);result={url:p.path.endsWith('.png')?canvas.toDataURL(): 'data:video/webm;base64,AA==',token:p.path};}
else if(p.operation==='preview.release')result=true;
else if(p.operation==='read'){if(p.path.endsWith('notes.txt'))result={text:'fixture text',revision:'revision'};else return new Response(JSON.stringify({error:'Binary files cannot be edited as text'}));}
else if(p.operation==='download')result={url:'/fixture-download'};
else throw Error('Unexpected operation '+p.operation);
return new Response(JSON.stringify({result}));
};
const root=createRoot(document.getElementById('root')!);
const render=(key:string)=>root.render(<div className="app manager"><header className="shell-header"><Brand wordmark/><span>Files</span></header><div className="workspace tools-workspace"><main className="content file-content"><div className="file-workspace-frame"><FileManager key={key}/></div></main></div></div>);
const button=(label:string)=>[...document.querySelectorAll<HTMLButtonElement>('.file-manager button')].find(b=>b.textContent?.trim()===label)!;
const select=async(label:string,option:string)=>{document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!.click();await until(()=>document.querySelector('[role=listbox]'));[...document.querySelectorAll<HTMLButtonElement>('[role=option]')].find(b=>b.textContent===option)!.click();await wait();};
const ready=()=>document.querySelector('.file-list-scroll')?.getAttribute('aria-busy')==='false';
const listing=()=>calls.filter(p=>p.operation==='list'&&!p.hidden).at(-1);
const changeAddress=async(value:string)=>{const input=document.querySelector<HTMLInputElement>('.file-address input')!;Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(input,value);input.dispatchEvent(new Event('input',{bubbles:true}));await wait();input.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));};
(window as any).runInstallReviewChecks=async()=>{try{
configureLanguage('en-US',[]);render('one');await until(ready);await wait(200);
if(calls.some(p=>p.operation==='preview'))throw Error('List view fetched thumbnails');
if(listing().sort!=='size'||listing().descending!==true)throw Error('Home sort not restored on initial canonical path');
await until(()=>[...document.querySelectorAll('.file-tree-entry button>span')].some(node=>node.textContent===longFolder));
const tree=document.querySelector<HTMLElement>('.file-tree-scroll')!,name=[...tree.querySelectorAll<HTMLElement>('button>span')].find(node=>node.textContent===longFolder)!;
if(getComputedStyle(name).textOverflow==='ellipsis'||name.scrollWidth>name.clientWidth+1)throw Error('Tree folder name clipped');
if(tree.scrollWidth<=tree.clientWidth)throw Error('Tree does not expose horizontal overflow');
tree.scrollLeft=tree.scrollWidth;if(tree.scrollLeft<=0)throw Error('Cannot scroll long tree names');tree.scrollLeft=0;
if(name.closest('button')?.title!==home+'/'+longFolder)throw Error('Full path tooltip missing');
await select('View','Large icons');await until(()=>document.querySelector('.file-icon-grid.icons-large'));
if(document.querySelector('.file-table'))throw Error('Table remained in icon view');
const checkbox=document.querySelector<HTMLInputElement>('.file-tile-check input')!;checkbox.click();await until(()=>document.querySelector('.file-icon-tile.selected'));
await select('View','Small icons');await until(()=>document.querySelector('.icons-small .file-icon-tile.selected'));
document.querySelector('.file-tile-open')!.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:500,clientY:400}));await until(()=>document.querySelector('.file-context-menu'));if(!button('Copy'))throw Error('Context actions missing');window.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'}));
button('Downloads').click();await until(()=>ready()&&listing().path===downloads);
if(listing().sort!=='modified'||listing().descending!==false||!document.querySelector('.icons-small'))throw Error('Folder sort/global view scope incorrect');
await select('Sort','Name');await until(ready);document.querySelector<HTMLButtonElement>('[aria-label="Ascending"]')!.click();await until(()=>ready()&&listing().descending===true);
button('Home').click();await until(()=>ready()&&listing().path===home);if(listing().sort!=='size'||listing().descending!==true)throw Error('Home sort overwritten by downloads');
button('Downloads').click();await until(()=>ready()&&listing().path===downloads);if(listing().sort!=='name'||listing().descending!==true)throw Error('Changed sort not remembered');
await select('View','Medium icons');await until(()=>preferences.view==='icons-medium');render('two');await wait();await until(()=>ready()&&document.querySelector('.icons-medium'));
if(listing().sort!=='size')throw Error('Preferences failed across remount');
await changeAddress('/alias');await until(()=>ready()&&document.querySelector<HTMLInputElement>('.file-address input')!.value===home);
if(listing().sort!=='size')throw Error('Canonical folder preference not restored');
const tile=document.querySelector<HTMLButtonElement>('.file-tile-open[title="notes.txt"]')!;tile.click();await until(()=>document.querySelector('.file-editor'));button('Close').click();
document.querySelector<HTMLButtonElement>('.file-tile-open[title="A very long photograph name with spaces.png"]')!.click();await until(()=>document.querySelector('.file-media-overlay img'));await until(()=>document.querySelector('.file-media-overlay img')?.getAttribute('src'));document.querySelector<HTMLButtonElement>('.file-media-overlay [aria-label="Close"]')!.click();await until(()=>!document.querySelector('.file-media-overlay'));
await wait(400);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MANAGER_ICONS_EN');await wait(300);
configureLanguage('zh-CN',[]);render('three');await wait();await until(()=>ready()&&document.querySelector('.icons-medium'));await select('展示方式','大图标');await until(()=>document.querySelector('.icons-large'));
await wait(400);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MANAGER_ICONS_ZH');await wait(300);
await select('展示方式','列表');await until(()=>document.querySelector('.file-table'));document.querySelector<HTMLInputElement>('[aria-label="选择本页"]')!.click();await until(()=>document.querySelectorAll('.file-table tr.selected').length===4);
if(calls.some(p=>p.operation==='preview'&&!/\.(png|webm)$/.test(p.path)))throw Error('Non-media fetched for thumbnails');
if(!calls.some(p=>p.operation==='preview.release'))throw Error('Source preview grants not released');
binaryMode=true;document.querySelector<HTMLButtonElement>('[aria-label="刷新"]')!.click();await until(()=>document.querySelector('.file-name')?.textContent?.includes('sample.bin'));document.querySelector<HTMLButtonElement>('.file-name')!.click();await until(()=>downloadsClicked.includes('/fixture-download'));if(document.querySelector('.modal'))throw Error('Unsupported file opened a dialog');
HTMLAnchorElement.prototype.click=anchorClick;console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+String(e)+' '+(e as Error).stack);}};
console.log('FRAMELY_VIEW_READY');
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {FileMediaPreview} from '../ui/src/file-media-preview';
import {configureLanguage} from '../ui/src/i18n';
import '../ui/src/style.css';
import '../ui/src/file-manager.css';
const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms));
const until=async(f:()=>unknown)=>{for(let n=0;n<200;n++){if(f())return;await wait();}throw Error('Preview timeout '+document.body.innerText);};
const root=createRoot(document.getElementById('root')!);
let imageUrl='',videoUrl='',closed=0;const released:string[]=[],downloads:string[]=[];
window.fetch=async(_url,init)=>{const p=JSON.parse(String(init?.body));if(p.operation==='preview.release'){released.push(p.token);return new Response(JSON.stringify({result:true}));}if(p.operation!=='preview')throw Error('Unexpected operation');await wait(60);return new Response(JSON.stringify({result:{url:p.path==='/broken.png'?'data:image/png;base64,AA==':p.path.endsWith('.webm')?videoUrl:imageUrl,token:p.path}}));};
const entries=[{path:'/first.png',name:'Generated landscape.png'},{path:'/clip.webm',name:'Generated video.webm'},{path:'/last.png',name:'Second generated image.png'},{path:'/broken.png',name:'Unsupported image.png'}];
const render=(initial=0)=>root.render(<><button id="opener">Files</button><FileMediaPreview entries={entries} initial={initial} onClose={()=>{closed++;root.render(<button id="opener">Files</button>);}} onDownload={path=>downloads.push(path)}/></>);
const button=(label:string)=>document.querySelector<HTMLButtonElement>(`.file-media-overlay [aria-label="${label}"]`)!;
const loaded=()=>document.querySelector<HTMLElement>('.file-media-canvas')?.style.visibility==='visible'&&!document.querySelector('.file-media-loading')&&!document.querySelector('.file-media-error');
const transform=()=>document.querySelector<HTMLElement>('.file-media-canvas')!.style.transform;
async function samples(){const canvas=document.createElement('canvas');canvas.width=640;canvas.height=360;const ctx=canvas.getContext('2d')!;ctx.fillStyle='#63869b';ctx.fillRect(0,0,640,360);ctx.fillStyle='#bcc4c9';ctx.beginPath();ctx.moveTo(0,360);ctx.lineTo(220,100);ctx.lineTo(450,360);ctx.fill();imageUrl=canvas.toDataURL();const stream=canvas.captureStream(10),recorder=new MediaRecorder(stream,{mimeType:'video/webm;codecs=vp8'}),chunks:Blob[]=[];recorder.ondataavailable=e=>chunks.push(e.data);const stopped=new Promise<void>(r=>recorder.onstop=()=>r());recorder.start();const timer=setInterval(()=>ctx.fillRect(500,100,40,40),70);await wait(1200);recorder.stop();await stopped;clearInterval(timer);stream.getTracks().forEach(t=>t.stop());videoUrl=URL.createObjectURL(new Blob(chunks,{type:'video/webm'}));}
(window as any).runInstallReviewChecks=async()=>{try{
configureLanguage('en-US',[]);root.render(<div className="app manager" style={{height:'100vh',background:'#222'}}><button>Files</button></div>);await wait(300);await samples();render();await until(loaded);
const overlay=document.querySelector<HTMLElement>('.file-media-overlay')!,bounds=overlay.getBoundingClientRect();if(bounds.width!==innerWidth||bounds.height!==innerHeight)throw Error('Viewer not fullscreen');
const toolbar=document.querySelector<HTMLElement>('.file-media-actions')!.getBoundingClientRect(),caption=document.querySelector<HTMLElement>('.file-media-caption')!.getBoundingClientRect();if(Math.abs(toolbar.x+toolbar.width/2-innerWidth/2)>1||toolbar.bottom>innerHeight||caption.top<toolbar.bottom||document.querySelector('.file-media-header'))throw Error('Bottom capsule/caption layout incorrect');
if(!button('Previous file').disabled)throw Error('Previous not disabled on first file');
button('Zoom in').click();await until(()=>document.querySelector('output')?.textContent==='125%');
const stage=document.querySelector<HTMLElement>('.file-media-stage')!;stage.dispatchEvent(new WheelEvent('wheel',{deltaY:-100,clientX:180,clientY:150,bubbles:true,cancelable:true}));await until(()=>transform().includes('scale(1.5'));
// Synthetic events cannot capture native pointers. Exercise the same pan handler
// while retaining real browser media decoding and layout.
const capture=Element.prototype.setPointerCapture;Element.prototype.setPointerCapture=function(){};
stage.dispatchEvent(new PointerEvent('pointerdown',{pointerId:7,clientX:100,clientY:100,button:0,bubbles:true,cancelable:true}));const before=transform();stage.dispatchEvent(new PointerEvent('pointermove',{pointerId:7,clientX:140,clientY:120,bubbles:true,cancelable:true}));await until(()=>transform()!==before);stage.dispatchEvent(new PointerEvent('pointerup',{pointerId:7,bubbles:true}));stage.click();await wait();if(closed)throw Error('Panning closed viewer');Element.prototype.setPointerCapture=capture;
button('Fit to window').click();await until(()=>transform()==='translate(0px, 0px) scale(1)');
overlay.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}));await until(()=>document.querySelector('video')&&loaded());
const player=document.querySelector('video')!,videoBounds=player.getBoundingClientRect();if(player.videoWidth!==640||player.videoHeight!==360||videoBounds.height<100)throw Error('Video picture missing or collapsed');
if(!player.controls||!released.includes('/first.png'))throw Error('Video controls/grant release missing');
button('Download').click();if(downloads[0]!=='/clip.webm')throw Error('Wrong video downloaded');
button('Next file').click();await until(()=>document.querySelector('img')?.getAttribute('alt')===entries[2].name&&loaded());
if(!transform().includes('scale(1)'))throw Error('Switch retained transform');
button('Next file').click();await until(()=>document.querySelector('.file-media-error'));if(!button('Next file').disabled)throw Error('Next not disabled on last file');
button('Download').click();if(!downloads.includes('/broken.png'))throw Error('Decode failure download missing');
button('Previous file').click();await until(loaded);await wait(200);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MEDIA_EN');await wait(250);
overlay.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await until(()=>closed===1&&!document.querySelector('.file-media-overlay'));await until(()=>released.includes('/last.png'));
configureLanguage('zh-CN',[]);render();await until(loaded);if(!button('放大')||!button('下一个文件'))throw Error('Chinese controls missing');await wait(200);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MEDIA_ZH');await wait(250);
document.querySelector('img')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));await wait();if(closed!==1)throw Error('Media click closed viewer');
const blank=document.querySelector<HTMLElement>('.file-media-stage')!;Element.prototype.setPointerCapture=function(){};blank.dispatchEvent(new PointerEvent('pointerdown',{pointerId:9,clientX:10,clientY:10,button:0,bubbles:true}));blank.dispatchEvent(new PointerEvent('pointerup',{pointerId:9,bubbles:true}));Element.prototype.setPointerCapture=capture;blank.click();await until(()=>closed===2);URL.revokeObjectURL(videoUrl);console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+String(e)+' '+(e as Error).stack);}};
console.log('FRAMELY_VIEW_READY');
+48
View File
@@ -0,0 +1,48 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {FileThumbnail} from '../ui/src/file-thumbnail';
import '../ui/src/file-manager.css';
const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms));
const until=async(f:()=>unknown)=>{for(let n=0;n<200;n++){if(f())return;await wait();}throw Error('Thumbnail timeout');};
const root=createRoot(document.getElementById('root')!);
const requests:string[]=[],released:string[]=[],cancelled:string[]=[];
let active=0,maxActive=0,imageUrl='',videoUrl='',revision='1';
window.fetch=async(_url,init)=>{
const p=JSON.parse(String(init?.body));
if(p.operation==='preview.release'){released.push(p.token);return new Response(JSON.stringify({result:true}));}
if(p.operation!=='preview')throw Error('Unexpected request');
requests.push(p.path);active++;maxActive=Math.max(maxActive,active);
try{await new Promise<void>((resolve,reject)=>{const timer=setTimeout(resolve,p.path==='/slow.png'?1000:80);init?.signal?.addEventListener('abort',()=>{clearTimeout(timer);cancelled.push(p.path);reject(new DOMException('Aborted','AbortError'));},{once:true});});}
finally{active--;}
return new Response(JSON.stringify({result:{url:p.path==='/bad.png'?'data:image/png;base64,AA==':p.path.endsWith('.webm')?videoUrl:imageUrl,token:p.path}}));
};
const render=(paths:string[],active=true)=>root.render(<div className="file-manager" style={{background:'#222',color:'#ddd',padding:20}}><div id="viewport" style={{height:180,width:360,overflow:'auto'}}>{paths.map(path=><div key={path} data-path={path} style={{height:140,display:'flex',gap:20,alignItems:'center'}}><span style={{width:100,height:100}}><FileThumbnail active={active} entry={{path,name:path,directory:false,size:100,mediaRevision:revision}}><span className="fallback">File icon</span></FileThumbnail></span>{path}</div>)}</div></div>);
const thumbnail=(path:string)=>document.querySelector(`[data-path="${path}"] .file-media-thumbnail`);
async function samples(){
const canvas=document.createElement('canvas');canvas.width=120;canvas.height=80;const ctx=canvas.getContext('2d')!;
ctx.fillStyle='#77b0cf';ctx.fillRect(0,0,120,80);imageUrl=canvas.toDataURL();
const stream=canvas.captureStream(10),recorder=new MediaRecorder(stream,{mimeType:'video/webm;codecs=vp8'}),chunks:Blob[]=[];
recorder.ondataavailable=e=>chunks.push(e.data);
const stopped=new Promise<void>(r=>recorder.onstop=()=>r());recorder.start();
const timer=setInterval(()=>{ctx.fillStyle='#c89665';ctx.fillRect(10,10,100,60);},70);
await wait(1300);recorder.stop();await stopped;clearInterval(timer);stream.getTracks().forEach(t=>t.stop());videoUrl=URL.createObjectURL(new Blob(chunks,{type:'video/webm'}));
}
(window as any).runInstallReviewChecks=async()=>{try{
await samples();render(['/one.png','/two.png','/three.png','/clip.webm','/bad.png']);
await until(()=>thumbnail('/one.png')&&thumbnail('/two.png'));
if(requests.includes('/three.png')||requests.includes('/clip.webm'))throw Error('Offscreen media loaded');
document.getElementById('viewport')!.scrollTop=280;await until(()=>thumbnail('/three.png')&&thumbnail('/clip.webm'));
if(!document.querySelector('[data-path="/clip.webm"] .file-video-marker'))throw Error('Video marker missing');
document.getElementById('viewport')!.scrollTop=560;await until(()=>released.includes('/bad.png'));await wait(100);
if(thumbnail('/bad.png')||!document.querySelector('[data-path="/bad.png"] .fallback'))throw Error('Decode failure did not retain icon');
const count=requests.filter(p=>p==='/one.png').length;document.getElementById('viewport')!.scrollTop=0;await until(()=>thumbnail('/one.png'));
if(requests.filter(p=>p==='/one.png').length!==count)throw Error('Thumbnail cache not reused');
revision='2';render(['/one.png']);await until(()=>requests.filter(p=>p==='/one.png').length===count+1&&thumbnail('/one.png'));
render(['/slow.png']);await until(()=>requests.includes('/slow.png'));render(['/notes.txt']);await until(()=>cancelled.includes('/slow.png'));
if(requests.includes('/notes.txt')||maxActive>2)throw Error('Invalid source request or unbounded parallelism');
render(['/inactive.png'],false);await wait(300);if(requests.includes('/inactive.png'))throw Error('Inactive panel fetched media');
render(['/one.png','/clip.webm']);await until(()=>thumbnail('/one.png')&&thumbnail('/clip.webm'));
console.log('FRAMELY_PREVIEW_INSTALL_FILE_THUMBNAILS');await wait(300);
URL.revokeObjectURL(videoUrl);console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+String(e)+' '+(e as Error).stack);}};
console.log('FRAMELY_VIEW_READY');
+16
View File
@@ -14,4 +14,20 @@ int main(){
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);
}
+22
View File
@@ -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'));
+14
View File
@@ -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.');
+24
View File
@@ -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.');
+2 -1
View File
@@ -14,8 +14,9 @@ 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_"));++loads;*data=(uint8_t*)malloc(4);*len=4;return true;}
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;}
+3 -3
View File
@@ -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()=>{
+3
View File
@@ -11,3 +11,6 @@ g++ -std=c++17 -O2 -I"$cef" -I"$openvr" -I"$base/native" -I"$out" "$base/native/
# 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 -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"
+1 -1
View File
@@ -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"
+27 -1
View File
@@ -3,6 +3,8 @@
"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",
@@ -974,6 +976,30 @@
"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."
"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",
"展示方式": "View",
"列表": "List",
"小图标": "Small icons",
"中图标": "Medium icons",
"大图标": "Large icons",
"选择 {0}": "Select {0}",
"Invalid file view": "Invalid file view",
"Invalid file sort": "Invalid file sort",
"Invalid file sort direction": "Invalid file sort direction",
"缩小": "Zoom out",
"放大": "Zoom in",
"缩放比例": "Zoom level",
"适应窗口": "Fit to window",
"上一个文件": "Previous file",
"下一个文件": "Next file",
"滚轮缩放 · 拖动移动 · ← → 切换 · Esc 关闭": "Scroll to zoom · Drag to pan · ← → Browse · Esc Close",
"预览操作": "Preview controls"
}
}
+37 -1
View File
@@ -3,6 +3,8 @@
"locale": "zh-CN",
"name": "简体中文",
"messages": {
"L2 / R2 按键触发行程": "L2 / R2 按键触发行程",
"默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。": "默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。",
"安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。": "安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。",
"关闭容器": "关闭容器",
"更新应用并保留现有存档和设置。": "更新应用并保留现有存档和设置。",
@@ -964,6 +966,40 @@
"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 就绪状态查询无响应,请导出日志后重试。"
"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 更新包。",
"展示方式": "展示方式",
"列表": "列表",
"小图标": "小图标",
"中图标": "中图标",
"大图标": "大图标",
"选择 {0}": "选择 {0}",
"Invalid file view": "无效的文件展示方式",
"Invalid file sort": "无效的文件排序方式",
"Invalid file sort direction": "无效的文件排序方向",
"缩小": "缩小",
"放大": "放大",
"缩放比例": "缩放比例",
"适应窗口": "适应窗口",
"上一个文件": "上一个文件",
"下一个文件": "下一个文件",
"滚轮缩放 · 拖动移动 · ← → 切换 · Esc 关闭": "滚轮缩放 · 拖动移动 · ← → 切换 · Esc 关闭",
"预览操作": "预览操作"
}
}
+2
View File
@@ -9,3 +9,5 @@
.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%}}
+5 -4
View File
@@ -11,10 +11,11 @@ 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;gamepadSource?:string;gamepadRumble?: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;deleteContainer?:boolean};
@@ -56,12 +57,12 @@ 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('手柄输入')} 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><SwitchRow label={t('手柄震动')} description={t('将游戏的手柄震动反馈回传给当前输入手柄;应用失去 Android 前台状态或手柄断连时停止。')} checked={data.gamepadRumble??true} disabled={busy||loading} onChange={rumble=>void perform('gamepad.settings',{enabled:!!data.gamepadEnabled,rumble})}/></>}
{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 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><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('管理应用'))}
{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)}
+56
View File
@@ -31,6 +31,10 @@
.file-manager .file-tree-entry{gap:0;min-height:38px}
.file-manager .file-tree-entry>button.file-tree-toggle{flex:none;padding:4px;width:26px;min-width:26px;border:0}
.file-manager .file-tree-entry>button:last-child{width:auto;flex:1;min-width:0;padding-left:4px;gap:8px}
/* Keep complete tree names on one line; the tree scrolls horizontally. */
.file-manager .file-tree-scroll>div{width:max-content;min-width:100%}
.file-manager .file-tree-entry>button:last-child{flex:1 0 auto;min-width:max-content}
.file-manager .file-tree-entry>button>span{overflow:visible;text-overflow:clip;white-space:nowrap;flex:none}
.file-manager .file-directory-tree .file-tree{padding-left:12px;border-left:1px solid #ffffff0a;margin-left:12px}
.file-manager .file-side-entry{display:flex;align-items:center;min-width:0;position:relative}
.file-manager .file-side-entry>button:first-child{min-width:0}
@@ -155,3 +159,55 @@
.file-manager .file-context-menu button{display:flex;width:100%;text-align:left;justify-content:flex-start;min-height:34px;font-size:13px;padding:6px 10px}
.file-manager .file-context-menu button:focus-visible{background:#3a3a3f;outline:none}
.file-manager .file-context-menu [role=separator]{height:1px;margin:5px 6px;background:var(--file-line)}
/* File display preference is global; folders retain only their sort order. */
.file-manager .file-view-control .framely-select{min-width:112px}
.file-manager .file-view-control .framely-select button{font-size:13px;min-height:36px;padding:6px 9px;background:transparent!important;border-color:var(--file-line)}
.file-manager .file-icon-grid{--file-tile-width:152px;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--file-tile-width),100%),1fr));gap:12px 8px;padding:20px 8px;align-content:start}
.file-manager .file-icon-grid.icons-small{--file-tile-width:116px;gap:8px 4px}
.file-manager .file-icon-grid.icons-large{--file-tile-width:200px;gap:20px 12px}
.file-manager .file-icon-tile{position:relative;min-width:0;border-radius:8px;align-self:start}
.file-manager .file-icon-tile:hover{background:#ffffff06}
.file-manager .file-icon-tile.selected{background:#3b3b41}
.file-manager .file-tile-open{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;width:100%;padding:16px 8px 12px;gap:10px;white-space:normal}
.file-manager .file-tile-open:hover:not(:disabled){background:transparent}
.file-manager .file-tile-open .file-entry-icon{position:relative;width:96px;height:78px}
.file-manager .icons-small .file-tile-open .file-entry-icon{width:64px;height:54px}
.file-manager .icons-large .file-tile-open .file-entry-icon{width:124px;height:106px}
.file-manager .file-tile-name{font-size:13px;line-height:1.45;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:38px;max-width:100%;text-align:center}
.file-manager .file-link-marker{position:absolute;bottom:0;right:0;background:var(--file-bg);border-radius:4px}
.file-manager .file-tile-check{position:absolute;top:6px;left:6px;z-index:1;display:grid;place-items:center;padding:4px;opacity:0}
.file-manager .file-icon-tile:hover .file-tile-check,.file-manager .file-icon-tile:focus-within .file-tile-check,.file-manager .file-icon-tile.selected .file-tile-check{opacity:1}
@media(hover:none){.file-manager .file-tile-check{opacity:1}}
.file-manager .file-grid-select-all{display:inline-flex;align-items:center;gap:7px;margin-right:16px}
.file-manager .file-grid-skeleton{display:flex;flex-direction:column;align-items:center;gap:16px;padding:16px 8px}
.file-manager .file-grid-skeleton i{width:48px;height:56px;background:#ffffff09;border-radius:6px}
.file-manager .file-grid-skeleton span{width:70%;height:12px;background:#ffffff09;border-radius:4px}
.file-manager .file-thumbnail{position:relative;width:100%;height:100%;display:grid;place-items:center}
.file-manager .file-media-thumbnail{width:100%;height:100%;object-fit:contain;border-radius:5px;user-select:none;pointer-events:none}
.file-manager .file-video-marker{position:absolute;bottom:2px;left:2px;color:#f4f4f6;filter:drop-shadow(0 1px 2px #000);pointer-events:none}
/* Preserve Framely dark theme. DESIGN_VARIANCE: 4, MOTION_INTENSITY: 2, VISUAL_DENSITY: 2.
Bottom capsule uses a CSS glass approximation, not an Apple platform material. */
.file-manager.file-media-overlay{position:fixed;inset:0;z-index:150;display:flex;flex-direction:column;background:#111113f5;color:#ededf0;outline:none;--file-text:#ededf0;--file-muted:#a6a6af}
.file-media-stage{position:relative;flex:1;min-height:0;overflow:hidden;touch-action:none;user-select:none;cursor:grab}
.file-media-stage:active{cursor:grabbing}
.file-media-canvas{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;transform-origin:center;will-change:transform;pointer-events:none}
.file-media-canvas img,.file-media-canvas video{max-width:calc(100% - 48px);max-height:calc(100% - 40px);object-fit:contain;display:block}
.file-media-canvas img{pointer-events:auto;}
.file-media-canvas video{pointer-events:auto;cursor:auto;touch-action:auto}
.file-media-loading,.file-media-error{position:absolute;inset:0;display:flex;flex-direction:column;gap:14px;align-items:center;justify-content:center;padding:40px;text-align:center;color:#b1b1bb;cursor:default}
.file-media-dock{display:flex;flex-direction:column;align-items:center;gap:14px;flex-shrink:0;padding:8px 16px max(20px,env(safe-area-inset-bottom));}
.file-media-actions{display:flex;align-items:center;gap:3px;max-width:100%;padding:7px 9px;border-radius:999px;border:1px solid #ffffff1f;background:#2a2a2dd9;backdrop-filter:blur(16px);box-shadow:inset 0 1px 0 #ffffff12,0 8px 30px #05050750;}
.file-manager .file-media-actions button{display:grid;place-items:center;width:42px;height:42px;flex-shrink:0;padding:0;border:0;border-radius:50%;background:transparent;color:#ececef;box-shadow:none;transition:background .16s ease,transform .16s ease;}
.file-manager .file-media-actions button:hover:not(:disabled){background:#ffffff12;}
.file-manager .file-media-actions button:active:not(:disabled){transform:scale(.94);background:#ffffff1b;}
.file-manager .file-media-actions button:focus-visible{outline:2px solid #c5c5cc;outline-offset:1px;}
.file-manager .file-media-actions button:disabled{color:#77777f;opacity:1;cursor:default;}
.file-media-actions output{min-width:46px;text-align:center;color:#c5c5cd;font-size:12px;font-variant-numeric:tabular-nums;}
.file-media-divider{height:18px;width:1px;background:#ffffff18;margin:0 6px;flex-shrink:0;}
.file-media-caption{max-width:min(720px,90vw);text-align:center;}
.file-media-caption p{margin:0;color:#e1e1e7;font-size:15px;font-weight:500;line-height:1.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.file-media-caption>span{display:block;margin-top:4px;color:#a6a6b0;font-size:12px;font-variant-numeric:tabular-nums;}
@media(max-width:640px){.file-media-dock{padding-left:10px;padding-right:10px;gap:11px}.file-media-actions{gap:0;padding:5px 7px}.file-manager .file-media-actions button{width:36px;height:36px}.file-media-actions output{min-width:38px;font-size:11px}.file-media-divider{margin:0 4px}.file-media-caption p{font-size:13px}.file-media-canvas img,.file-media-canvas video{max-width:calc(100% - 24px);max-height:calc(100% - 24px)}}
@media(max-width:360px){.file-manager .file-media-actions button{width:33px;height:36px}.file-media-divider{margin:0 3px}.file-media-actions output{min-width:34px}}
@media(prefers-reduced-transparency:reduce){.file-media-actions{background:#2a2a2d;backdrop-filter:none}.file-manager.file-media-overlay{background:#111113}}
@media(prefers-reduced-motion:reduce){.file-manager .file-media-actions button{transition:none;}}
+43 -11
View File
@@ -1,6 +1,8 @@
import React,{useEffect,useRef,useState} from 'react';
import {IconFolder,IconFile,IconArrowUp,IconRefresh,IconUpload,IconDownload,IconStar,IconTrash,IconChevronRight,IconChevronDown,IconSearch,IconFolderPlus,IconPlus,IconCopy,IconCut,IconClipboard,IconDots,IconPencil,IconX,IconArrowDown,IconHome,IconDeviceSdCard,IconFileText,IconPhoto,IconMovie,IconFileZip,IconExternalLink,IconInfoCircle,IconArrowLeft} from '@tabler/icons-react';
import {managerApi,ManagerError} from './manager-api';
import {FileThumbnail} from './file-thumbnail';
import {FileMediaPreview,mediaKind,MediaEntry} from './file-media-preview';
import {api} from './api';
import {uploadFile} from './upload';
import {Select} from './localized-select';
@@ -8,9 +10,12 @@ import {SwitchRow} from './switch';
import {t} from './i18n';
import './manager-tools.css';
import './file-manager.css';
type Entry={name:string;path:string;directory:boolean;symlink:boolean;size:number;modified?:number;mode:number;uid:number;gid:number;linkTarget?:string};
type Entry={name:string;path:string;directory:boolean;symlink:boolean;size:number;modified?:number;mediaRevision?:string|null;mode:number;uid:number;gid:number;linkTarget?:string};
type Listing={path:string;parent?:string;entries:Entry[];total:number;roots:{name:string;path:string}[]};
type Bookmark={name:string;path:string};
type FileSort='name'|'size'|'modified';
type FileView='list'|'icons-small'|'icons-medium'|'icons-large';
type FilePreferences={view:FileView;folders:Record<string,{sort:FileSort;descending:boolean}>};
type Job={id:string;phase:string;bytes?:number;totalBytes?:number;completed?:number;total?:number;visited?:number;matches?:number;error?:string;result?:any};
type Dialog={title:string;kind:string;path?:string;name?:string;value?:string;entry?:Entry;files?:File[]};
function bytes(n:number){return n<1024?`${n} B`:n<1024**2?`${(n/1024).toFixed(1)} KiB`:n<1024**3?`${(n/1024**2).toFixed(1)} MiB`:`${(n/1024**3).toFixed(1)} GiB`;}
@@ -26,10 +31,19 @@ function FolderTree({name,path,onOpen,current,depth=0}:{name:string;path:string;
}catch(e){if(id===request.current){setError(String(e));setExpanded(true);}}}
useEffect(()=>{if(depth<20&&(depth===0||ancestor||current===path))void expand(0,ancestor);return()=>{request.current++;};},[path,current]);
useEffect(()=>{if(current!==path||!row.current)return;const node=row.current,sidebar=node.closest<HTMLElement>('.file-tree-scroll');if(!sidebar)return;const a=node.getBoundingClientRect(),b=sidebar.getBoundingClientRect();if(a.top<b.top)sidebar.scrollTop+=a.top-b.top-8;else if(a.bottom>b.bottom)sidebar.scrollTop+=a.bottom-b.bottom+8;},[current,path]);
return <div><div ref={row} className="file-side-entry file-tree-entry"><button className="file-tree-toggle" disabled={depth>=20} aria-expanded={expanded} aria-label={t(expanded?'收起文件夹':'展开文件夹')} onClick={()=>{if(expanded){request.current++;setExpanded(false);}else void expand();}}>{expanded?<IconChevronDown size={16}/>:<IconChevronRight size={16}/>}</button><button aria-current={current===path?'location':undefined} className={current===path?'active':''} onClick={()=>onOpen(path)}><IconFolder size={18}/><span>{name}</span></button></div>{error&&expanded&&<small>{error}</small>}{expanded&&<div className="file-tree">{children.map(e=><FolderTree key={e.path} name={e.name} path={e.path} current={current} onOpen={onOpen} depth={depth+1}/>)}{more&&<button onClick={()=>void expand(offset)}>{t('加载更多')}</button>}</div>}</div>;
return <div><div ref={row} className="file-side-entry file-tree-entry"><button className="file-tree-toggle" disabled={depth>=20} aria-expanded={expanded} aria-label={t(expanded?'收起文件夹':'展开文件夹')} onClick={()=>{if(expanded){request.current++;setExpanded(false);}else void expand();}}>{expanded?<IconChevronDown size={16}/>:<IconChevronRight size={16}/>}</button><button title={path} aria-current={current===path?'location':undefined} className={current===path?'active':''} onClick={()=>onOpen(path)}><IconFolder size={18}/><span>{name}</span></button></div>{error&&expanded&&<small>{error}</small>}{expanded&&<div className="file-tree">{children.map(e=><FolderTree key={e.path} name={e.name} path={e.path} current={current} onOpen={onOpen} depth={depth+1}/>)}{more&&<button onClick={()=>void expand(offset)}>{t('加载更多')}</button>}</div>}</div>;
}
export function FileManager({active=true}:{active?:boolean}){
const[data,setData]=useState<Listing>({path:'',entries:[],total:0,roots:[]}),[path,setPath]=useState(''),[address,setAddress]=useState(''),[query,setQuery]=useState(''),[hidden,setHidden]=useState(false),[sort,setSort]=useState('name'),[descending,setDescending]=useState(false),[offset,setOffset]=useState(0),[selected,setSelected]=useState<string[]>([]),[bookmarks,setBookmarks]=useState<Bookmark[]>([]),[error,setError]=useState(''),[busy,setBusy]=useState(false),[loading,setLoading]=useState(true),[dialog,setDialog]=useState<Dialog|null>(null),[value,setValue]=useState(''),[policy,setPolicy]=useState('keep'),[permanent,setPermanent]=useState(false),[job,setJob]=useState<Job|null>(null),[clipboard,setClipboard]=useState<{paths:string[];cut:boolean}|null>(null),[preview,setPreview]=useState<{entry:Entry;url:string;video:boolean}|null>(null),[editor,setEditor]=useState<{path:string;text:string;revision:string}|null>(null),[modified,setModified]=useState(false),[trash,setTrash]=useState<any[]|null>(null),[upload,setUpload]=useState<{name:string;received:number;total:number}|null>(null),[results,setResults]=useState<any[]|null>(null),[counter,setCounter]=useState(0),[context,setContext]=useState(false),[bookmarkMenu,setBookmarkMenu]=useState<string|null>(null),[outcomes,setOutcomes]=useState<any[]>([]),[menu,setMenu]=useState<{x:number;y:number;blank:boolean}|null>(null);
const[data,setData]=useState<Listing>({path:'',entries:[],total:0,roots:[]}),[path,setPath]=useState(''),[address,setAddress]=useState(''),[query,setQuery]=useState(''),[hidden,setHidden]=useState(false),[offset,setOffset]=useState(0),[selected,setSelected]=useState<string[]>([]),[bookmarks,setBookmarks]=useState<Bookmark[]>([]),[error,setError]=useState(''),[busy,setBusy]=useState(false),[loading,setLoading]=useState(true),[dialog,setDialog]=useState<Dialog|null>(null),[value,setValue]=useState(''),[policy,setPolicy]=useState('keep'),[permanent,setPermanent]=useState(false),[job,setJob]=useState<Job|null>(null),[clipboard,setClipboard]=useState<{paths:string[];cut:boolean}|null>(null),[preview,setPreview]=useState<{entries:MediaEntry[];initial:number}|null>(null),[editor,setEditor]=useState<{path:string;text:string;revision:string}|null>(null),[modified,setModified]=useState(false),[trash,setTrash]=useState<any[]|null>(null),[upload,setUpload]=useState<{name:string;received:number;total:number}|null>(null),[results,setResults]=useState<any[]|null>(null),[counter,setCounter]=useState(0),[context,setContext]=useState(false),[bookmarkMenu,setBookmarkMenu]=useState<string|null>(null),[outcomes,setOutcomes]=useState<any[]>([]),[menu,setMenu]=useState<{x:number;y:number;blank:boolean}|null>(null);
const[filePreferences,setFilePreferences]=useState<FilePreferences>({view:'list',folders:{}}),[preferencesReady,setPreferencesReady]=useState(false);
const preferenceWrites=useRef<Promise<unknown>>(Promise.resolve());
const sort=filePreferences.folders[path]?.sort??'name',descending=filePreferences.folders[path]?.descending??false,view=filePreferences.view;
function saveFilePreferences(patch:Record<string,unknown>,update:(previous:FilePreferences)=>FilePreferences){
setFilePreferences(update);
preferenceWrites.current=preferenceWrites.current.catch(()=>{}).then(()=>managerApi('files',{operation:'view.preferences',...patch})).catch(e=>setError(String(e)));
}
function changeSort(nextSort:FileSort=sort,nextDescending:boolean=descending){if(!path||!preferencesReady)return;setOffset(0);setResults(null);saveFilePreferences({path,sort:nextSort,descending:nextDescending},previous=>({...previous,folders:{...previous.folders,[path]:{sort:nextSort,descending:nextDescending}}}));}
function changeView(next:FileView){saveFilePreferences({view:next},previous=>({...previous,view:next}));}
const layout=useRef<HTMLDivElement>(null),separator=useRef<HTMLDivElement>(null),menuElement=useRef<HTMLDivElement>(null),resize=useRef<{id:number;x:number;width:number;previous:string}|null>(null);
const sidebarKey='framely-file-sidebar-width';
function limitWidth(width:number){return Math.max(132,Math.min(width,480,(layout.current?.clientWidth??1200)*.4));}
@@ -43,9 +57,10 @@ export function FileManager({active=true}:{active?:boolean}){
useEffect(()=>{if(!modified)return;const guard=(event:BeforeUnloadEvent)=>{event.preventDefault();event.returnValue='';};window.addEventListener('beforeunload',guard);return()=>window.removeEventListener('beforeunload',guard);},[modified]);
useEffect(()=>{if(!active)return;const back=(event:Event)=>{if(event instanceof KeyboardEvent&&event.key!=='Escape')return;if(menu){setMenu(null);}else if(dialog){setDialog(null);}else if(preview){setPreview(null);}else if(editor){if(modified)openDialog({title:t('未保存的修改'),kind:'editor.discard'});else setEditor(null);}};window.addEventListener('keydown',back);window.addEventListener('framely.back',back);return()=>{window.removeEventListener('keydown',back);window.removeEventListener('framely.back',back);};},[active,menu,dialog,preview,editor,modified]);
const refresh=()=>setCounter(n=>n+1);
function navigate(next:string){setMenu(null);setError('');setData(previous=>({...previous,path:next,entries:[],total:0,parent:next==='/'?undefined:next.replace(/\/?[^/]+\/?$/,'')||'/'}));setLoading(true);setPath(next);setAddress(next);setSelected([]);setOffset(0);setResults(null);setQuery('');setTrash(null);setContext(false);}
function navigate(next:string){setMenu(null);setError('');setData(previous=>({...previous,path:next,entries:[],total:0,parent:next==='/'?undefined:next.replace(/\/?[^/]+\/?$/,'')||'/'}));setLoading(true);setPath(next);setAddress(next);setSelected([]);setOffset(0);setResults(null);setQuery('');setTrash(null);setContext(false);refresh();}
useEffect(()=>{let live=true;managerApi<Bookmark[]>('files',{operation:'preferences'}).then(v=>{if(live)setBookmarks(v);}).catch(e=>setError(String(e)));return()=>{live=false;};},[]);
useEffect(()=>{const seq=++sequence.current;setLoading(true);const timer=setTimeout(()=>{managerApi<Listing>('files',{operation:'list',path:path||undefined,query,hidden,sort,descending,offset}).then(v=>{if(seq!==sequence.current)return;setData(v);setAddress(v.path);setLoading(false);if(path!==v.path)setPath(v.path);}).catch(e=>{if(seq===sequence.current){setError(String(e));setLoading(false);setSelected([]);setData(previous=>({...previous,entries:[],total:0}));}});},120);return()=>clearTimeout(timer);},[path,query,hidden,sort,descending,offset,counter]);
useEffect(()=>{let live=true;managerApi<FilePreferences>('files',{operation:'view.preferences'}).then(v=>{if(live)setFilePreferences(v);}).catch(e=>{if(live)setError(String(e));}).finally(()=>{if(live)setPreferencesReady(true);});return()=>{live=false;};},[]);
useEffect(()=>{if(!preferencesReady)return;const seq=++sequence.current;setLoading(true);const timer=setTimeout(()=>{managerApi<Listing>('files',{operation:'list',path:path||undefined,query,hidden,sort,descending,offset}).then(v=>{if(seq!==sequence.current)return;if(path!==v.path){setPath(v.path);setAddress(v.path);const saved=filePreferences.folders[v.path];if((saved?.sort??'name')!==sort||(saved?.descending??false)!==descending){setData(previous=>({...previous,path:v.path,entries:[],total:0}));return;}}setData(v);setAddress(v.path);setLoading(false);}).catch(e=>{if(seq===sequence.current){setError(String(e));setLoading(false);setSelected([]);setData(previous=>({...previous,entries:[],total:0}));}});},120);return()=>clearTimeout(timer);},[path,query,hidden,sort,descending,offset,counter,preferencesReady]);
useEffect(()=>setSelected([]),[query,offset,sort,descending,hidden]);
const shown=results??data.entries;
async function saveBookmarks(next:Bookmark[]){try{await managerApi('files',{operation:'preferences',value:next});setBookmarks(next);}catch(e){setError(String(e));throw e;}}
@@ -54,7 +69,17 @@ export function FileManager({active=true}:{active?:boolean}){
async function run(p:any){try{await perform(p);}catch{}}
function download(url:string){const a=document.createElement('a');a.href=url;a.download='';document.body.appendChild(a);a.click();a.remove();}
async function getDownload(paths=selected){try{if(paths.length===1&&!shown.find(e=>e.path===paths[0])?.directory){const v=await perform({operation:'download',path:paths[0]});download(v.url);}else{const v=await perform({operation:'download.batch',paths});download(v.url);}}catch{}}
async function open(e:Entry){if(e.directory){navigate(e.path);return;}const ext=e.name.split('.').pop()?.toLowerCase()??'';if(['png','jpg','jpeg','gif','webp','avif','bmp','mp4','webm','mov','m4v'].includes(ext)){try{const v=await perform({operation:'preview',path:e.path});setPreview({entry:e,url:v.url,video:['mp4','webm','mov','m4v'].includes(ext)});}catch{}return;}try{const v=await perform({operation:'read',path:e.path});setEditor({path:e.path,...v});setModified(false);}catch{openDialog({title:t('文件属性'),kind:'properties',entry:e,value:e.mode.toString(8)});}}
async function open(e:Entry){
if(e.directory){navigate(e.path);return;}
if(mediaKind(e)){const entries=shown.filter(item=>!item.directory&&mediaKind(item));setMenu(null);setError('');setPreview({entries,initial:Math.max(0,entries.findIndex(item=>item.path===e.path))});return;}
// UTF-8 text remains editable. Other formats download instead of opening properties.
if(e.size>5*1024*1024){await getDownload([e.path]);return;}
setBusy(true);setError('');
try{const v=await managerApi('files',{operation:'read',path:e.path});setEditor({path:e.path,...v});setModified(false);}
catch(error){const raw=error instanceof ManagerError?error.raw:String(error);if(['Binary files cannot be edited as text','Only UTF-8 text can be edited','Text editor supports files up to 5 MiB'].some(message=>raw.includes(message)))await getDownload([e.path]);else setError(String(error));}
finally{setBusy(false);}
}
useEffect(()=>()=>cancelUpload.current?.abort(),[]);
async function uploads(files:File[]){
setBusy(true);setError('');const controller=new AbortController();cancelUpload.current=controller;let id='';
@@ -104,6 +129,8 @@ export function FileManager({active=true}:{active?:boolean}){
const bookmarkCurrent=bookmarks.some(b=>b.path===path);
const fileKind=(entry:Entry)=>entry.directory?t('文件夹'):entry.name.split('.').length>1?entry.name.split('.').pop()!.toUpperCase():t('文件');
const formatDate=(seconds?:number)=>seconds?new Date(seconds*1000).toLocaleString(document.documentElement.lang,{year:'numeric',month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'-';
const entryIcon=(entry:Entry,size:number)=>entry.directory?<IconFolder size={size}/>:['png','jpg','jpeg','gif','webp','avif','bmp'].includes(entry.name.split('.').pop()?.toLowerCase()??'')?<IconPhoto size={size}/>:['mp4','webm','mov','m4v'].includes(entry.name.split('.').pop()?.toLowerCase()??'')?<IconMovie size={size}/>:['zip','tar','gz'].includes(entry.name.split('.').pop()?.toLowerCase()??'')?<IconFileZip size={size}/>:<IconFileText size={size}/>;
function selectEntry(entry:Entry,checked:boolean){setSelected(previous=>checked?[...new Set([...previous,entry.path])]:previous.filter(path=>path!==entry.path));}
const actions=<>
<button role={menu?'menuitem':undefined} disabled={!selectedCount||busy} onClick={()=>{setClipboard({paths:selected,cut:false});setContext(false);}}><IconCopy size={18}/>{t('复制')}</button>
<button role={menu?'menuitem':undefined} disabled={!selectedCount||busy} onClick={()=>{setClipboard({paths:selected,cut:true});setContext(false);}}><IconCut size={18}/>{t('剪切')}</button>
@@ -138,11 +165,16 @@ export function FileManager({active=true}:{active?:boolean}){
<button disabled={busy} onClick={()=>directoryInput.current?.click()}><IconFolderPlus size={18}/>{t('上传文件夹')}</button>
<button disabled={!selectedCount||busy} onClick={()=>void getDownload()}><IconDownload size={18}/>{t('下载文件')}</button><button className={context?'active':''} aria-expanded={context} onClick={()=>setContext(v=>!v)}><IconDots size={18}/>{t('操作')}</button></div></div>
{context&&<div className="file-operation-panel">{actions}</div>}
<div className="file-filter-bar"><label className="file-search"><span className="file-sr-only">{t('搜索文件')}</span><IconSearch size={18}/><input aria-label={t('搜索文件')} placeholder={t('搜索当前目录')} value={query} onChange={e=>{setQuery(e.target.value);setOffset(0);setResults(null);}}/>{query&&<button className="file-icon-button" aria-label={t('清除选择')} onClick={()=>{setQuery('');setResults(null);}}><IconX size={16}/></button>}</label><button disabled={!query||busy} onClick={()=>void perform({operation:'search',path,query}).then(v=>{setResults(v.entries);setSelected([]);}).catch(()=>{})}>{t('搜索子目录')}</button><div className="file-sort"><Select label={t('排序')} value={sort} onChange={setSort} options={[{value:'name',label:t('名称')},{value:'size',label:t('大小')},{value:'modified',label:t('修改时间')}]}/><button className="file-icon-button" aria-label={t(descending?'降序':'升序')} onClick={()=>setDescending(v=>!v)}>{descending?<IconArrowDown size={18}/>:<IconArrowUp size={18}/>}</button></div><SwitchRow label={t('显示隐藏文件')} checked={hidden} onChange={setHidden}/></div>
<div className="file-list-scroll" aria-busy={loading} onContextMenu={event=>showMenu(event)}><table className="file-table"><thead><tr><th className="file-check"><input type="checkbox" aria-label={t('选择本页')} checked={shown.length>0&&shown.every(entry=>selected.includes(entry.path))} onChange={event=>setSelected(event.target.checked?shown.map(entry=>entry.path):[])}/></th><th>{t('名称')}</th><th className="file-kind">{t('类型')}</th><th className="file-size">{t('大小')}</th><th className="file-time">{t('修改时间')}</th></tr></thead><tbody>
{loading&&!shown.length?Array.from({length:5},(_,index)=><tr className="file-skeleton" key={index} aria-hidden="true"><td/><td><i/></td><td className="file-kind"><i/></td><td className="file-size"><i/></td><td className="file-time"><i/></td></tr>):shown.map(entry=><tr key={entry.path} className={selected.includes(entry.path)?'selected':''} onContextMenu={event=>showMenu(event,entry)}><td className="file-check"><input type="checkbox" aria-label={entry.name} checked={selected.includes(entry.path)} onChange={event=>setSelected(v=>event.target.checked?[...new Set([...v,entry.path])]:v.filter(p=>p!==entry.path))}/></td><td><button className="file-name" onClick={()=>void open(entry)}><span className={`file-entry-icon ${entry.directory?'folder':''}`}>{entry.directory?<IconFolder size={22}/>:['png','jpg','jpeg','gif','webp','avif','bmp'].includes(entry.name.split('.').pop()?.toLowerCase()??'')?<IconPhoto size={22}/>:['mp4','webm','mov','m4v'].includes(entry.name.split('.').pop()?.toLowerCase()??'')?<IconMovie size={22}/>:['zip','tar','gz'].includes(entry.name.split('.').pop()?.toLowerCase()??'')?<IconFileZip size={22}/>:<IconFileText size={22}/>}</span><span>{entry.name}</span>{entry.symlink&&<IconExternalLink size={14}/>}</button></td><td className="file-kind">{fileKind(entry)}</td><td className="file-size">{entry.directory?'-':bytes(entry.size)}</td><td className="file-time">{formatDate(entry.modified)}</td></tr>)}</tbody></table>
<div className="file-filter-bar"><label className="file-search"><span className="file-sr-only">{t('搜索文件')}</span><IconSearch size={18}/><input aria-label={t('搜索文件')} placeholder={t('搜索当前目录')} value={query} onChange={e=>{setQuery(e.target.value);setOffset(0);setResults(null);}}/>{query&&<button className="file-icon-button" aria-label={t('清除选择')} onClick={()=>{setQuery('');setResults(null);}}><IconX size={16}/></button>}</label><button disabled={!query||busy} onClick={()=>void perform({operation:'search',path,query}).then(v=>{setResults(v.entries);setSelected([]);}).catch(()=>{})}>{t('搜索子目录')}</button><div className="file-sort"><Select label={t('排序')} value={sort} disabled={!preferencesReady||!path||loading} onChange={value=>changeSort(value as FileSort)} options={[{value:'name',label:t('名称')},{value:'size',label:t('大小')},{value:'modified',label:t('修改时间')}]}/><button className="file-icon-button" aria-label={t(descending?'降序':'升序')} disabled={!preferencesReady||!path||loading} onClick={()=>changeSort(sort,!descending)}>{descending?<IconArrowDown size={18}/>:<IconArrowUp size={18}/>}</button></div><div className="file-view-control"><Select label={t('展示方式')} value={view} disabled={!preferencesReady} onChange={value=>changeView(value as FileView)} options={[{value:'list',label:t('列表')},{value:'icons-small',label:t('小图标')},{value:'icons-medium',label:t('中图标')},{value:'icons-large',label:t('大图标')}]}/></div><SwitchRow label={t('显示隐藏文件')} checked={hidden} onChange={setHidden}/></div>
<div className="file-list-scroll" aria-busy={loading} onContextMenu={event=>showMenu(event)}>{view==='list'?<table className="file-table"><thead><tr><th className="file-check"><input type="checkbox" aria-label={t('选择本页')} checked={shown.length>0&&shown.every(entry=>selected.includes(entry.path))} onChange={event=>setSelected(event.target.checked?shown.map(entry=>entry.path):[])}/></th><th>{t('名称')}</th><th className="file-kind">{t('类型')}</th><th className="file-size">{t('大小')}</th><th className="file-time">{t('修改时间')}</th></tr></thead><tbody>
{loading&&!shown.length?Array.from({length:5},(_,index)=><tr className="file-skeleton" key={index} aria-hidden="true"><td/><td><i/></td><td className="file-kind"><i/></td><td className="file-size"><i/></td><td className="file-time"><i/></td></tr>):shown.map(entry=><tr key={entry.path} className={selected.includes(entry.path)?'selected':''} onContextMenu={event=>showMenu(event,entry)}><td className="file-check"><input type="checkbox" aria-label={entry.name} checked={selected.includes(entry.path)} onChange={event=>setSelected(v=>event.target.checked?[...new Set([...v,entry.path])]:v.filter(p=>p!==entry.path))}/></td><td><button className="file-name" onClick={()=>void open(entry)}><span className={`file-entry-icon ${entry.directory?'folder':''}`}>{entryIcon(entry,22)}</span><span>{entry.name}</span>{entry.symlink&&<IconExternalLink size={14}/>}</button></td><td className="file-kind">{fileKind(entry)}</td><td className="file-size">{entry.directory?'-':bytes(entry.size)}</td><td className="file-time">{formatDate(entry.modified)}</td></tr>)}</tbody></table>:<div className={`file-icon-grid ${view}`}>
{loading&&!shown.length?Array.from({length:8},(_,index)=><div className="file-grid-skeleton" aria-hidden="true" key={index}><i/><span/></div>):shown.map(entry=><div key={entry.path} className={`file-icon-tile ${selected.includes(entry.path)?'selected':''}`} onContextMenu={event=>showMenu(event,entry)}>
<label className="file-tile-check"><input type="checkbox" aria-label={t('选择 {0}',{0:entry.name})} checked={selected.includes(entry.path)} onChange={event=>selectEntry(entry,event.target.checked)}/></label>
<button className="file-tile-open" title={entry.name} onClick={()=>void open(entry)}><span className={`file-entry-icon ${entry.directory?'folder':''}`}><FileThumbnail entry={entry} active={active}>{entryIcon(entry,view==='icons-large'?96:view==='icons-medium'?68:44)}</FileThumbnail>{entry.symlink&&<IconExternalLink className="file-link-marker" size={16}/>}</span><span className="file-tile-name">{entry.name}</span></button>
</div>)}
</div>}
{!loading&&!shown.length&&<div className="file-empty"><IconFolder size={40}/><h3>{t(query?'没有匹配的文件':'此文件夹为空')}</h3><p>{t(query?'试试其他名称,或搜索子目录。':'上传文件或新建文件夹。')}</p></div>}</div>
<footer className="file-status-bar"><span>{selectedCount?t('已选择 {0} 项',{0:selectedCount}):t('共 {0} 项',{0:results?results.length:data.total})}{clipboard&&<small>{t(clipboard.cut?'已剪切 {0} 项':'已复制 {0} 项',{0:clipboard.paths.length})}</small>}</span>{!results&&<div className="file-pagination"><button className="file-icon-button" aria-label={t('上一页')} disabled={offset===0} onClick={()=>setOffset(v=>Math.max(0,v-200))}><IconChevronRight className="file-chevron-back" size={18}/></button><span>{Math.floor(offset/200)+1} / {Math.max(1,Math.ceil(data.total/200))}</span><button className="file-icon-button" aria-label={t('下一页')} disabled={offset+200>=data.total} onClick={()=>setOffset(v=>v+200)}><IconChevronRight size={18}/></button></div>}</footer>
<footer className="file-status-bar"><span>{view!=='list'&&!trash&&<label className="file-grid-select-all"><input type="checkbox" aria-label={t('选择本页')} checked={shown.length>0&&shown.every(entry=>selected.includes(entry.path))} onChange={event=>setSelected(event.target.checked?shown.map(entry=>entry.path):[])}/>{t('选择本页')}</label>}{selectedCount?t('已选择 {0} 项',{0:selectedCount}):t('共 {0} 项',{0:results?results.length:data.total})}{clipboard&&<small>{t(clipboard.cut?'已剪切 {0} 项':'已复制 {0} 项',{0:clipboard.paths.length})}</small>}</span>{!results&&<div className="file-pagination"><button className="file-icon-button" aria-label={t('上一页')} disabled={offset===0} onClick={()=>setOffset(v=>Math.max(0,v-200))}><IconChevronRight className="file-chevron-back" size={18}/></button><span>{Math.floor(offset/200)+1} / {Math.max(1,Math.ceil(data.total/200))}</span><button className="file-icon-button" aria-label={t('下一页')} disabled={offset+200>=data.total} onClick={()=>setOffset(v=>v+200)}><IconChevronRight size={18}/></button></div>}</footer>
</>}
</div></div>
{active&&menu&&<div ref={menuElement} className="file-context-menu" role="menu" aria-label={t('文件操作')} style={{left:menu.x,top:menu.y}} onContextMenu={event=>event.preventDefault()} onClick={event=>{if((event.target as HTMLElement).closest('button:not(:disabled)'))setMenu(null);}} onKeyDown={event=>{const buttons=Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));if(['ArrowDown','ArrowUp','Home','End'].includes(event.key)){event.preventDefault();const index=buttons.indexOf(document.activeElement as HTMLButtonElement),next=event.key==='Home'?0:event.key==='End'?buttons.length-1:(index+(event.key==='ArrowDown'?1:-1)+buttons.length)%buttons.length;buttons[next]?.focus();}if(event.key==='Escape')setMenu(null);}}>
@@ -152,7 +184,7 @@ export function FileManager({active=true}:{active?:boolean}){
{dialog.kind==='delete'&&<><p>{t('请选择删除方式。')}</p><Select label={t('删除方式')} value={permanent?'permanent':'trash'} onChange={v=>setPermanent(v==='permanent')} options={[{value:'trash',label:t('移至回收站(30 天)')},{value:'permanent',label:t('永久删除')}]}/>{permanent&&<p>{t('永久删除无法撤销。')}</p>}</>}
{['paste','upload','compress','extract','trash.restore'].includes(dialog.kind)&&<><p>{t('遇到同名文件时')}</p><Select label={t('同名处理')} value={policy} onChange={setPolicy} options={[{value:'keep',label:t('保留两份')},{value:'skip',label:t('跳过')},...(dialog.kind==='trash.restore'?[]:[{value:'overwrite',label:t('覆盖')}])]}/>{policy==='overwrite'&&<p>{t('确认后会覆盖同名文件或整个同名文件夹。')}</p>}</>}{dialog.kind==='compress'&&<p>{t('支持 .zip、.tar 和 .tar.gz。')}</p>}{dialog.kind==='trash.purge'&&<p>{t('永久删除无法撤销。')}</p>}{dialog.kind==='editor.discard'&&<p>{t('关闭将丢弃未保存的修改。')}</p>}{dialog.kind==='editor.overwrite'&&<p>{t('文件已被外部修改。可重新加载,或确认覆盖。')}</p>}
<footer>{dialog.kind==='editor.overwrite'&&<button onClick={()=>void perform({operation:'read',path:editor?.path}).then(v=>{setEditor(e=>e?{...e,...v}:null);setModified(false);setDialog(null);}).catch(()=>{})}>{t('重新加载')}</button>}<button disabled={busy} onClick={()=>setDialog(null)}>{t('取消')}</button><button disabled={busy} className={permanent||dialog.kind==='trash.purge'?'danger':'primary'} onClick={()=>void submit()}>{t('确认')}</button></footer></div>}
{active&&preview&&<div className="modal file-preview" role="dialog" aria-modal="true" aria-label={preview.entry.name}><h2>{preview.entry.name}</h2>{error&&<p className="error">{error}</p>}{preview.video?<video src={preview.url} controls playsInline onError={()=>setError(t('浏览器无法播放此格式,请下载后打开。'))}/>:<img src={preview.url} alt={preview.entry.name} onError={()=>setError(t('浏览器无法预览此格式,请下载后打开。'))}/>}<footer><button onClick={()=>void getDownload([preview.entry.path])}>{t('下载文件')}</button><button onClick={()=>setPreview(null)}>{t('关闭')}</button></footer></div>}
{active&&preview&&<FileMediaPreview entries={preview.entries} initial={preview.initial} onClose={()=>setPreview(null)} onDownload={path=>void getDownload([path])}/>}
{active&&editor&&!dialog&&<div className="modal file-editor-modal" role="dialog" aria-modal="true"><h2>{editor.path.split('/').pop()}{modified?' *':''}</h2>{error&&<p className="error">{error}</p>}<textarea aria-label={t('文本内容')} className="file-editor" value={editor.text} onChange={e=>{setEditor(v=>v?{...v,text:e.target.value}:null);setModified(true);}}/><footer><button disabled={busy} onClick={()=>void saveText()}>{t('保存')}</button><button onClick={()=>{if(!modified)setEditor(null);else openDialog({title:t('未保存的修改'),kind:'editor.discard'});}}>{t('关闭')}</button></footer></div>}
</section>;
}
+82
View File
@@ -0,0 +1,82 @@
import React,{useEffect,useRef,useState} from 'react';
import {createPortal} from 'react-dom';
import {IconChevronLeft,IconChevronRight,IconDownload,IconX,IconZoomIn,IconZoomOut,IconArrowsMaximize} from '@tabler/icons-react';
import {managerApi} from './manager-api';
import {t} from './i18n';
export type MediaEntry={path:string;name:string};
const images=new Set(['png','jpg','jpeg','gif','webp','avif','bmp']),videos=new Set(['mp4','webm','mov','m4v']);
export function mediaKind(entry:MediaEntry){const ext=entry.name.split('.').pop()?.toLowerCase()??'';return images.has(ext)?'image':videos.has(ext)?'video':'';}
type Transform={scale:number;x:number;y:number};
const fitted:Transform={scale:1,x:0,y:0};
export function FileMediaPreview({entries,initial,onClose,onDownload}:{entries:MediaEntry[];initial:number;onClose:()=>void;onDownload:(path:string)=>void}){
const[index,setIndex]=useState(initial),[source,setSource]=useState<{path:string;url:string}|null>(null),[error,setError]=useState(''),[ready,setReady]=useState(false);
const entry=entries[index],video=mediaKind(entry)==='video',url=source?.path===entry.path?source.url:'';
const overlay=useRef<HTMLDivElement>(null),stage=useRef<HTMLDivElement>(null),view=useRef(fitted),points=useRef(new Map<number,{x:number;y:number}>());
const readyState=useRef(ready);readyState.current=ready;
const blankClick=useRef(true),pressOrigin=useRef({x:0,y:0});
const canvas=useRef<HTMLDivElement>(null),level=useRef<HTMLOutputElement>(null),zoomIn=useRef<HTMLButtonElement>(null),zoomOut=useRef<HTMLButtonElement>(null);
const callbacks=useRef({onClose,onDownload});callbacks.current={onClose,onDownload};
function apply(next:Transform){view.current=next;if(canvas.current)canvas.current.style.transform=`translate(${next.x}px, ${next.y}px) scale(${next.scale})`;if(level.current)level.current.textContent=Math.round(next.scale*100)+'%';if(zoomIn.current)zoomIn.current.disabled=!readyState.current||next.scale>=8;if(zoomOut.current)zoomOut.current.disabled=!readyState.current||next.scale<=.25;}
useEffect(()=>apply(view.current),[ready]);
function zoom(scale:number,anchor={x:0,y:0}){const current=view.current,next=Math.min(8,Math.max(.25,scale)),ratio=next/current.scale;apply({scale:next,x:anchor.x-(anchor.x-current.x)*ratio,y:anchor.y-(anchor.y-current.y)*ratio});}
function move(direction:number){setIndex(current=>Math.max(0,Math.min(entries.length-1,current+direction)));}
useEffect(()=>{
const previous=document.activeElement as HTMLElement|null;overlay.current?.focus();
const overflow=document.body.style.overflow;document.body.style.overflow='hidden';
return()=>{document.body.style.overflow=overflow;if(previous?.isConnected)previous.focus();};
},[]);
useEffect(()=>{
setSource(null);setError('');setReady(false);apply(fitted);points.current.clear();let live=true,token:string|undefined;
const release=()=>{if(token){void managerApi('files',{operation:'preview.release',token}).catch(()=>{});token=undefined;}};
// A completed request is always released, even if the user closes while it is pending.
void managerApi<{url:string;token?:string}>('files',{operation:'preview',path:entry.path}).then(result=>{token=result.token;if(live)setSource({path:entry.path,url:result.url});else release();}).catch(e=>{if(live)setError(String(e));});
return()=>{live=false;release();};
},[entry.path]);
function keyDown(event:React.KeyboardEvent){
if(event.key==='Tab'){
const buttons=Array.from(overlay.current!.querySelectorAll<HTMLElement>('button:not(:disabled),video[controls]'));
const first=buttons[0],last=buttons.at(-1);if(event.shiftKey&&(document.activeElement===first||document.activeElement===overlay.current)){event.preventDefault();last?.focus();}else if(!event.shiftKey&&(document.activeElement===last||document.activeElement===overlay.current)){event.preventDefault();first?.focus();}return;
}
// Let the native player retain its own keyboard controls.
if((event.target as HTMLElement).tagName==='VIDEO'&&event.key!=='Escape')return;
if(event.key==='Escape'){event.preventDefault();event.stopPropagation();callbacks.current.onClose();}
else if(event.key==='ArrowLeft'){event.preventDefault();move(-1);}
else if(event.key==='ArrowRight'){event.preventDefault();move(1);}
else if(event.key==='+'||event.key==='='){event.preventDefault();zoom(view.current.scale*1.25);}
else if(event.key==='-'){event.preventDefault();zoom(view.current.scale/1.25);}
else if(event.key==='0'){event.preventDefault();apply(fitted);}
}
useEffect(()=>{const element=stage.current!;const wheel=(event:WheelEvent)=>{event.preventDefault();event.stopPropagation();const bounds=element.getBoundingClientRect();zoom(view.current.scale*Math.exp(-event.deltaY*.002),{x:event.clientX-bounds.left-bounds.width/2,y:event.clientY-bounds.top-bounds.height/2});};element.addEventListener('wheel',wheel,{passive:false});return()=>element.removeEventListener('wheel',wheel);},[]);
function position(event:React.PointerEvent){return{x:event.clientX,y:event.clientY};}
function geometry(){const p=[...points.current.values()];return{center:{x:(p[0].x+(p[1]?.x??p[0].x))/2,y:(p[0].y+(p[1]?.y??p[0].y))/2},distance:p[1]?Math.hypot(p[1].x-p[0].x,p[1].y-p[0].y):0};}
function isBlank(target:HTMLElement){return target===stage.current||target.classList.contains('file-media-loading')||target.classList.contains('file-media-error');}
function start(event:React.PointerEvent){blankClick.current=isBlank(event.target as HTMLElement);pressOrigin.current=position(event);if(points.current.size)blankClick.current=false;if(event.button!==0||!ready||(event.target as HTMLElement).closest('button'))return;const player=(event.target as HTMLElement).closest('video');if(player&&event.clientY>player.getBoundingClientRect().bottom-48)return;event.preventDefault();points.current.set(event.pointerId,position(event));event.currentTarget.setPointerCapture(event.pointerId);}
function drag(event:React.PointerEvent){if(!points.current.has(event.pointerId))return;if(Math.hypot(event.clientX-pressOrigin.current.x,event.clientY-pressOrigin.current.y)>6)blankClick.current=false;event.preventDefault();const before=geometry();points.current.set(event.pointerId,position(event));const after=geometry(),current=view.current;
if(before.distance&&after.distance){const bounds=stage.current!.getBoundingClientRect(),anchor={x:before.center.x-bounds.left-bounds.width/2,y:before.center.y-bounds.top-bounds.height/2};zoom(current.scale*after.distance/before.distance,anchor);}
apply({...view.current,x:view.current.x+after.center.x-before.center.x,y:view.current.y+after.center.y-before.center.y});
}
function end(event:React.PointerEvent){points.current.delete(event.pointerId);if(event.currentTarget.hasPointerCapture(event.pointerId))event.currentTarget.releasePointerCapture(event.pointerId);}
return createPortal(<div className="file-manager file-media-overlay" data-framely-no-scroll-drag ref={overlay} role="dialog" aria-modal="true" aria-label={entry.name} tabIndex={-1} onKeyDown={keyDown} onClick={event=>{const target=event.target as HTMLElement;if((isBlank(target)&&blankClick.current)||target===overlay.current||target.classList.contains('file-media-dock'))callbacks.current.onClose();}}>
<div className="file-media-stage" ref={stage} onPointerDown={start} onPointerMove={drag} onPointerUp={end} onPointerCancel={end} onLostPointerCapture={event=>points.current.delete(event.pointerId)} onDoubleClick={event=>{if((event.target as HTMLElement).tagName!=='VIDEO')view.current.scale===1?zoom(2):apply(fitted);}}>
{!ready&&!error&&<div className="file-media-loading" role="status">{t('加载中…')}</div>}
{error?<div className="file-media-error" role="alert"><p>{error}</p></div>:url&&<div className="file-media-canvas" ref={canvas} style={{transform:'translate(0px, 0px) scale(1)',visibility:ready?'visible':'hidden'}}>
{video?<video key={url} src={url} controls playsInline preload="metadata" onLoadedData={()=>setReady(true)} onError={()=>setError(t('浏览器无法播放此格式,请下载后打开。'))}/>:<img key={url} src={url} alt={entry.name} draggable={false} onLoad={()=>setReady(true)} onError={()=>setError(t('浏览器无法预览此格式,请下载后打开。'))}/>}
</div>}
</div>
<footer className="file-media-dock">
<div className="file-media-actions" role="toolbar" aria-label={t('预览操作')}>
<button aria-label={t('上一个文件')} title={t('上一个文件')} disabled={index===0} onClick={()=>move(-1)}><IconChevronLeft size={21} strokeWidth={1.7}/></button>
<button aria-label={t('下一个文件')} title={t('下一个文件')} disabled={index===entries.length-1} onClick={()=>move(1)}><IconChevronRight size={21} strokeWidth={1.7}/></button>
<span className="file-media-divider" aria-hidden="true"/>
<button ref={zoomOut} aria-label={t('缩小')} title={t('缩小')} disabled={!ready} onClick={()=>zoom(view.current.scale/1.25)}><IconZoomOut size={21} strokeWidth={1.7}/></button>
<output ref={level} aria-label={t('缩放比例')}>100%</output>
<button ref={zoomIn} aria-label={t('放大')} title={t('放大')} disabled={!ready} onClick={()=>zoom(view.current.scale*1.25)}><IconZoomIn size={21} strokeWidth={1.7}/></button>
<button aria-label={t('适应窗口')} title={t('适应窗口')} disabled={!ready} onClick={()=>apply(fitted)}><IconArrowsMaximize size={20} strokeWidth={1.7}/></button>
<span className="file-media-divider" aria-hidden="true"/>
<button aria-label={t('下载文件')} title={t('下载文件')} onClick={()=>callbacks.current.onDownload(entry.path)}><IconDownload size={21} strokeWidth={1.7}/></button>
<button aria-label={t('关闭')} title={t('关闭')} onClick={()=>callbacks.current.onClose()}><IconX size={21} strokeWidth={1.7}/></button>
</div>
<div className="file-media-caption"><p title={entry.name}>{entry.name}</p><span>{index+1} / {entries.length}</span></div>
</footer>
</div>,document.body);
}
+104
View File
@@ -0,0 +1,104 @@
import React,{useEffect,useRef,useState} from 'react';
import {IconPlayerPlayFilled} from '@tabler/icons-react';
import {managerApi} from './manager-api';
type Entry={path:string;name:string;directory:boolean;size:number;modified?:number;mediaRevision?:string|null};
type Cached={url:string|null;bytes:number;refs:number;retired:boolean;failedUntil:number};
type Watcher={notify:(url:string|null)=>void;cached?:Cached};
type Work={key:string;entry:Entry;watchers:Set<Watcher>;controller:AbortController};
const images=new Set(['png','jpg','jpeg','gif','webp','avif','bmp']);
const videos=new Set(['mp4','webm','mov','m4v']);
const cache=new Map<string,Cached>(),work=new Map<string,Work>(),queue:Work[]=[];
const visibility=new Map<Element,(visible:boolean)=>void>();
let observer:IntersectionObserver|undefined,active=0;
function kind(entry:Entry){if(entry.directory)return '';const ext=entry.name.split('.').pop()?.toLowerCase()??'';return images.has(ext)?'image':videos.has(ext)?'video':'';}
function releaseCached(value:Cached){value.refs--;if(value.retired&&value.refs===0&&value.url)URL.revokeObjectURL(value.url);}
function retire(value:Cached){value.retired=true;if(value.refs===0&&value.url)URL.revokeObjectURL(value.url);}
function trimCache(){let bytes=[...cache.values()].reduce((sum,item)=>sum+item.bytes,0);while(cache.size>128||bytes>16*1024*1024){const key=cache.keys().next().value!;const value=cache.get(key)!;cache.delete(key);bytes-=value.bytes;retire(value);}}
function waitFor(element:HTMLImageElement|HTMLVideoElement,event:string,signal:AbortSignal):Promise<void>{
signal.throwIfAborted();return new Promise((resolve,reject)=>{
const cleanup=()=>{element.removeEventListener(event,done);element.removeEventListener('error',failed);signal.removeEventListener('abort',abort);};
const done=()=>{cleanup();resolve();},failed=()=>{cleanup();reject(new Error('Media decoding failed'));},abort=()=>{cleanup();reject(signal.reason);};
element.addEventListener(event,done,{once:true});element.addEventListener('error',failed,{once:true});signal.addEventListener('abort',abort,{once:true});
});
}
async function snapshot(source:CanvasImageSource,width:number,height:number,signal:AbortSignal):Promise<Blob>{
signal.throwIfAborted();if(!width||!height)throw Error('Empty media frame');
const scale=Math.min(1,320/width,320/height),canvas=document.createElement('canvas');
canvas.width=Math.max(1,Math.round(width*scale));canvas.height=Math.max(1,Math.round(height*scale));
const context=canvas.getContext('2d');if(!context)throw Error('Canvas unavailable');
context.drawImage(source,0,0,canvas.width,canvas.height);
const blob=await new Promise<Blob>((resolve,reject)=>canvas.toBlob(value=>value?resolve(value):reject(Error('Thumbnail encoding failed')),'image/webp',.8));
signal.throwIfAborted();return blob;
}
async function decode(url:string,video:boolean,signal:AbortSignal):Promise<Blob>{
if(!video){
const image=new Image();image.decoding='async';
try{const loaded=waitFor(image,'load',signal);image.src=url;await loaded;return await snapshot(image,image.naturalWidth,image.naturalHeight,signal);}
finally{image.removeAttribute('src');}
}
const player=document.createElement('video');player.muted=true;player.playsInline=true;player.preload='metadata';
try{
const metadata=waitFor(player,'loadedmetadata',signal);player.src=url;await metadata;
if(player.duration<=0)throw Error('Empty video');
// Some local WebM files omit duration metadata but still support seeking.
const target=Number.isFinite(player.duration)?Math.min(1,Math.max(0,player.duration-.05)):1;
if(target>0){const sought=waitFor(player,'seeked',signal);player.currentTime=target;await sought;}
if(player.readyState<2)await waitFor(player,'loadeddata',signal);
return await snapshot(player,player.videoWidth,player.videoHeight,signal);
}finally{player.pause();player.removeAttribute('src');player.load();}
}
async function generate(task:Work):Promise<Blob>{
const signal=task.controller.signal;let token:string|undefined;
try{
const result=await managerApi<{url:string;token?:string}>('files',{operation:'preview',path:task.entry.path},signal);token=result.token;
signal.throwIfAborted();return await decode(result.url,kind(task.entry)==='video',signal);
}finally{
// The cached thumbnail no longer needs the source media token. Source bytes
// remain on Frame except for this normal authenticated browser preview.
if(token)void managerApi('files',{operation:'preview.release',token}).catch(()=>{});
}
}
function pump(){
while(active<2&&queue.length){
const task=queue.shift()!;if(!task.watchers.size||task.controller.signal.aborted)continue;active++;
const timeout=setTimeout(()=>task.controller.abort(new DOMException('Thumbnail timed out','TimeoutError')),10000);
void generate(task).then(blob=>{
if(task.controller.signal.aborted||!task.watchers.size)return;
const cached:Cached={url:URL.createObjectURL(blob),bytes:blob.size,refs:0,retired:false,failedUntil:0};
cache.set(task.key,cached);for(const watcher of task.watchers){watcher.cached=cached;cached.refs++;watcher.notify(cached.url);}trimCache();
}).catch(()=>{
if(task.controller.signal.aborted||!task.watchers.size)return;
const cached:Cached={url:null,bytes:0,refs:0,retired:false,failedUntil:Date.now()+60000};
cache.set(task.key,cached);for(const watcher of task.watchers){watcher.cached=cached;cached.refs++;watcher.notify(null);}trimCache();
}).finally(()=>{clearTimeout(timeout);if(work.get(task.key)===task)work.delete(task.key);active--;pump();});
}
}
function subscribe(key:string,entry:Entry,notify:Watcher['notify']){
const cached=cache.get(key);
if(cached&&(!cached.failedUntil||cached.failedUntil>Date.now())){
cache.delete(key);cache.set(key,cached);cached.refs++;notify(cached.url);return()=>releaseCached(cached);
}
if(cached){cache.delete(key);retire(cached);}
let task=work.get(key);
if(!task||task.controller.signal.aborted){task={key,entry,watchers:new Set(),controller:new AbortController()};work.set(key,task);queue.push(task);}
const watcher:Watcher={notify};task.watchers.add(watcher);pump();
return()=>{task!.watchers.delete(watcher);if(watcher.cached)releaseCached(watcher.cached);else if(!task!.watchers.size){task!.controller.abort();const index=queue.indexOf(task!);if(index>=0)queue.splice(index,1);if(work.get(key)===task)work.delete(key);}};
}
export function FileThumbnail({entry,children,active=true}:{entry:Entry;children:React.ReactNode;active?:boolean}){
const node=useRef<HTMLSpanElement>(null),[visible,setVisible]=useState(false),[url,setUrl]=useState<string|null>(null);
const type=kind(entry),key=JSON.stringify([entry.path,entry.mediaRevision??[entry.size,entry.modified],type]);
useEffect(()=>{
if(!type||!node.current||typeof IntersectionObserver==='undefined')return;
observer??=new IntersectionObserver(entries=>{for(const item of entries)visibility.get(item.target)?.(item.isIntersecting);},{threshold:.01});
const target=node.current;visibility.set(target,setVisible);observer.observe(target);
return()=>{observer?.unobserve(target);visibility.delete(target);};
},[type]);
useEffect(()=>{
setUrl(null);if(!active||!visible||!type)return;
let dispose:(()=>void)|undefined,live=true;
const timer=setTimeout(()=>{dispose=subscribe(key,entry,value=>{if(live)setUrl(value);});},120);
return()=>{live=false;clearTimeout(timer);dispose?.();};
},[key,visible,type,active]);
return <span className="file-thumbnail" ref={node}>{url?<><img src={url} className="file-media-thumbnail" alt="" draggable={false}/>{type==='video'&&<IconPlayerPlayFilled className="file-video-marker" size={14} aria-hidden="true"/>}</>:children}</span>;
}
+7
View File
@@ -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>;
}
+4 -1
View File
@@ -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}}
+7 -6
View File
@@ -1,3 +1,4 @@
import {SteamLaunchChoice} from './steam-launch-choice';
import {AppPlaceholder} from './app-placeholder';
import {SwitchRow} from './switch';
import React,{useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
@@ -13,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})));
@@ -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&&current+panView.direction>=0&&current+panView.direction<pages&&<div className="launcher-adjacent-page" aria-hidden="true" style={{translate:`${panView.offset+panView.direction*panView.width}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}}>{items.slice((current+panView.direction)*pageSize,(current+panView.direction+1)*pageSize).map((a,index)=>{const count=Math.min(pageSize,items.length-(current+panView.direction)*pageSize),{row,column:slot}=launcherCells(count)[index];return <div key={a.key} className="launch-item" style={{gridColumn:`${slot} / span 4`,gridRow:`${1+row*2} / span 2`}}><span className="launch-icon"><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/>{a.remoteClient&&<span className="launch-remote-mark"><IconDeviceDesktopShare size={19} stroke={1.7}/></span>}</span><span className="launch-name">{a.name}</span></div>;})}</div>}</div>
{ghost&&<div className="launcher-drag-ghost" style={{left:ghost.x,top:ghost.y}}><LauncherArtwork src={ghost.item.icon} name={ghost.item.name} kind={ghost.item.kind} fit={ghost.item.fit}/></div>}{ghost&&[-1,1].map(direction=><div key={direction} className={`launcher-drag-edge ${direction<0?'left':'right'} ${edge===direction?'active':''}`} style={{left:(direction<0?ghost.left:ghost.right)-4,top:ghost.top,height:ghost.bottom-ghost.top}} aria-hidden="true"/>)}
{!loading&&!items.length&&<p className="launcher-empty">{t(query?'没有匹配的应用':filter==='favorites'?'还没有收藏的应用':'没有可启动的应用')}</p>}<nav className="launcher-pages" aria-label={t('分页')}>{Array.from({length:pages},(_,n)=><button key={n} aria-label={t('第 {0} 页',{0:n+1})} aria-current={current===n?'page':undefined} disabled={!!dragging||!!panView} onClick={()=>setPage(n)}><span/></button>)}</nav>{loading&&<span className="launcher-loading" role="status">{t('正在读取本机应用…')}</span>}
{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>;
}
+2 -2
View File
@@ -89,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>}
@@ -109,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}/><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]??true} disabled={busy} onChange={enabled=>void action(()=>api('feedback.settings.save',{haptics:true,sounds:true,...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==='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>}
+1 -1
View File
@@ -1,3 +1,3 @@
import {t} from './i18n';
export class ManagerError extends Error {constructor(public readonly raw:string){super(t(raw));}}
export async function managerApi<T=any>(area:string,params:unknown={}):Promise<T>{const r=await fetch('/manager-api/'+area,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(params)});if(r.status===401){location.replace('/manager');throw new Error(t('请重新登录'));}if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new ManagerError(v.error);return v.result;}
export async function managerApi<T=any>(area:string,params:unknown={},signal?:AbortSignal):Promise<T>{const r=await fetch('/manager-api/'+area,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(params),signal});if(r.status===401){location.replace('/manager');throw new Error(t('请重新登录'));}if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new ManagerError(v.error);return v.result;}
+13
View File
@@ -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>;
}
+2
View File
@@ -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
View File
@@ -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){