Compare 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
45 changed files with 1512 additions and 93 deletions

No files matched your search

Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.5.1-preview.2"
version = "0.6.0"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.5.1-preview.2"
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.
+8 -2
View File
@@ -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 修复层只在所选应用中,于绑定纹理后检查帧缓冲状态,不改变其他应用、系统驱动、游戏文件或存档。关闭后,下次启动恢复正常渲染路径。这是针对特定问题的兼容措施,并非通用画质或性能优化。
+8 -2
View File
@@ -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];}
};
@@ -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
+256 -15
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)
@@ -1531,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,
@@ -1686,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()
});
}
@@ -2016,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",
@@ -2395,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,
@@ -2408,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)?;
@@ -2519,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();
}
@@ -2591,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()
};
@@ -2759,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()
@@ -2838,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(
@@ -2886,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
@@ -3270,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);
@@ -3391,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,
@@ -3428,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"}),
@@ -3442,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() {
@@ -3757,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);
@@ -3822,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,
@@ -3836,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");
@@ -4394,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());
@@ -5063,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();
@@ -5279,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();
+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;
+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)));
+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);
}
+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.');
+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"
+24 -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",
@@ -977,6 +979,27 @@
"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"
"架构": "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"
}
}
+24 -1
View File
@@ -3,6 +3,8 @@
"locale": "zh-CN",
"name": "简体中文",
"messages": {
"L2 / R2 按键触发行程": "L2 / R2 按键触发行程",
"默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。": "默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。",
"安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。": "安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。",
"关闭容器": "关闭容器",
"更新应用并保留现有存档和设置。": "更新应用并保留现有存档和设置。",
@@ -977,6 +979,27 @@
"默认开启,在 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>;
}
+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;}
+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){