Compare commits

...
12 Commits
87 changed files with 3053 additions and 215 deletions

No files matched your search

Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.8.2"
version = "0.8.3-preview.2"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.8.2"
version = "0.8.3-preview.2"
edition = "2021"
license = "AGPL-3.0-only"
+3 -1
View File
@@ -4,6 +4,8 @@
## 采集与网页观看
快捷面板和“网页观看”提供 **显示录制指示器** 开关,默认开启并持久保存。录制过程中也可切换;关闭仅隐藏头显右上角指示器及其点击区域,不停止或重启录制、不改变录制区域边框。隐藏后仍可通过两个面板的“停止录制”按钮停止。
“网页观看”和快捷面板的串流卡片均有 **开始/停止录制** 按钮,状态同步。录制与串流共用画面来源、取景、分辨率、帧率、码率、视频编码和声音设置;默认输出为 1920×1440(4:3),已有自定义设置保持不变。录制复用原始 H.264/H.265 视频,不进行二次视频编码,声音转为 AAC。文件保存到 Steam 用户的 `Videos/framely`(头显默认 `/home/steamos/Videos/framely`),使用唯一文件名和分片 MP4 容器,不覆盖已有视频。
录制与网页串流独立启停:点击录制直接启动本地采集和录制,不开启网页观看或 HLS 服务;之后开启串流会复用正在录制的采集编码,不重启录制。停止串流不停止录制,停止录制不停止串流,两者都停止时释放采集资源。录制期间不能修改采集参数或调整取景;接收投屏设置不受影响。
@@ -22,7 +24,7 @@ Framecorder 原插件的名称是“面板画面”和“SteamVR 备用画面”
网页优先协商用户选择的 H.264 或 H.265。当前官方 CEF 运行包不能解码所选编码时,头显自带 FFmpeg 以 libvpx 提供 VP8 + Opus 兼容输出;所有需要 VP8 的观看者共享一个额外的软件编码进程。采集来源不变。支持所选编码的观看者直接接收原串流,DLNA 仍使用 H.264。VP8 增加 CPU 消耗;兼容观看结束后收回该进程。Framely 不安装、调用或依赖外部 Chrome。
在“在其他设备上观看”中开启 **免验证**,显示和复制的观看地址会附带随机凭证。持有该链接的人无需管理密码即可观看,但不能访问管理面板、文件或终端。链接在停止/重新开始串流及重启后仍有效;关闭开关会撤销链接并断开免验证观看,已登录观看不受影响。再次开启生成新链接。网络访问总开关仍需开启。
“在其他设备上观看”及 **免验证** 开关常驻显示,未开始串流时也能提前设置和复制观看地址。开启后,显示和复制的观看地址会附带随机凭证。持有该链接的人无需管理密码即可观看,但不能访问管理面板、文件或终端。链接在停止/重新开始串流及重启后仍有效;关闭开关会撤销链接并断开免验证观看,已登录观看不受影响。再次开启生成新链接。网络访问总开关仍需开启。
声音使用小片段和独立的有界输入队列,避免音频阻塞拖慢视频;编码使用无 B 帧和低延迟参数。SteamVR 的 H.265 是软件编码,会增加 CPU 负载;屏幕采集使用 Iris 硬件编码。
+14
View File
@@ -163,6 +163,14 @@ On Frame Gamescope `3.16.28-76-ge383171f`, both window and Dock close controls l
Steam entries show the APK's application name and icon. Framely generates matching library capsules, headers and hero images from the icon, without downloading artwork. The original launcher icon, order and favorites stay intact; the explicit Steam App ID binding excludes duplicate entries from the launcher Steam category. APK updates refresh generated entry metadata.
Generated artwork includes a 600×900 portrait capsule, 920×430 landscape capsule, 3840×1240 text-free hero, 1280×360 transparent logo and 256×256 icon. Portrait titles are centered; landscape capsules use the icon on the left and the title on the right. The hero reserves space for Steam's separate logo overlay. Long titles are fitted using actual font metrics. Rendering uses Steam's existing CEF Canvas and system fonts; no extra runtime, image service or font download is required.
The shortcut icon is a separate file, not a fifth Steam custom-artwork type. Steam can crop hero backgrounds with `cover` depending on its window shape; the main motifs stay in the central safe band rather than requiring the whole exported image to be visible. An exact Framely-owned icon accidentally written over a landscape capsule by the first artwork build is automatically repaired; unrelated custom images are not replaced.
The same OKLab clustering and area/edge-distribution scoring processes every icon; application names and package IDs do not select colors. Main ornaments retain the selected color's hue and use 80% RGB intensity. Icons without a reliable chromatic candidate use a neutral palette. Package IDs seed only the stable decoration layout, so refreshing does not randomly change the design.
Framely stores generated image-content hashes in its private Devkit entry directory. Updates replace only missing images, unchanged Framely-generated images or exact old icon-only artwork; manually replaced Steam artwork is preserved. Renaming a shortcut refreshes Framely-generated titles during the next Steam synchronization. Steam must be running and its library UI available; otherwise synchronization is deferred.
The native runtime has a `steamlaunch-…` process/container name, while the APK keeps its existing data directory and Framely identity. No game-data copy, APK reinstall or system compositor modification is required. A private, per-user Lepton entry adapter retains resource storage, selected activity, window orientation, shader cache and optional controller forwarding. It prevents native Lepton from clearing an existing installation after a timestamp change or early exit. Unknown entry-script formats are refused before starting the application.
Close an already running container before switching its launch path. Framely does not restart a running game to assign Steam ownership. Old shared containers remain discoverable, but only one Steam launch may own their data at a time. Duplicate launches are blocked using an ownership lock that survives the native process launch.
@@ -195,6 +203,12 @@ Container list headings show installed application names; the data path identifi
**APK → Settings → Keep installed APKs in Steam** defaults to on. Turning it off removes idle Framely-owned entries and disables automatic registration. Launching an app whose **Launch through Steam** option is enabled registers its entry on demand; after the native launch process and container finish, the entry and its temporary Devkit copy are removed. Installed APKs, containers, saves and downloaded game data are retained. Running entries are protected. Failed or interrupted starts are cleaned up after a startup grace period, with retries when Steam is unavailable. Turning retention back on resumes automatic registration.
### Failed installs and damaged containers
A failed first install rolls back only the container, runtime and installation records created by that attempt, retaining operation logs. A failed update keeps the existing container, application data and any backup already created. If safe cleanup fails, the pending state is retained and both installation and cleanup errors are reported.
For older, incomplete Framely-owned containers without an Android package database, choose **Clean damaged container** in application details or delete the container from the container page. Explicit confirmation permanently deletes its application data, associated APK cache and Steam entries; historical backups remain. Ownership, storage paths and runtime ownership must pass validation. Unreadable old data is never automatically deleted. After confirming that the data is no longer needed, clean the damaged container and select the APK again. Diagnose the original installation failure using its logs.
### 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.
+2
View File
@@ -25,6 +25,8 @@ Plugin windows use the same SteamVR move, resize, keyboard and close controls, w
Settings provides language, network panel and proxy configuration. HTTP and GitHub proxies have independent switches; saving an address does not automatically enable it.
**Automatically try gh-proxy.com when GitHub cannot be reached** is enabled by default and can be disabled separately. Framely first uses the existing direct/manual proxy route, then retries eligible public GitHub network failures once through this third-party service. Authentication-bearing requests, signed URLs, missing resources and failed checksum verification are not routed automatically. A truncated download restarts from zero; package verification remains required. Non-GitHub requests are unchanged.
Safe mode pauses third-party plugins for troubleshooting; leave it after resolving the problem. Settings → About shows the device version and terms; Settings → Updates manages updates. Revoking consent disables all plugins; accepting again does not automatically re-enable them.
In Settings → Updates, select the Stable or Testing update channel and check for updates. Choose **Download and install**, then confirm once: Framely downloads and verifies the release and automatically starts installation. Download and verification are displayed as separate stages, with byte progress for each. Download or verification failure stops installation and displays an error. Plugins and data remain; the Framely interface briefly closes and restarts.
+8
View File
@@ -72,6 +72,14 @@ Download a single file directly, or select multiple files/folders to download a
Opening an ordinary file attempts the UTF-8 editor, limited to 5 MiB. Invalid UTF-8, binary data and larger files cannot be edited; use Download instead. Saving checks a content revision. External changes require reloading the file or explicitly confirming overwrite. Unsaved changes require confirmation before closing the editor.
**Edit as text** is available in the context menu and Actions for every non-directory entry, including images, archives and unknown extensions. It bypasses media preview. Binary data and invalid UTF-8 can be viewed as read-only text (replacement characters may appear); they cannot be saved through the editor. Devices, pipes and files over 5 MiB are rejected. Nothing is executed or converted on disk.
The offline CodeMirror editor loads only when needed. It provides line numbers, highlighting for JavaScript/TypeScript, JSON, HTML, CSS, Python and Rust, indentation, folding, find/replace, undo/redo, word wrap and Ctrl/Cmd+S. Other formats open as plain text. There are no completion popups, diagnostics, language servers or CDN requests. Headset keyboard input edits the document at the current selection. If loading fails, the ordinary textarea remains available.
The editor occupies the file workspace, with a document tab and parent path, a compact toolbar, a manual language-mode selector and live line/column status. Saving and closing stay at the top. The status bar reports encoding and line endings; consistent CRLF and CR files retain their line separators when edited. Opening a confirmation dialog preserves the editor's undo history and temporarily blocks hidden input.
**Properties** reads fresh metadata: path, type, byte size, allocated disk space, owner/group names and numeric IDs, modification/access/metadata-change/creation times (where supported), inode, device and hard-link count. Link properties describe the link itself and show its target and resolved path. Directory size describes the directory entry, not a recursive total. Owned non-symlink entries support a read/write/execute matrix synchronized with a validated three-digit octal mode. Existing special bits are preserved; other users' permissions are not elevated. Saving checks device/inode identity to reject replaced entries.
## Archives and media
Create ZIP, TAR or TAR.GZ archives; the output filename selects the format. Choose a destination and conflict behavior when extracting. Archive operations reject absolute paths, parent traversal, symbolic/hard links and special files; extracted contents are never executed. Archive limits are 50,000 entries and 64 GiB expanded data. Cancelling removes staging files and leaves already committed items intact.
+14
View File
@@ -133,6 +133,14 @@ APK 安装、更新、其他修改与启动检查期间暂停监测。容器重
Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的库封面、横幅和背景图,不联网下载图片。启动台原有图标、排序和收藏不变,通过明确的 Steam App ID 绑定过滤 Steam 分类中的重复入口。APK 更新会刷新生成的入口信息。
自动生成 600×900 竖版封面、920×430 横版封面、3840×1240 无文字详情背景、1280×360 透明 Logo 和 256×256 图标。竖版标题居中,横版采用左图标、右名称,详情背景由 Steam 单独叠加 Logo。较长名称按实际字体宽度缩放。使用 Steam 现有 CEF Canvas 和系统字体,无需额外安装运行时、调用图片服务或下载字体。
快捷方式图标使用独立文件,不作为第五种 Steam 自定义封面类型写入。Steam 会按窗口比例以 `cover` 裁切详情背景;主要装饰放在中央安全带,不依赖整张导出图片都可见。第一版封面功能误写到横版位置的 Framely 图标,会在内容摘要完全匹配时自动修复;无关的手动图片不覆盖。
所有图标使用同一套 OKLab 聚类及面积、外圈分布评分,不按应用名称或包名指定颜色。主装饰保留选中色相,RGB 强度统一为原色的 80%;无可靠彩色候选时使用中性配色。包名仅用于生成稳定的装饰布局,刷新不会随机改变画面。
Framely 在自己的私有 Devkit 入口目录记录生成图片的内容摘要。后续只更新缺失图片、仍与记录一致的 Framely 生成图片,或内容完全匹配的旧版居中图标封面;手动替换的 Steam 图片保留。修改 Steam 名称后,会在下一次 Steam 同步时刷新 Framely 生成的标题。Steam 及其库界面未就绪时延后同步。
原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。
切换启动路径之前,需要先关闭已经运行的容器;Framely 不会为了绑定 Steam 重启游戏。旧共享容器仍可识别,但同时只允许一个 Steam 启动占用其数据。跨进程保留的占用锁阻止重复启动及共享数据的并发启动。
@@ -149,6 +157,12 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
**APK → 设置 → 保留安装的 APK 到 Steam** 默认开启。关闭后清理空闲的 Framely 自有入口,不再自动注册。应用启用 **通过 Steam 启动** 时,会在启动时临时注册入口;原生启动进程和容器退出后删除入口及临时 Devkit 副本。已安装 APK、容器、存档和下载的游戏数据都会保留,运行中的入口不会删除。失败或中断的启动在启动宽限期后清理;Steam 不可用时稍后重试。重新开启保留设置会恢复自动注册。
### 安装失败与损坏容器
首次安装失败时,Framely 仅回滚本次新建的容器、运行时和安装记录,保留操作日志。更新已有容器失败时不会自动删除容器或存档,已生成的备份也会保留。安全校验或清理失败会保留待处理状态,并报告安装和清理两项错误。
旧版本遗留的容器若缺少安卓数据库,可在应用管理详情中选择 **清理损坏容器**,或在容器页删除该容器。此操作仍需确认:容器内可能存在的应用数据、关联 APK 缓存和 Steam 入口会永久删除,历史备份保留。只允许对明确由 Framely 管理且路径和运行时归属校验通过的损坏容器执行;不会因读取失败就自动删除旧数据。确认不需要容器内数据后清理,再重新选择 APK 安装。原始安装失败的具体原因仍应通过日志诊断。
### 卸载并保留容器
保留应用数据的卸载使用 Android 包管理器就绪检查,不再强制等待用户解锁或外部存储挂载。删除数据仍等待这些条件。Framely 在后台启动已停止的容器,不显示容器桌面;需要时只修复目标 APK 的登记。启动、包管理器、解锁、存储和容器退出分别报错。Android 确认卸载成功后才移除 Steam 入口,先保存 Android 卸载结果,重试 Steam 清理不会重复卸载。启动或卸载失败保留应用记录、Steam 绑定和应用数据。错误弹窗提供重试与导出日志。无法启动的容器仍需诊断,不会自动删除容器或直接修改 Android 包数据库。
+2
View File
@@ -25,6 +25,8 @@
设置中可修改语言、网络面板及代理。HTTP 代理和 GitHub 代理各有开关,保存地址不会自动启用代理。
**GitHub 连接失败时自动使用 gh-proxy.com** 默认开启,可单独关闭。先使用原有直连或手动代理,符合条件的公开 GitHub 网络失败才通过第三方服务重试一次。带认证信息或签名参数的请求、资源不存在以及校验失败不自动代理;下载中断后从零重新下载,不混合两条线路的内容,仍须通过原有校验。非 GitHub 请求不受影响。
“安全模式”暂停第三方插件,适合出现故障时检查;排查后再退出安全模式。在“设置 → 关于”查看本体版本和协议,在“设置 → 更新”检查更新。撤回协议会停用全部插件,再次同意后需要手动启用。
在“设置 → 更新”选择正式版或测试版渠道并检查更新,点击“下载并安装”并确认一次:Framely 下载、校验发行包后自动开始安装。下载和校验分别显示阶段提示与实际字节进度。下载或校验失败时停止安装并显示原因。插件和数据保留,Framely 界面会暂时关闭并重新启动。
@@ -74,6 +74,14 @@
普通文件打开时尝试 UTF-8 文本编辑器,上限 5 MiB;非法编码、二进制或超大文件无法编辑,可下载后处理。保存校验内容版本,外部修改后须重新加载或确认覆盖。关闭未保存内容会要求确认。
所有非目录条目的右键菜单和“操作”中都提供“以文本编辑”,包括图片、压缩包和未知后缀文件,不会先打开媒体预览。二进制或非法 UTF-8 可作为只读文本查看(可能出现替代字符),不能保存;设备、管道和超过 5 MiB 的文件不打开。不执行文件内容,不在磁盘上转换编码。
离线 CodeMirror 编辑器仅在打开文件时加载,支持行号、JavaScript/TypeScript、JSON、HTML、CSS、Python、Rust 高亮、缩进、折叠、查找替换、撤销重做、自动换行和 Ctrl/Cmd+S。其他格式按纯文本显示。没有补全弹窗、诊断、语言服务或 CDN 请求。头显键盘在当前光标或选区编辑文档;加载失败时仍可使用普通文本框。
编辑器占满文件管理工作区,提供文件标签及父目录路径、紧凑工具栏、手动语言模式选择和实时行列状态,保存及关闭固定在顶部。底部显示编码和换行格式;统一使用 CRLF 或 CR 的文件编辑后保留其分隔符。确认对话框打开时保留撤销历史,并暂时阻止隐藏编辑区继续接收输入。
“属性”实时读取完整路径、类型、字节大小、磁盘占用、所有者及所属组名称和数字 ID、修改/访问/状态变更/创建时间(不支持时显示不可用)、Inode、设备编号及硬链接数。符号链接显示链接自身的属性,以及目标和解析路径。目录大小是目录条目自身大小,不是递归统计。当前用户拥有的非符号链接条目可通过读/写/执行矩阵和同步的三位八进制权限修改;保留现有特殊权限位,不自动提权。保存会核对设备及 Inode,拒绝更改已经被替换的条目。
## 压缩与媒体
支持创建 ZIP、TAR、TAR.GZ,输出文件名决定格式。解压时选择目标目录和冲突策略。拒绝绝对路径、向上路径穿越、软硬链接及特殊文件,不执行解压内容;最多 50,000 项、展开大小 64 GiB。取消会清理暂存文件,保留已经提交的项目。
+20 -1
View File
@@ -20,5 +20,24 @@ for ((i=0; i<${#args[@]}; i++)); do
[[ $version =~ ^[a-zA-Z0-9][a-zA-Z0-9.+-]*$ ]] || { echo 'Invalid release version.' >&2; exit 1; }
release_path="download/$version"
done
curl --fail --location --proto '=https' --proto-redir '=https' --retry 3 --output "$work/bootstrap.py" "https://github.com/$repo/releases/$release_path/bootstrap.py"
bootstrap_url="https://github.com/$repo/releases/$release_path/bootstrap.py"
if status=$(curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --dump-header "$work/headers" --write-out '%{http_code}' --output "$work/bootstrap.py" "$bootstrap_url"); then
:
else
code=$?
retry=false
case "$code" in 5|6|7|18|28|35|52|55|56) retry=true;; esac
if [[ $code == 22 ]]; then
case "$status" in 408|429|5??) retry=true;; 403)
if grep -Eiq '^(x-ratelimit-remaining:[[:space:]]*0[[:space:]]*$|retry-after:)' "$work/headers"; then retry=true; fi;;
esac
fi
if [[ ${FRAMELY_GITHUB_AUTO_FALLBACK:-1} == 0 || $retry != true ]]; then
echo "Bootstrap primary route failed (curl $code, HTTP $status); not eligible for automatic proxy." >&2; exit 1
fi
echo 'GitHub connection failed; trying gh-proxy.com once' >&2
if ! curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --output "$work/bootstrap.py" "https://gh-proxy.com/$bootstrap_url"; then
echo "Bootstrap primary route failed (curl $code, HTTP $status); gh-proxy.com fallback also failed." >&2; exit 1
fi
fi
python3 "$work/bootstrap.py" --repo "$repo" "$@"
+2
View File
@@ -88,4 +88,6 @@ The archive includes bounded recent service/plugin/CEF logs, GPU evidence, insta
### Proxy preferences
Automatic GitHub fallback is enabled by default and can be disabled independently. After an eligible public GitHub connection failure, the installer retries once through `https://gh-proxy.com`, retaining the existing HTTP/system proxy and checksum verification. Partial downloads restart from zero. The selected fallback preference is passed to the device-side installation engine without changing persistent Frame settings. Standalone scripts also default to fallback and accept `FRAMELY_GITHUB_AUTO_FALLBACK=0` to disable it. Certificate errors, ordinary permission/not-found errors, authenticated or signed URLs and checksum failures do not trigger fallback.
The **Settings** tab controls downloads on the installer computer. System proxy discovery is enabled by default. An explicit HTTP/SOCKS proxy takes precedence; leave it empty to follow the system toggle. An optional HTTPS GitHub proxy prefix routes GitHub API and asset URLs as `prefix/original-URL`. Settings persist locally and apply to subsequent version checks and downloads; SSH and Frame settings are unchanged. Saving clears the release selection so the next version check uses the new settings. Package verification remains enabled.
+2
View File
@@ -88,4 +88,6 @@ cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --
### 代理设置
自动 GitHub 代理默认开启,可单独关闭。公开 GitHub 请求出现符合条件的网络故障后,安装器通过 `https://gh-proxy.com` 最多重试一次,继续沿用 HTTP/系统代理及校验保护;半截下载会从零重新开始。本次开关状态会传给设备侧安装引擎,不改写 Frame 的持久设置。独立安装脚本默认开启,可用 `FRAMELY_GITHUB_AUTO_FALLBACK=0` 禁用。证书错误、普通权限或资源不存在错误、带认证或签名参数的地址以及校验失败不触发兜底。
**设置**页控制安装器电脑的下载。默认启用系统代理;填写 HTTP/SOCKS 代理后优先使用指定地址,留空则遵循系统代理开关。可选的 HTTPS GitHub 代理以 `代理前缀/原始 URL` 转发 GitHub API 和资源请求。设置在本机保存,后续检查版本和下载时生效,不更改 SSH 或 Frame 的设置。保存会清除版本选择,下次检查版本使用新配置;包校验继续启用。
+2
View File
@@ -1,4 +1,6 @@
pub mod discovery;
#[path = "../../src/http/github_fallback.rs"]
pub(crate) mod github_fallback;
pub mod instance;
pub mod maintenance;
pub mod progress;
+4
View File
@@ -49,6 +49,7 @@ enum Event {
struct Installer {
page: usize,
system_proxy: bool,
github_auto_fallback: bool,
http_proxy: Entity<InputState>,
github_proxy: Entity<InputState>,
chosen_action: String,
@@ -117,6 +118,7 @@ impl Installer {
let mut view = Self {
page: 0,
system_proxy: proxies.system,
github_auto_fallback: proxies.github_auto_fallback,
http_proxy: input(&proxies.http, "http://127.0.0.1:7890", false),
github_proxy: input(&proxies.github, "https://your-github-proxy.example", false),
chosen_action: "install".into(),
@@ -601,6 +603,7 @@ impl Installer {
}
fn save_proxy(&mut self, cx: &mut Context<Self>) {
let settings = framely_installer::proxy::DownloadSettings {
github_auto_fallback: self.github_auto_fallback,
system: self.system_proxy,
http: self.http_proxy.read(cx).value().trim().to_owned(),
github: self
@@ -610,6 +613,7 @@ impl Installer {
.trim()
.trim_end_matches('/')
.to_owned(),
..Default::default()
};
match framely_installer::proxy::save(settings) {
Ok(()) => {
+2 -2
View File
@@ -62,12 +62,12 @@ pub fn copy(
ensure!(value.completed + n as u64 <= total, "传输超过声明长度");
writer.write_all(&block[..n])?;
value.completed += n as u64;
if last.elapsed() >= Duration::from_millis(200) {
if last.elapsed() >= Duration::from_millis(200) && value.completed < total {
report(value.clone());
last = Instant::now();
}
}
ensure!(value.completed == total, "传输未完成");
if value.completed != total { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "传输未完成").into()); }
writer.flush()?;
report(value);
Ok(())
+16
View File
@@ -8,6 +8,10 @@ use url::Url;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct DownloadSettings {
#[serde(skip)]
#[doc(hidden)]
pub github_fallback_attempt: bool,
pub github_auto_fallback: bool,
pub system: bool,
pub http: String,
pub github: String,
@@ -15,6 +19,8 @@ pub struct DownloadSettings {
impl Default for DownloadSettings {
fn default() -> Self {
Self {
github_fallback_attempt: false,
github_auto_fallback: true,
system: true,
http: String::new(),
github: String::new(),
@@ -46,6 +52,7 @@ impl DownloadSettings {
Ok(())
}
pub fn route(&self, url: &Url) -> Url {
if self.github_fallback_attempt && !crate::github_fallback::eligible(url.as_str(), &[]) { return url.clone(); }
if !self.github.is_empty()
&& matches!(
url.host_str(),
@@ -695,6 +702,15 @@ mod download_settings_tests {
assert!(settings.proxy_for(&target).unwrap().is_some());
}
#[test]
fn github_fallback_default_and_sensitive_redirect_policy() {
let legacy:DownloadSettings=serde_json::from_str("{}").unwrap();assert!(legacy.github_auto_fallback);
let disabled:DownloadSettings=serde_json::from_str("{\"githubAutoFallback\":false}").unwrap();assert!(!disabled.github_auto_fallback);
let fallback=DownloadSettings {github:crate::github_fallback::PREFIX.into(),github_fallback_attempt:true,..Default::default()};
let signed=Url::parse("https://release-assets.githubusercontent.com/a?X-Amz-Signature=secret").unwrap();
assert_eq!(fallback.route(&signed),signed);
assert!(fallback.route(&Url::parse("https://github.com/a/b/releases").unwrap()).as_str().starts_with(crate::github_fallback::PREFIX));
}
#[test]
fn github_routes_only_exact_hosts_and_does_not_double_wrap_redirects() {
let settings = DownloadSettings {
github: "https://proxy.example/prefix/".into(),
+57 -11
View File
@@ -30,25 +30,42 @@ pub fn validate_repo(repo: &str) -> Result<()> {
);
Ok(())
}
fn agent(url: &url::Url, settings: &crate::proxy::DownloadSettings) -> Result<ureq::Agent> {
fn agent(url: &url::Url, settings: &crate::proxy::DownloadSettings, download: bool) -> Result<ureq::Agent> {
let mut builder = ureq::AgentBuilder::new()
.try_proxy_from_env(false)
.timeout(Duration::from_secs(120))
.redirects(0);
let timeout = Duration::from_secs(120);
builder = if download { builder.timeout_connect(timeout).timeout_read(timeout).timeout_write(timeout) } else { builder.timeout(timeout) };
if let Some(proxy) = settings.proxy_for(url)? {
builder = builder.proxy(proxy);
}
Ok(builder.build())
}
#[cfg(test)]
fn get(url: &str) -> Result<ureq::Response> {
let settings = crate::proxy::current();
crate::github_fallback::run(settings.github_auto_fallback, url, &[], |fallback| {
let mut route=settings.clone();
if fallback {route.github=crate::github_fallback::PREFIX.into();route.github_fallback_attempt=true;}
get_routed(url, &route, false)
})
}
fn get_routed(url: &str, settings: &crate::proxy::DownloadSettings, download: bool) -> Result<ureq::Response> {
let mut url = url::Url::parse(url)?;
for _ in 0..=5 {
ensure!(url.scheme() == "https", "下载地址必须使用 HTTPS");
// Re-evaluate bypass rules for every redirect target.
let routed = settings.route(&url);
let response = agent(&routed, &settings)?
.get(routed.as_str())
#[cfg(test)]
let transport_url = crate::github_fallback::test_url(routed.as_str());
#[cfg(not(test))]
let transport_url = routed.to_string();
#[cfg(test)]
let transport_settings = if transport_url != routed.as_str() { crate::proxy::DownloadSettings {system:false,http:String::new(),..settings.clone()} } else {settings.clone()};
#[cfg(not(test))]
let transport_settings = settings.clone();
let response = agent(&routed, &transport_settings, download)?
.get(&transport_url)
.set("User-Agent", "Framely-Installer")
.call()?;
if (300..400).contains(&response.status()) {
@@ -63,15 +80,22 @@ pub fn list(repo: &str) -> Result<Vec<Release>> {
validate_repo(repo)?;
let mut all = Vec::new();
for page in 1..=10 {
let response = get(&format!(
let url=format!(
"https://api.github.com/repos/{repo}/releases?per_page=100&page={page}"
))?;
);
let settings=crate::proxy::current();
let bytes=crate::github_fallback::run(settings.github_auto_fallback,&url,&[],|fallback| {
let mut route=settings.clone();
if fallback {route.github=crate::github_fallback::PREFIX.into();route.github_fallback_attempt=true;}
let response=get_routed(&url,&route,false)?;
let mut bytes = Vec::new();
response
.into_reader()
.take(4 * 1024 * 1024 + 1)
.read_to_end(&mut bytes)?;
ensure!(bytes.len() <= 4 * 1024 * 1024, "Release 列表过大");
Ok(bytes)
})?;
let items: Vec<Release> = serde_json::from_slice(&bytes)?;
let finished = items.len() < 100;
all.extend(
@@ -209,9 +233,17 @@ pub fn download(
crate::progress::Progress::new(crate::progress::Stage::Download, &asset.name);
starting.total = Some(asset.size);
progress(starting);
let mut input = get(&asset.browser_download_url)?.into_reader();
let mut output = File::create(dest)?;
crate::progress::copy(
let settings = crate::proxy::current();
crate::github_fallback::run(settings.github_auto_fallback, &asset.browser_download_url, &[], |fallback| {
let mut route=settings.clone();
if fallback {
route.github=crate::github_fallback::PREFIX.into();
route.github_fallback_attempt=true;
progress(crate::progress::Progress::new(crate::progress::Stage::Download, "正在尝试 gh-proxy.com 自动代理"));
}
let mut input = get_routed(&asset.browser_download_url, &route, true)?.into_reader();
let mut output = File::create(dest)?;
crate::progress::copy(
&mut input,
&mut output,
asset.size,
@@ -219,12 +251,26 @@ pub fn download(
&asset.name,
progress,
)?;
output.sync_all()?;
Ok(())
output.sync_all()?;
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn github_fallback_restarts_a_truncated_installer_download_from_zero() {
let (target,server)=crate::github_fallback::local_truncated_server();
let source=format!("https://github.com/framely-test/{}/asset",target.rsplit(':').next().unwrap());
let _routes=crate::github_fallback::TestRoutes::new(vec![(source.clone(),target.clone()),(format!("{}/{}",crate::github_fallback::PREFIX,source),target)]);
let temp=tempfile::tempdir().unwrap();let dest=temp.path().join("archive");let mut progress=vec![];
// This test uses an explicit local transport mapping, never the public network.
let asset=Asset{name:"test.tar.gz".into(),browser_download_url:source,size:8};
download(&asset,&dest,&mut |value| progress.push(value)).unwrap();
assert_eq!(std::fs::read(dest).unwrap(),b"new-data");
assert!(progress.iter().any(|p|p.detail.contains("gh-proxy.com")));
server.join().unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn https_requests_use_discovered_desktop_proxy() {
+2 -1
View File
@@ -368,7 +368,8 @@ pub fn operate(
transfer.completed = super::BOOTSTRAP.len() as u64;
progress(transfer);
let mut args = format!(
"sudo -S -p '' -k -- python3 -u {} {} --yes --progress-json --repo {} --user {}",
"sudo -S -p '' -k -- env FRAMELY_GITHUB_AUTO_FALLBACK={} python3 -u {} {} --yes --progress-json --repo {} --user {}",
if crate::proxy::current().github_auto_fallback { "1" } else { "0" },
quote(&script),
quote(action),
quote(repo),
+6
View File
@@ -491,6 +491,12 @@ impl Installer {
.child(field("HTTP 代理", &self.http_proxy, self.busy))
.child(muted("留空时使用系统代理开关的选择。支持 HTTP 和 SOCKS 代理。"))
.child(field("GitHub 代理", &self.github_proxy, self.busy))
.child(div().flex().items_center().justify_between()
.child(div().flex().flex_col().gap_2().child("GitHub 连接失败时自动使用 gh-proxy.com")
.child(muted("默认开启。仅在网络失败时尝试一次;公开请求将经过第三方代理,仍保留包校验。")))
.child(Switch::new("github-auto-fallback").checked(self.github_auto_fallback)
.accessibility_label("GitHub 连接失败时自动使用 gh-proxy.com").disabled(self.busy)
.on_change(cx.listener(|view, checked, _, cx| { view.github_auto_fallback = *checked; cx.notify(); }))))
.child(muted("留空直接访问 GitHub。填写 HTTPS 前缀地址后,GitHub 请求按“代理地址/原始 URL”转发;仍保留包校验。"))
.when(self.error.is_some(), |this| this.child(div().text_color(rgb(0xf7a7a7)).child(self.error.clone().unwrap())))
.child(div().flex().justify_end().child(Button::new("save-proxy").primary().label("保存")
+1 -1
View File
@@ -752,7 +752,7 @@ int main(int argc,char** argv){
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);recording_state=data.value("recording",json::object());recording_region_state=data.value("recordingRegion",json::object());framing_editor_state=data.value("framingEditor",json(nullptr));if(!recording_state.is_object())recording_state=json::object();auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",false)}});last_feedback=feedback;}auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
std::thread priority_worker([]{LauncherPriority priority;while(running){priority.update(launcher_priority_active.load());std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;int icon_progress=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();json recording,region,framing;{std::lock_guard<std::mutex> lock(queue_mutex);recording=recording_state;region=recording_region_state;framing=framing_editor_state;}if(auto editor=views.find("framely.framing");editor!=views.end()&&framing_editor_should_close(framing,editor->second->region_editor_id))close_view(*editor->second);recording_indicator.tick(overlays,recording.value("active",false)?recording.value("id",std::string{}):std::string{},recording.value("elapsedSeconds",uint64_t(0)));if(auto editor=views.find("framely.framing");editor!=views.end())update_framing_placement(*editor->second);if(region.value("visible",false)&&region.contains("view")){auto presentation=recording_region_presentation(region["view"]);recording_region_overlay.tick(overlays,region,presentation.distance,&presentation.view);}else recording_region_overlay.tick(overlays,region);vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}observe_dashboard(global);keyboard_event(global);}launcher_priority_active.store(launcher_open&&launcher_prioritize_ui.load());vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();json recording,region,framing;{std::lock_guard<std::mutex> lock(queue_mutex);recording=recording_state;region=recording_region_state;framing=framing_editor_state;}if(auto editor=views.find("framely.framing");editor!=views.end()&&framing_editor_should_close(framing,editor->second->region_editor_id))close_view(*editor->second);recording_indicator.tick(overlays,recording.value("active",false)&&recording.value("indicatorVisible",true)?recording.value("id",std::string{}):std::string{},recording.value("elapsedSeconds",uint64_t(0)));if(auto editor=views.find("framely.framing");editor!=views.end())update_framing_placement(*editor->second);if(region.value("visible",false)&&region.contains("view")){auto presentation=recording_region_presentation(region["view"]);recording_region_overlay.tick(overlays,region,presentation.distance,&presentation.view);}else recording_region_overlay.tick(overlays,region);vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}observe_dashboard(global);keyboard_event(global);}launcher_priority_active.store(launcher_open&&launcher_prioritize_ui.load());vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
if(visible&&(!was_visible||dock!=last_dock)){uv={};anchored_at={};sampled_at={};}last_dock=dock;
// Width and aspect are only ray-solver seeds; physical size comes from intersections.
float width=0,height=0;bool fresh=visible&&!overlays->GetOverlayWidthInMeters(dock,&width)&&std::isfinite(width)&&width>0&&width<10&&std::isfinite(mouse_scale.v[0])&&std::isfinite(mouse_scale.v[1])&&mouse_scale.v[0]>0&&mouse_scale.v[1]>0;
+1 -1
View File
@@ -40,7 +40,7 @@ class RecordingIndicator {
vr::IVROverlay* overlay=nullptr;vr::VROverlayHandle_t handle=0;
std::string id;uint64_t rendered=~uint64_t(0);bool last_hover=false;std::array<RecordingIndicatorClick,2> clicks{};std::array<bool,2> hover{};
public:
void destroy(){if(handle&&overlay)overlay->DestroyOverlay(handle);handle=0;id.clear();hover={};}
void destroy(){if(handle&&overlay)overlay->DestroyOverlay(handle);handle=0;id.clear();cancel_input();}
bool active()const{return handle&&!id.empty();}
void cancel_input(){clicks={};hover={};}
bool hovered(int hand)const{return hand>=0&&hand<2&&hover[hand];}
+292
View File
@@ -12,6 +12,18 @@
"examples/showcase"
],
"dependencies": {
"@codemirror/commands": "6.11.1",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-html": "6.4.12",
"@codemirror/lang-javascript": "6.2.5",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-rust": "6.0.2",
"@codemirror/language": "6.13.1",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.14",
"@lezer/highlight": "1.2.5",
"@tabler/icons-react": "3.48.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
@@ -34,6 +46,172 @@
"@framely/sdk": "0.4.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.12",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-rust": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/rust": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/streamparser": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/streamparser": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
"license": "MIT"
},
"node_modules/@codemirror/view": {
"version": "6.43.14",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
@@ -488,6 +666,102 @@
"resolved": "ui",
"link": true
},
"node_modules/@lezer/common": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz",
"integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/rust": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.3.tgz",
"integrity": "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"license": "MIT"
},
"node_modules/@resvg/resvg-js": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
@@ -777,6 +1051,12 @@
"addons/*"
]
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -853,6 +1133,12 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/style-mod": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -867,6 +1153,12 @@
"node": ">=14.17"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"sdk": {
"name": "@framely/sdk",
"version": "0.4.0",
+12
View File
@@ -15,6 +15,18 @@
"plugin:init": "node tools/plugin-dev.mjs init"
},
"dependencies": {
"@codemirror/commands": "6.11.1",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-html": "6.4.12",
"@codemirror/lang-javascript": "6.2.5",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-rust": "6.0.2",
"@codemirror/language": "6.13.1",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.14",
"@lezer/highlight": "1.2.5",
"@tabler/icons-react": "3.48.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
+6
View File
@@ -21,7 +21,13 @@ if [[ $installer_cgroup =~ /framely(-session|-ui-recovery|-backend-[^/]+|-plugin
worker_args=("$steam_user")
if $repair; then worker_args=(--repair "$steam_user"); fi
install_unit="framely-install-$$-$(date +%s)"
worker_environment=()
if [[ -n ${FRAMELY_GITHUB_AUTO_FALLBACK+x} ]]; then
[[ $FRAMELY_GITHUB_AUTO_FALLBACK == 0 || $FRAMELY_GITHUB_AUTO_FALLBACK == 1 ]] || { echo 'Invalid automatic GitHub fallback preference.' >&2; exit 1; }
worker_environment+=("--setenv=FRAMELY_GITHUB_AUTO_FALLBACK=$FRAMELY_GITHUB_AUTO_FALLBACK")
fi
systemd-run --quiet --collect --no-block --unit="$install_unit" \
"${worker_environment[@]}" \
--property=Type=exec --property=StandardInput=null \
--property=StandardOutput=journal --property=StandardError=journal \
/usr/bin/bash "$base/install.sh" "${worker_args[@]}"
+12 -3
View File
@@ -1,16 +1,23 @@
type Editable=HTMLInputElement|HTMLTextAreaElement;
type Editable=HTMLInputElement|HTMLTextAreaElement|HTMLElement;
export function installKeyboard(open:(params:{existing:string;password:boolean;multiline:boolean})=>Promise<unknown>){
let field:Editable|null=null,last:Editable|null=null,openedAt=0;
const request=(target:EventTarget|null)=>{
if(!(target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement)||target.disabled||target.readOnly)return;
const embedded=target instanceof HTMLElement&&target.matches('[data-framely-editor-input][contenteditable="true"]');
if(!embedded&&(!(target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement)||target.disabled||target.readOnly))return;
if(!(target instanceof HTMLElement))return;
if(target instanceof HTMLInputElement&&!['text','password','url','search','email','number','tel'].includes(target.type))return;
field=target;const now=performance.now();if(last===target&&now-openedAt<300)return;last=target;openedAt=now;
void open({existing:target.value,password:target instanceof HTMLInputElement&&target.type==='password',multiline:target instanceof HTMLTextAreaElement}).catch(console.error);
const params={existing:target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement?target.value:'',password:target instanceof HTMLInputElement&&target.type==='password',multiline:embedded||target instanceof HTMLTextAreaElement};
if(embedded)target.dispatchEvent(new CustomEvent('framely.keyboard.open',{detail:params}));
void open(params).catch(console.error);
};
document.addEventListener('focusin',e=>request(e.target));
document.addEventListener('pointerdown',e=>request(e.target));
(window as any).__framelyCommitKeyboard=(value:string)=>{
if(!field?.isConnected)return;
if(field.closest('[inert]')||((field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement)&&(field.disabled||field.readOnly)))return;
if(!field.dispatchEvent(new CustomEvent('framely.keyboard.commit',{detail:value,cancelable:true})))return;
if(!(field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement))return;
const proto=field instanceof HTMLTextAreaElement?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto,'value')!.set!.call(field,value);
field.dispatchEvent(new Event('input',{bubbles:true}));field.dispatchEvent(new Event('change',{bubbles:true}));
@@ -18,9 +25,11 @@ export function installKeyboard(open:(params:{existing:string;password:boolean;m
};
(window as any).__framelyEditKeyboard=(edit:string)=>{
if(!field?.isConnected)return;
if(field.closest('[inert]')||((field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement)&&(field.disabled||field.readOnly)))return;
// Embedded editors can consume the native keyboard without mutating their
// hidden textarea. Ordinary inputs continue through the path below.
if(!field.dispatchEvent(new CustomEvent('framely.keyboard.edit',{detail:edit,cancelable:true})))return;
if(!(field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement))return;
const value=field.value;
let start=field.selectionStart??value.length,end=field.selectionEnd??start;
const segments=Array.from(new (Intl as any).Segmenter(undefined,{granularity:'grapheme'}).segment(value)) as {index:number;segment:string}[];
+168 -22
View File
@@ -2,6 +2,7 @@
use crate::{
apk_metadata::{self, Metadata},
jobs::Cancellation,
model::ProxySettings,
};
use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize};
@@ -22,6 +23,7 @@ use std::{
};
mod cleanup;
mod graphics;
mod install_rollback;
mod lifecycle;
mod native;
pub(crate) mod permissions;
@@ -439,17 +441,16 @@ fn containers(home: &Path, db: &Database) -> Vec<Container> {
for id in &db.owned_contexts {
if safe(id) && !out.iter().any(|c| c.id == *id) {
let baked = managed_base.join(id).join("baked");
if baked.parent().is_some_and(|p| p.is_dir())
&& !fs::symlink_metadata(&baked).is_ok_and(|m| m.file_type().is_symlink())
{
out.push(Container {
id: id.clone(),
name: id.clone(),
baked,
running: false,
managed: true,
steam: false,
});
let candidate = Container {
id: id.clone(),
name: id.clone(),
baked,
running: false,
managed: true,
steam: false,
};
if cleanup::validate_location(home, db, &candidate).is_ok() {
out.push(candidate);
}
}
}
@@ -656,7 +657,8 @@ fn automatic_install_context(
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)?;
let bytes = fs::read(&p)
.with_context(|| format!("Cannot read Android package database {}", p.display()))?;
ensure!(
bytes.len() <= 16 * 1024 * 1024,
"Package database is too large"
@@ -2788,8 +2790,9 @@ pub fn q2f_convert(
ticket: &str,
cancel: &Cancellation,
progress: impl Fn(Value) -> Result<()>,
proxy: &ProxySettings,
) -> Result<Value> {
q2f::convert(home, ticket, cancel, progress)
q2f::convert(home, ticket, cancel, progress, proxy)
}
pub fn q2f_status(home: &Path) -> Result<Value> {
q2f::status(home)
@@ -3083,8 +3086,9 @@ fn operate_internal(
}
}
let packages = if c.baked.exists() {
installed(home, &c)?
installed(home, &c).with_context(|| format!("Existing container is unreadable; inspect its log or explicitly clean the damaged container before retrying: {}", c.baked.display()))?
} else {
ensure!(!cs.iter().any(|old| old.id == c.id), "Existing container is incomplete; explicitly clean the damaged container before retrying: {}", c.baked.display());
Vec::new()
};
let previous = packages
@@ -3117,6 +3121,12 @@ fn operate_internal(
fs::metadata(review.join("base.apk"))?.len() * 4
+ if c.baked.exists() { size(&c.baked) } else { 0 },
)?;
let fresh=if !cs.iter().any(|old|old.id==c.id)
&& !db.records.values().any(|record|record.context==c.id) {
Some(install_rollback::NewContext::reserve(home,&db,&c)?)
} else {None};
let mut backup_id=None;
let attempt=(|| -> Result<Value> {
let rec = db.records.entry(app_id.clone()).or_insert_with(|| Record {
id: app_id.clone(),
context: c.id.clone(),
@@ -3135,18 +3145,39 @@ fn operate_internal(
// Before submission cancellation remains possible. Package-manager submission is a commit.
cancel.commit(||{
progress(json!({"cancellable":false}));
let mut backup_id=None;if c.baked.exists(){progress(json!({"phase":"backing-up"}));stop(&c,&log)?;backup_id=Some(backup(home,&c,&db,&log)?);}
progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
if fresh.is_none() && c.baked.exists(){progress(json!({"phase":"backing-up"}));stop(&c,&log)?;backup_id=Some(backup(home,&c,&db,&log)?);}
progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log).with_context(||format!("Cannot initialize Android container {}",c.baked.display()))?;
let sdk=podman(&["exec",&format!("lepton-{}",c.name),"getprop","ro.build.version.sdk"],Some(&log))?.trim().parse::<u32>()?;ensure!(metadata.min_sdk<=sdk,"APK requires Android SDK {}, container provides {}",metadata.min_sdk,sdk);
progress(json!({"phase":"installing","cancellable":false}));install_package(&c,&review.join("base.apk"),&log)?;
progress(json!({"phase":"verifying"}));let path=podman(&["exec",&format!("lepton-{}",c.name),"pm","path",&metadata.package],Some(&log))?;ensure!(path.contains("package:"),"Android did not report the installed APK");let dest=r.join("apks").join(hash(&app_id));store_apk(&review.join("base.apk"),&dest)?;
// Installation boots a headless container solely for the package manager.
// Finish that runtime before Steam takes ownership of the first app launch.
stop(&c,&log)?;
let rec=db.records.get_mut(&app_id).unwrap();rec.metadata=metadata.clone();rec.removed=false;rec.pending=None;rec.show_window=settings.show_window;rec.q2f_version=q2f_version;save(home,&db)?;fs::remove_dir_all(review)?;
let rec=db.records.get_mut(&app_id).unwrap();rec.metadata=metadata.clone();rec.removed=false;rec.pending=None;rec.show_window=settings.show_window;rec.q2f_version=q2f_version;save(home,&db)?;
// Review cleanup cannot turn a committed installation into a failed one.
if let Err(error)=fs::remove_dir_all(&review) {let _=fs::OpenOptions::new().append(true).create(true).open(&log).and_then(|mut f|writeln!(f,"Review cleanup failed: {error}"));}
// Mirror the operation log under the stable application id for newly installed apps.
if id!=app_id {let _=fs::copy(&log,r.join("logs").join(format!("{}-{}.log",hash(&app_id),now())));}
Ok(json!({"app":app_id,"backup":backup_id,"launchable":!metadata.activities.is_empty()}))})
Ok(json!({"app":app_id,"backup":backup_id.clone(),"launchable":!metadata.activities.is_empty()}))})
})();
match attempt {
Ok(value)=>Ok(value),
Err(error)=>{
let _=fs::OpenOptions::new().append(true).create(true).open(&log).and_then(|mut f|writeln!(f,"APK installation failed: {error:#}"));
if let Some(fresh)=fresh {
progress(json!({"phase":"rolling-back","cancellable":false}));
match fresh.rollback(home,&mut db,&c,&log) {
Ok(())=>bail!("New installation failed; newly created container was cleaned up: {error:#}"),
Err(cleanup_error)=>{
let _=fs::OpenOptions::new().append(true).create(true).open(&log).and_then(|mut f|writeln!(f,"New container rollback failed: {cleanup_error:#}"));
bail!("Installation failed; container retained because safe cleanup failed: {error:#}; cleanup: {cleanup_error:#}");
}
}
} else {
bail!("Installation failed; existing container and data retained (backup: {}): {error:#}",backup_id.as_deref().unwrap_or("not created"));
}
}
}
}
"reconcile" => {
let cs = containers(home, &db);
@@ -3527,10 +3558,11 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
.any(|other| other.name == c.name && other.baked != c.baked),
"Container name is ambiguous; correct its data location first"
);
ensure!(
validate_baked(&c.baked)? == c.baked,
"Container data location changed"
);
if kind == "container.delete" {
cleanup::validate_location(home, &db, &c)?;
} else {
ensure!(validate_baked(&c.baked)? == c.baked, "Container data location changed");
}
cancel.commit(|| {
progress(json!({"cancellable":false}));
match kind {
@@ -4605,6 +4637,120 @@ echo true > '{}'
}
}
#[test]
fn failed_install_new_context_is_rolled_back_and_logs_retained() {
let f = Fixture::new();
fs::write(f.dir.path().join("running"), "missing").unwrap();
let entry = f.home.join(".local/share/Steam/steamapps/common/Lepton/lepton");
// A missing interpreter reproduces ENOENT after context reservation.
let script = fs::read_to_string(&entry).unwrap().replace("#!/bin/bash", "#!/missing-framely-test-interpreter");
fs::write(entry, script).unwrap();
let ticket = f.review(43);
let old_log = root(&f.home).join("logs").join(format!("{}-previous.log", hash("new-test/com.example.app")));
fs::write(&old_log, "previous failure").unwrap();
let error = f.operation("install", json!({"context":"new-test","ticket":ticket,"approve":true})).unwrap_err();
assert!(format!("{error:#}").contains("newly created container was cleaned up"), "{error:#}");
assert!(format!("{error:#}").contains("Cannot initialize Android container"), "{error:#}");
assert!(format!("{error:#}").contains("No such file or directory"), "{error:#}");
assert!(!contexts_root(&f.home).join("new-test").exists());
let db = load(&f.home).unwrap();
assert!(!db.owned_contexts.iter().any(|id| id == "new-test"));
assert!(!db.records.values().any(|r| r.context == "new-test"));
assert_eq!(fs::read_to_string(f.save_file()).unwrap(), "saved progress");
assert_eq!(fs::read_to_string(old_log).unwrap(), "previous failure");
assert!(fs::read_dir(root(&f.home).join("logs")).unwrap().any(|entry|
fs::read_to_string(entry.unwrap().path()).unwrap().contains("APK installation failed")));
}
#[test]
fn failed_install_unsafe_rollback_retains_container_and_record() {
let f = Fixture::new();
let mut db = load(&f.home).unwrap();
let c = Container { id:"new-test".into(), name:"new-test".into(), baked:contexts_root(&f.home).join("new-test/baked"), running:false, managed:true, steam:false };
db.owned_contexts.push(c.id.clone());
fs::write(f.dir.path().join("running"), "missing").unwrap();
let reserved = install_rollback::NewContext::reserve(&f.home, &db, &c).unwrap();
db.records.insert("new-test/com.example.app".into(), Record { id:"new-test/com.example.app".into(), context:c.id.clone(), ..Default::default() });
save(&f.home, &db).unwrap();
fs::write(f.dir.path().join("running"), "query-error").unwrap();
let log = root(&f.home).join("logs/rollback-test.log");
fs::write(&log, "original installation failure").unwrap();
assert!(reserved.rollback(&f.home, &mut db, &c, &log).is_err());
assert!(c.baked.parent().unwrap().exists());
assert!(load(&f.home).unwrap().records.contains_key("new-test/com.example.app"));
assert!(log.exists());
}
#[test]
fn failed_install_reservation_never_adopts_existing_or_replaced_directories() {
let f = Fixture::new();
let mut db = load(&f.home).unwrap();
let parent = contexts_root(&f.home).join("new-test");
let c = Container { id:"new-test".into(), name:"new-test".into(), baked:parent.join("baked"), running:false, managed:true, steam:false };
db.owned_contexts.push(c.id.clone());
fs::write(f.dir.path().join("running"), "missing").unwrap();
fs::create_dir(&parent).unwrap();
fs::write(parent.join("save"), "old data").unwrap();
assert!(install_rollback::NewContext::reserve(&f.home, &db, &c).is_err());
let old = f.dir.path().join("old-data");
fs::rename(&parent, &old).unwrap();
let reservation = install_rollback::NewContext::reserve(&f.home, &db, &c).unwrap();
let reserved = f.dir.path().join("reserved");
fs::rename(&parent, &reserved).unwrap();
fs::create_dir(&parent).unwrap();
fs::write(parent.join("save"), "replacement data").unwrap();
assert!(reservation.rollback(&f.home, &mut db, &c, &root(&f.home).join("logs/test.log")).is_err());
assert_eq!(fs::read_to_string(old.join("save")).unwrap(), "old data");
assert_eq!(fs::read_to_string(parent.join("save")).unwrap(), "replacement data");
assert!(reserved.exists());
}
#[test]
fn damaged_container_owned_cleanup_preserves_backups() {
let f = Fixture::new();
list(&f.home).unwrap();
let mut db = load(&f.home).unwrap();
db.owned_contexts.push("test".into());
save(&f.home, &db).unwrap();
fs::remove_file(contexts_root(&f.home).join("test/baked/data_overlay/system/packages.xml")).unwrap();
let ticket = f.review(43);
let error = f.operation("install", json!({"context":"test","ticket":ticket,"approve":true})).unwrap_err();
assert!(format!("{error:#}").contains("Existing container is unreadable"));
assert_eq!(fs::read_to_string(f.save_file()).unwrap(), "saved progress");
let backup = root(&f.home).join("backups/keep");
fs::create_dir_all(&backup).unwrap();
fs::write(backup.join("save"), "backup").unwrap();
assert!(f.operation("container.delete", json!({"context":"test","approve":true})).is_ok());
assert!(!contexts_root(&f.home).join("test").exists());
assert!(backup.join("save").exists());
assert!(!load(&f.home).unwrap().records.contains_key("test/com.example.app"));
}
#[test]
fn damaged_container_unowned_or_symlink_cleanup_is_rejected() {
let f = Fixture::new();
let mut db = load(&f.home).unwrap();
let baked = contexts_root(&f.home).join("test/baked");
fs::remove_file(baked.join("data_overlay/system/packages.xml")).unwrap();
let c = Container { id:"test".into(), name:"test".into(), baked:baked.clone(), running:false, managed:true, steam:false };
assert!(cleanup::validate_location(&f.home, &db, &c).is_err());
db.owned_contexts.push("test".into());
let moved = f.dir.path().join("preserved-data");
fs::rename(&baked, &moved).unwrap();
std::os::unix::fs::symlink(&moved, &baked).unwrap();
assert!(cleanup::validate_location(&f.home, &db, &c).is_err());
assert_eq!(fs::read_to_string(moved.join("data_overlay/data/com.example.app/files/save")).unwrap(), "saved progress");
}
#[test]
fn damaged_container_missing_directory_can_clear_owned_journal() {
let f = Fixture::new();
list(&f.home).unwrap();
let mut db = load(&f.home).unwrap();
db.owned_contexts.push("test".into());
save(&f.home, &db).unwrap();
fs::remove_dir_all(contexts_root(&f.home).join("test")).unwrap();
fs::write(f.dir.path().join("running"), "missing").unwrap();
assert!(list(&f.home).unwrap()["containers"].as_array().unwrap().iter().any(|c| c["id"] == "test"));
f.operation("container.delete", json!({"context":"test","approve":true})).unwrap();
assert!(!load(&f.home).unwrap().records.contains_key("test/com.example.app"));
assert!(!load(&f.home).unwrap().owned_contexts.iter().any(|id| id == "test"));
}
#[test]
fn unchanged_inventory_preserves_state_file_and_changes_still_persist() {
use std::os::unix::fs::MetadataExt;
let f = Fixture::new();
+94 -8
View File
@@ -16,6 +16,57 @@ fn directory(path: &Path) -> Result<()> {
}
Ok(())
}
// A failed first boot has no packages.xml. Only explicit Framely ownership
// plus an exact, non-symlink managed path may replace that validation.
pub(super) fn validate_location(home: &Path, db: &Database, c: &Container) -> Result<()> {
if let Ok(path) = validate_baked(&c.baked) {
ensure!(path == c.baked, "Container data location changed");
return Ok(());
}
ensure!(
c.managed
&& !c.steam
&& c.id == c.name
&& safe(&c.id)
&& !c.id.starts_with("steamlaunch-")
&& db.owned_contexts.contains(&c.id),
"Unreadable containers can only be cleaned when owned by Framely"
);
let base = contexts_root(home);
ensure!(
base.is_absolute() && fs::canonicalize(&base)? == base,
"Managed container location changed"
);
let parent = base.join(&c.id);
ensure!(
c.baked == parent.join("baked"),
"Container data location changed"
);
for path in [&base, &parent, &c.baked] {
directory(path)?;
if path.exists() {
ensure!(
fs::canonicalize(path)? == *path,
"Container cleanup path contains a link"
);
}
}
Ok(())
}
pub(super) fn ensure_runtime_absent(c: &Container) -> Result<()> {
ensure!(
runtime(&c.name, &c.baked, true)?.is_none(),
"Container runtime already exists; refusing to replace it"
);
Ok(())
}
fn identity(path: &Path) -> Result<Option<(u64, u64)>> {
match fs::symlink_metadata(path) {
Ok(m) => Ok(Some((m.dev(), m.ino()))),
Err(e) if absent(&e) => Ok(None),
Err(e) => Err(e.into()),
}
}
fn remove_owned(path: &Path) -> Result<()> {
match fs::symlink_metadata(path) {
Ok(m) => {
@@ -80,6 +131,24 @@ fn runtime(name: &str, baked: &Path, original: bool) -> Result<Option<String>> {
}
pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path) -> Result<()> {
delete_internal(home, db, c, log, false)
}
pub(super) fn delete_failed_install(
home: &Path,
db: &mut Database,
c: &Container,
log: &Path,
) -> Result<()> {
delete_internal(home, db, c, log, true)
}
fn delete_internal(
home: &Path,
db: &mut Database,
c: &Container,
log: &Path,
preserve_logs: bool,
) -> Result<()> {
validate_location(home, db, c)?;
let records: Vec<_> = db
.records
.values()
@@ -113,7 +182,12 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
} else {
c.id.clone()
};
let standard = (if db.owned_contexts.contains(&c.id) { super::contexts_root(home) } else { home.join(".local/share/lepton/contexts") }).join(&c.id);
let standard = (if db.owned_contexts.contains(&c.id) {
super::contexts_root(home)
} else {
home.join(".local/share/lepton/contexts")
})
.join(&c.id);
let default_location = !c.id.starts_with("external-")
&& !fs::symlink_metadata(&standard).is_ok_and(|m| m.file_type().is_symlink())
&& fs::canonicalize(&standard).is_ok_and(|p| p == standard && p.join("baked") == c.baked);
@@ -122,14 +196,15 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
} else {
c.baked.clone()
};
let target_identity = identity(&target)?;
let mut names = BTreeSet::from([original.clone(), c.name.clone()]);
names.extend(records.iter().filter_map(native::runtime_name));
// Resolve every name before stopping anything. A recycled Steam App ID
// pointing at different storage must never be removed.
let mut runtimes = Vec::new();
for name in names {
if let Some(id) = runtime(&name, &c.baked, name == original)? {
runtimes.push((name, id));
for name in &names {
if let Some(id) = runtime(name, &c.baked, *name == original)? {
runtimes.push((name.clone(), id));
}
}
for (name, _) in &runtimes {
@@ -156,9 +231,14 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
for rec in &records {
steam_shortcuts::unregister(home, rec)?;
}
validate_location(home, db, c)?;
ensure!(
validate_baked(&c.baked)? == c.baked,
"Container data location changed"
identity(&target)? == target_identity,
"Container directory changed during deletion"
);
ensure!(
!names.iter().any(|name| wrapper_active(name)),
"A container launcher is still running; wait for it to close"
);
for (name, identity) in &runtimes {
if let Some(current) = runtime(name, &c.baked, *name == original)? {
@@ -181,14 +261,20 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
for entry in fs::read_dir(root(home).join("logs"))? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if entry.path() != log
if !preserve_logs
&& entry.path() != log
&& name.ends_with(".log")
&& keys.iter().any(|key| name.starts_with(&format!("{key}-")))
{
remove_owned(&entry.path())?;
}
}
remove_tree(&target)?;
validate_location(home, db, c)?;
ensure!(
identity(&target)? == target_identity,
"Container directory changed during deletion"
);
remove_owned(&target)?;
db.roots.retain(|p| p != &c.baked);
db.root_contexts.remove(&c.baked);
db.records.retain(|_, r| r.context != c.id);
+53
View File
@@ -0,0 +1,53 @@
//! Roll back only a directory atomically reserved by this installation.
use super::*;
pub(super) struct NewContext {
parent: PathBuf,
identity: (u64, u64),
}
impl NewContext {
pub(super) fn reserve(home: &Path, db: &Database, c: &Container) -> Result<Self> {
cleanup::ensure_runtime_absent(c)?;
let base = contexts_root(home);
let ancestor = base
.ancestors()
.find(|p| p.exists())
.context("Missing context storage ancestor")?;
ensure!(
fs::canonicalize(ancestor)? == ancestor,
"Container storage contains a link"
);
fs::create_dir_all(&base)?;
cleanup::validate_location(home, db, c)?;
let parent = c
.baked
.parent()
.context("Missing context parent")?
.to_path_buf();
// create_dir, not create_dir_all: an existing/replaced context is never
// owned by this attempt, regardless of its size or missing database.
fs::create_dir(&parent)
.with_context(|| format!("Cannot reserve new container {}", parent.display()))?;
let metadata = fs::symlink_metadata(&parent)?;
Ok(Self {
parent,
identity: (metadata.dev(), metadata.ino()),
})
}
pub(super) fn rollback(
self,
home: &Path,
db: &mut Database,
c: &Container,
log: &Path,
) -> Result<()> {
let metadata = fs::symlink_metadata(&self.parent)?;
ensure!(
metadata.is_dir()
&& !metadata.file_type().is_symlink()
&& (metadata.dev(), metadata.ino()) == self.identity,
"New container directory changed; rollback refused"
);
cleanup::delete_failed_install(home, db, c, log)
}
}
+5
View File
@@ -1,5 +1,6 @@
//! Optional Quest APK conversion. The runtime is versioned separately from Framely.
use super::{apk_metadata, init, review, space, Cancellation, Metadata, MUTATION};
use crate::model::ProxySettings;
use anyhow::{ensure, Context, Result};
use serde_json::{json, Value};
use std::{
@@ -251,6 +252,7 @@ pub(super) fn convert(
ticket: &str,
cancel: &Cancellation,
progress: impl Fn(Value) -> Result<()>,
proxy: &ProxySettings,
) -> Result<Value> {
let _guard = MUTATION.lock().unwrap();
cancel.check()?;
@@ -285,6 +287,9 @@ pub(super) fn convert(
let log = storage.join("logs").join(format!("q2f-{ticket}.log"));
let mut prepare = Command::new("python3");
prepare.arg(tool_path()?).arg(&descriptor).arg(&storage);
prepare.env("FRAMELY_GITHUB_AUTO_FALLBACK", if proxy.github_auto_fallback {"1"} else {"0"});
prepare.env("FRAMELY_GITHUB_PROXY", if proxy.github_enabled {&proxy.github} else {""});
prepare.env("FRAMELY_HTTP_PROXY", if proxy.http_enabled {&proxy.http} else {""});
run(prepare, &log, cancel)?;
let runtime = PathBuf::from(
fs::read_to_string(&log)?
+57
View File
@@ -0,0 +1,57 @@
// Only missing artwork or content previously generated by Framely may change.
async function(p, id, render, selectPalette) {
const suffix={0:'p',1:'_hero',2:'_logo',3:''};
const load=data=>new Promise((resolve,reject)=>{const i=new Image(),timer=setTimeout(()=>reject(Error('Artwork decode timeout')),4000);i.onload=()=>{clearTimeout(timer);resolve(i);};i.onerror=()=>{clearTimeout(timer);reject(Error('Invalid artwork'));};i.src=data;});
const digest=async image=>{
let c=image;
if(!image.getContext){c=document.createElement('canvas');c.width=image.width;c.height=image.height;c.getContext('2d').drawImage(image,0,0);}
const ctx=c.getContext('2d'),pixels=ctx.getImageData(0,0,c.width,c.height).data;
const header=new TextEncoder().encode(`${c.width}x${c.height}:`),input=new Uint8Array(header.length+pixels.length);input.set(header);input.set(pixels,header.length);
return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',input))).map(v=>v.toString(16).padStart(2,'0')).join('');
};
const inspect=async kind=>{
const hashes=[];
for(const ext of ['png','jpg','jpeg','webp']){
const response=await fetch(`/customimages/${id}${suffix[kind]}.${ext}?framely=${Date.now()}`,{cache:'no-store'});
if(response.status===404)continue;
if(!response.ok)throw Error('Cannot inspect existing Steam artwork');
const blob=await response.blob();
if(blob.size>16*1024*1024)throw Error('Existing Steam artwork is too large');
const data=await new Promise((resolve,reject)=>{const r=new FileReader();r.onload=()=>resolve(r.result);r.onerror=reject;r.readAsDataURL(blob);});
const image=await load(data);
if(image.width>8192||image.height>8192||image.width*image.height>16*1024*1024)throw Error('Existing artwork dimensions are too large');
hashes.push(await digest(image));
}
return hashes;
};
const owned=p.ownership?.appId===id?p.ownership.hashes:{};
const hashes={...owned}, generated=await render(p,selectPalette);
try { for(const a of generated){
if(!Object.hasOwn(suffix,a.kind))throw Error('Unsupported Steam artwork type');
const current=await inspect(a.kind), previous=[];
if(owned[a.kind])previous.push(owned[a.kind]);
// v1 mistakenly sent the icon as type 4; Steam wrote it to the SAME
// landscape path as type 3. Repair only that exact owned image.
if(a.kind===3&&owned[4])previous.push(owned[4]);
// One-time upgrade of the exact old plain-background/icon-only images.
const legacy=p.legacy?.find(v=>v.kind===a.kind);
if(legacy)previous.push(await digest(await load('data:image/png;base64,'+legacy.data)));
if(a.kind===3){const oldIcon=p.legacy?.find(v=>v.kind===4);if(oldIcon)previous.push(await digest(await load('data:image/png;base64,'+oldIcon.data)));}
if(current.length&&current.some(h=>!previous.includes(h))){delete hashes[a.kind];if(a.kind===3)delete hashes[4];continue;}
const hash=await digest(a.canvas);
if(current.length===1&&current[0]===hash){hashes[a.kind]=hash;if(a.kind===3)delete hashes[4];continue;}
// Recheck just before writing in case a user changed the image meanwhile.
const rechecked=await inspect(a.kind);
if(JSON.stringify(current)!==JSON.stringify(rechecked)){delete hashes[a.kind];continue;}
try{await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind);}
catch(error){await new Promise(r=>setTimeout(r,200));const latest=await inspect(a.kind);if(latest.some(h=>!previous.includes(h)&&h!==hash))throw Error('Steam artwork changed during retry');await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind);}
hashes[a.kind]=hash;
if(a.kind===3)delete hashes[4];
}} catch(error) {
// Return completed hashes even on a partial failure. The core persists
// this checkpoint before reporting failure, so retry does not mistake
// images just written by Framely for user-created artwork.
return {version:1,appId:id,hashes,failed:true};
}
return {version:1,appId:id,hashes};
}
+68
View File
@@ -0,0 +1,68 @@
// Pure image algorithm. Package IDs and application names never enter selection.
function(pixels, width, height) {
if (pixels.length !== width * height * 4) throw Error('Invalid RGBA image');
const distance = (a, b) => a.reduce((sum, v, i) => sum + (v - b[i]) ** 2, 0);
const average = (points, key) => {
const weight = points.reduce((sum, p) => sum + p.weight, 0);
return [0, 1, 2].map(i => points.reduce((sum, p) => sum + p[key][i] * p.weight, 0) / weight);
};
const hex = rgb => '#' + rgb.map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
const lab = rgb => {
const [r, g, b] = rgb.map(v => { v /= 255; return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4; });
const l = Math.cbrt(.4122214708*r + .5363325363*g + .0514459929*b);
const m = Math.cbrt(.2119034982*r + .6806995451*g + .1073969566*b);
const s = Math.cbrt(.0883024619*r + .2817188376*g + .6299787005*b);
return [.2104542553*l + .793617785*m - .0040720468*s, 1.9779984951*l - 2.428592205*m + .4505937099*s, .0259040371*l + .7827717662*m - .808675766*s];
};
const hsl = rgb => {
const [r,g,b] = rgb.map(v => v/255), hi = Math.max(r,g,b), lo = Math.min(r,g,b), d = hi-lo, l = (hi+lo)/2;
const h = d === 0 ? 0 : hi === r ? (g-b)/d : hi === g ? (b-r)/d+2 : (r-g)/d+4;
return [(h*60+360)%360, d === 0 ? 0 : d/(1-Math.abs(2*l-1)), l];
};
const fromHsl = (h,s,l) => {
const a=s*Math.min(l,1-l), f=n=>{const k=(n+h/30)%12;return Math.round(255*(l-a*Math.max(-1,Math.min(k-3,9-k,1))));};
return [f(0),f(8),f(4)];
};
const points = [];
for (let y=0; y<height; y++) for (let x=0; x<width; x++) {
const i=(y*width+x)*4; if(pixels[i+3]<32) continue;
const rgb=Array.from(pixels.subarray(i,i+3));
points.push({rgb,lab:lab(rgb),weight:pixels[i+3]/255,edge:x<width*.19||x>=width*.81||y<height*.19||y>=height*.81});
}
const neutral = {selected:'#808080',base:'#191a1d',accent:'#9a9da5',neutral:true};
if(!points.length) return neutral;
let centers=[average(points,'lab')], groups;
while(centers.length<8) {
const nearest=p=>Math.min(...centers.map(c=>distance(p.lab,c)));
const far=points.reduce((best,p)=>nearest(p)>nearest(best)?p:best,points[0]);
if(nearest(far)<.0004) break;
centers.push(far.lab);
}
for(let step=0;step<24;step++) {
groups=centers.map(()=>[]);
for(const p of points) {const ds=centers.map(c=>distance(p.lab,c));groups[ds.indexOf(Math.min(...ds))].push(p);}
const next=groups.map((g,i)=>g.length?average(g,'lab'):centers[i]);
const delta=Math.max(...next.map((c,i)=>distance(c,centers[i])));
centers=next;if(delta<.0000001)break;
}
const clusters=groups.filter(g=>g.length).map(points=>({points,lab:average(points,'lab')}));
for(let changed=true;changed;) {
changed=false;
outer:for(let i=0;i<clusters.length;i++)for(let j=i+1;j<clusters.length;j++)if(distance(clusters[i].lab,clusters[j].lab)<.065**2) {
clusters[i].points.push(...clusters[j].points);clusters[i].lab=average(clusters[i].points,'lab');clusters.splice(j,1);changed=true;break outer;
}
}
const total=points.reduce((s,p)=>s+p.weight,0), edgeTotal=points.filter(p=>p.edge).reduce((s,p)=>s+p.weight,0);
const candidates=clusters.map(c=>{
const coverage=c.points.reduce((s,p)=>s+p.weight,0)/total;
const edgeCoverage=edgeTotal?c.points.filter(p=>p.edge).reduce((s,p)=>s+p.weight,0)/edgeTotal:coverage;
const rgb=average(c.points,'rgb'), chroma=Math.hypot(c.lab[1],c.lab[2]);
const eligible=coverage>=.035&&edgeCoverage>=.025&&chroma>=.012&&c.lab[0]>.18&&c.lab[0]<.96;
return {rgb,coverage,edgeCoverage,eligible,score:(.5*coverage+.5*edgeCoverage)*Math.min(1,chroma/.025)*(chroma>.16?.8:1)};
}).sort((a,b)=>b.score-a.score);
const best=candidates.find(c=>c.eligible);
if(!best||best.score<.055)return {...neutral,selected:hex([...candidates].sort((a,b)=>(b.coverage+b.edgeCoverage)-(a.coverage+a.edgeCoverage))[0].rgb)};
const [h,s]=hsl(best.rgb);
// Multiply all RGB channels uniformly: 80% intensity, no hue remapping.
return {selected:hex(best.rgb),base:hex(fromHsl(h,s*.22,.095)),accent:hex(best.rgb.map(v=>v*.8)),neutral:false};
}
+61
View File
@@ -0,0 +1,61 @@
// Render inside Steam's existing CEF context; no external runtime/fonts needed.
async function(p, selectPalette) {
const load = data => new Promise((resolve,reject)=>{
const image=new Image(), timer=setTimeout(()=>reject(Error('Artwork image timeout')),4000);
image.onload=()=>{clearTimeout(timer);resolve(image);};
image.onerror=()=>{clearTimeout(timer);reject(Error('Invalid artwork image'));};image.src=data;
});
const source=await load(p.image);
const canvas=(w,h)=>{const c=document.createElement('canvas');c.width=w;c.height=h;return c;};
const sample=canvas(64,64), sc=sample.getContext('2d');sc.drawImage(source,0,0,64,64);
const palette=selectPalette(sc.getImageData(0,0,64,64).data,64,64);
let n=2166136261;
for(const c of p.package)n=Math.imul(n^c.charCodeAt(0),16777619);
const random=()=>{n+=0x6D2B79F5;let t=n;t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return ((t^(t>>>14))>>>0)/4294967296;};
const angle=(-39-random()*8)*Math.PI/180, offset=(random()-.5)*65;
const shade=(hex,f)=>'#'+[1,3,5].map(i=>Math.round(parseInt(hex.slice(i,i+2),16)*f).toString(16).padStart(2,'0')).join('');
const rounded=(c,x,y,w,h,r)=>{c.beginPath();c.roundRect(x,y,w,h,r);};
const icon=(c,x,y,s,rotation=0,opacity=1)=>{
c.save();c.globalAlpha=opacity;c.translate(x+s/2,y+s/2);c.rotate(rotation*Math.PI/180);c.translate(-s/2,-s/2);
rounded(c,0,0,s,s,s*.23);c.clip();
const scale=Math.min(s/source.width,s/source.height),w=source.width*scale,h=source.height*scale;
c.drawImage(source,(s-w)/2,(s-h)/2,w,h);c.restore();
};
const fontSize=(size,max)=>{
// Use actual font metrics, including CJK/emoji and long custom names.
const c=sc;c.font=`700 ${size}px "Noto Sans CJK SC", "Noto Sans", sans-serif`;
const width=c.measureText(p.name).width;return width>max?size*max/width:size;
};
const title=(c,x,y,size,max,center=false)=>{
c.save();c.fillStyle='#fff';c.textAlign=center?'center':'left';c.font=`700 ${fontSize(size,max)}px "Noto Sans CJK SC", "Noto Sans", sans-serif`;
c.fillText(p.name,x,y);c.restore();
};
const base=(c,w,h)=>{
const g=c.createLinearGradient(0,0,w,h);g.addColorStop(0,palette.base);g.addColorStop(.43,shade(palette.base,.7));g.addColorStop(1,shade(palette.accent,.24));
c.fillStyle=g;c.fillRect(0,0,w,h);
c.save();c.translate(w*.82,h*.82);c.scale(w*.7,h*.85);
const light=c.createRadialGradient(0,0,0,0,0,1);light.addColorStop(0,palette.accent+'0f');light.addColorStop(1,palette.accent+'00');c.fillStyle=light;c.fillRect(-1,-1,2,2);c.restore();
};
const pill=(c,x,y,w,h,muted=false,opacity=1)=>{
c.save();c.translate(x+w/2,y+h/2);c.rotate(angle);c.translate(-w/2,-h/2);
c.globalAlpha=muted?opacity:1;
if(muted){const g=c.createLinearGradient(0,0,w,h);g.addColorStop(0,shade(palette.accent,.22));g.addColorStop(1,shade(palette.accent,.4));c.fillStyle=g;}else c.fillStyle=palette.accent;
rounded(c,0,0,w,h,h/2);c.fill();c.restore();
};
const line=(c,x,y,w,h)=>{c.fillStyle=palette.accent;rounded(c,x,y,w,h,h/2);c.fill();};
const result=[];
// Only these four Steam custom-artwork enums are supported. A shortcut
// icon goes through SetShortcutIcon, never SetCustomArtworkForApp(type=4).
for(const [kind,w,h] of [[0,600,900],[1,3840,1240],[2,1280,360],[3,920,430]]){
const image=canvas(w,h),c=image.getContext('2d');
if(kind===0){base(c,w,h);pill(c,-205+offset,30,540,116);pill(c,206,-90+offset,425,98,true,.42);pill(c,-132,684,310,100);icon(c,315,676,490,-16,.5);icon(c,155,146,290);title(c,300,551,65,490,true);line(c,265,592,70,7);}
if(kind===3){base(c,w,h);pill(c,-100+offset,-24,405,80);pill(c,700,-30+offset,355,100,true,.35);pill(c,692,350,350,104);pill(c,570,410,286,49,true,.55);icon(c,76,117,196);title(c,323,243,62,490);line(c,326,282,62,6);}
// Frame currently displays the hero at ~4.17:1 using object-fit:cover.
// Keep the main motifs within the central 75% height, rather than
// designing solely against the uncropped 3840x1240 export.
if(kind===1){base(c,w,h);pill(c,-520,-140,1410,240);pill(c,1650+offset*2,400,620,150,true,.43);pill(c,2540,680,720,190);pill(c,2140,780,520,100,true,.5);pill(c,3300,450,700,160,true,.32);}
if(kind===2){icon(c,16,24,312);title(c,382,232,136,866);}
result.push({kind,data:image.toDataURL('image/png').split(',')[1],canvas:image});
}
return result;
}
+15 -4
View File
@@ -234,6 +234,7 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
icon.as_deref(),
&launch,
&native_apk(home, &a.id),
&a.metadata.package,
)?);
r.steam_binding = Some(Binding {
game_id: game,
@@ -270,7 +271,9 @@ pub(super) fn unregister(home: &Path, r: &Record) -> Result<()> {
"icon.png",
"icon.webp",
"icon.jpg",
"steam-icon.png",
"lepton-show-flatscreen",
".framely-artwork.json",
] {
let file = p.parent().unwrap().join(name);
if file.exists() {
@@ -287,11 +290,14 @@ pub(super) fn unregister(home: &Path, r: &Record) -> Result<()> {
}
pub(super) fn parse_name(value: &Value) -> Result<Option<String>> {
let name = value.as_str().context("Invalid Steam display name")?.trim();
ensure!(name.chars().count() <= 128 && !name.chars().any(char::is_control), "Steam display name must be at most 128 characters without control characters");
ensure!(
name.chars().count() <= 128 && !name.chars().any(char::is_control),
"Steam display name must be at most 128 characters without control characters"
);
Ok((!name.is_empty()).then(|| name.to_owned()))
}
fn revision(a: &App, r: &Record) -> String {
let mut value = json!({"metadata":a.metadata,"showWindow":a.show_window});
let mut value = json!({"metadata":a.metadata,"showWindow":a.show_window,"artworkVersion":2});
if let Some(name) = &r.steam_name {
value["steamName"] = json!(name);
}
@@ -303,8 +309,13 @@ pub(super) fn update_name(home: &Path, a: &App, r: &mut Record) -> Result<()> {
"(async()=>{{const p={parameters};const owned=await ({lookup})(p);SteamClient.Apps.SetShortcutName(owned.id,p.name);return true;}})()",
lookup = include_str!("steam_shortcut_lookup.js")
))?;
ensure!(result == json!(true), "Steam name update was not acknowledged");
let current_revision = revision(a, r);
ensure!(
result == json!(true),
"Steam name update was not acknowledged"
);
// The next synchronization redraws only our generated titles; custom user
// artwork is preserved by its content hash. Do not mark stale artwork fresh.
let current_revision = format!("artwork-pending:{}", revision(a, r));
if let Some(binding) = &mut r.steam_binding {
binding.revision = current_revision;
}
+161 -20
View File
@@ -54,7 +54,8 @@ pub(crate) fn evaluate(expression: &str) -> Result<Value> {
&SocketAddr::from(([127, 0, 0, 1], 8080)),
Duration::from_secs(3),
)?;
stream.set_read_timeout(Some(Duration::from_secs(6)))?;
// Artwork renders and hashes a 3840x1240 hero inside Steam's CEF context.
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
stream.set_write_timeout(Some(Duration::from_secs(3)))?;
let (mut ws, _) = client(address.as_str(), stream)
.map_err(|_| anyhow::anyhow!("Cannot connect to Steam UI"))?;
@@ -70,7 +71,7 @@ pub(crate) fn evaluate(expression: &str) -> Result<Value> {
.to_string()
.into(),
))?;
let deadline = Instant::now() + Duration::from_secs(8);
let deadline = Instant::now() + Duration::from_secs(30);
loop {
ensure!(Instant::now() < deadline, "Steam UI request timed out");
let message = ws
@@ -98,10 +99,30 @@ pub(super) fn configure(
icon: Option<&Path>,
launch: &str,
executable: &Path,
package: &str,
) -> Result<u32> {
let art = artwork(icon)?;
use base64::Engine;
let legacy = legacy_artwork(icon)?;
let image = icon
.map(|path| -> Result<String> {
ensure!(
fs::metadata(path)?.len() <= 8 * 1024 * 1024,
"APK icon is too large"
);
Ok(format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(fs::read(path)?)
))
})
.transpose()?;
let ownership = icon
.map(|p| load_artwork_ownership(p.parent().context("Missing artwork directory")?))
.transpose()?
.unwrap_or(Value::Null);
let shortcut_icon = icon.map(shortcut_icon_file).transpose()?;
let parameters = json!({"game":game,"previous":previous,"name":name,
"icon":icon,"launch":launch,"exe":executable,"art":art});
"icon":shortcut_icon,"launch":launch,"exe":executable,"image":image,"package":package,
"ownership":ownership,"legacy":legacy});
let expression = format!(
r#"(async()=>{{
const p={parameters};
@@ -109,27 +130,109 @@ pub(super) fn configure(
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);
for(const a of p.art){{
try{{await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind)}}
catch(e){{
await new Promise(r=>setTimeout(r,200));
await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind);
}}
}}
return id;
// Existing shortcuts may have a user-selected icon path.
if(p.icon&&!p.previous) SteamClient.Apps.SetShortcutIcon(id,p.icon);
const ownership=p.image?await ({apply})(p,id,({render}),({palette})):null;
return {{id,ownership}};
}})()"#,
lookup = include_str!("steam_shortcut_lookup.js")
lookup = include_str!("steam_shortcut_lookup.js"),
apply = include_str!("steam_artwork_apply.js"),
render = include_str!("steam_artwork_render.js"),
palette = include_str!("steam_artwork_palette.js"),
);
let result = evaluate(&expression)?;
result
// Numeric mock responses remain supported by the existing IPC fixtures.
let id = result
.get("id")
.unwrap_or(&result)
.as_u64()
.and_then(|v| u32::try_from(v).ok())
.filter(|v| *v >= 0x80000000)
.context("Invalid Steam APK App ID")
.context("Invalid Steam APK App ID")?;
if let (Some(path), Some(ownership)) = (icon, result.get("ownership").filter(|v| !v.is_null()))
{
validate_artwork_ownership(ownership)?;
ensure!(
ownership["appId"] == id,
"Artwork ownership does not match shortcut"
);
super::steam_shortcuts::write_owned(
&path
.parent()
.context("Missing artwork directory")?
.join(".framely-artwork.json"),
&serde_json::to_vec(ownership)?,
0o600,
)?;
ensure!(
ownership["failed"] != true,
"Steam artwork update was interrupted; completed images were recorded and will be retried"
);
}
Ok(id)
}
fn artwork(icon: Option<&Path>) -> Result<Vec<Value>> {
fn validate_artwork_ownership(value: &Value) -> Result<()> {
ensure!(
value["version"] == 1
&& value["appId"]
.as_u64()
.is_some_and(|id| (0x80000000..=u32::MAX as u64).contains(&id)),
"Invalid artwork ownership version or app ID"
);
let hashes = value["hashes"]
.as_object()
.context("Invalid artwork ownership hashes")?;
ensure!(
hashes.len() <= 5
&& hashes.iter().all(|(kind, hash)| matches!(
kind.as_str(),
"0" | "1" | "2" | "3" | "4"
) && hash
.as_str()
.is_some_and(|h| h.len() == 64 && h.bytes().all(|c| c.is_ascii_hexdigit()))),
"Invalid artwork ownership hash"
);
Ok(())
}
fn shortcut_icon_file(source: &Path) -> Result<PathBuf> {
let picture = image::open(source)?.thumbnail(256, 256).to_rgba8();
let mut icon = image::RgbaImage::new(256, 256);
image::imageops::overlay(
&mut icon,
&picture,
((256 - picture.width()) / 2) as i64,
((256 - picture.height()) / 2) as i64,
);
let mut bytes = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(icon).write_to(&mut bytes, image::ImageFormat::Png)?;
let path = source
.parent()
.context("Missing icon directory")?
.join("steam-icon.png");
super::steam_shortcuts::write_owned(&path, &bytes.into_inner(), 0o600)?;
Ok(path)
}
fn load_artwork_ownership(dir: &Path) -> Result<Value> {
let path = dir.join(".framely-artwork.json");
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Value::Null),
Err(error) => return Err(error.into()),
};
ensure!(
metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() <= 8192,
"Invalid artwork ownership file"
);
let value: Value = serde_json::from_slice(&fs::read(path)?)?;
validate_artwork_ownership(&value)?;
Ok(value)
}
// Kept solely to recognize/migrate exact artwork made by earlier releases.
fn legacy_artwork(icon: Option<&Path>) -> Result<Vec<Value>> {
use base64::Engine;
let Some(path) = icon else { return Ok(vec![]) };
let image = image::open(path)?.to_rgba8();
@@ -171,7 +274,7 @@ pub(super) fn clear_artwork(app_id: u32, executable: &Path) -> Result<()> {
sub?.unregister();
const exe=details.strShortcutExe?.replace(/^"|"$/g,'').replace('/./','/');
if(exe!==p.exe) throw Error('Shortcut target does not match owned APK');
for(const kind of [0,1,3,4]) await SteamClient.Apps.ClearCustomArtworkForApp(p.id,kind);
for(const kind of [0,1,2,3]) await SteamClient.Apps.ClearCustomArtworkForApp(p.id,kind);
await SteamClient.Apps.SetShortcutIcon(p.id,'');
}})()"#
))?;
@@ -182,6 +285,22 @@ pub(super) fn clear_artwork(app_id: u32, executable: &Path) -> Result<()> {
mod tests {
use super::*;
#[test]
fn shortcut_icon_has_its_own_256px_file_and_preserves_source() {
let t = tempfile::tempdir().unwrap();
let source = t.path().join("icon.png");
image::RgbaImage::from_pixel(128, 64, image::Rgba([230, 30, 20, 255]))
.save(&source)
.unwrap();
let before = fs::read(&source).unwrap();
let path = shortcut_icon_file(&source).unwrap();
assert_eq!(path.file_name().unwrap(), "steam-icon.png");
let picture = image::open(path).unwrap().to_rgba8();
assert_eq!(picture.dimensions(), (256, 256));
assert_eq!(picture.get_pixel(0, 0).0[3], 0);
assert_eq!(picture.get_pixel(128, 128).0, [230, 30, 20, 255]);
assert_eq!(fs::read(source).unwrap(), before);
}
#[test]
fn steam_artwork_has_matching_aspect_ratios_and_never_crops_icon() {
use base64::Engine;
let t = tempfile::tempdir().unwrap();
@@ -189,7 +308,7 @@ mod tests {
image::RgbaImage::from_pixel(128, 64, image::Rgba([230, 30, 20, 255]))
.save(&p)
.unwrap();
let art = artwork(Some(&p)).unwrap();
let art = legacy_artwork(Some(&p)).unwrap();
assert_eq!(art.len(), 4);
for (a, expected) in art
.iter()
@@ -205,6 +324,28 @@ mod tests {
[230, 30, 20, 255]
);
}
assert!(artwork(None).unwrap().is_empty());
assert!(legacy_artwork(None).unwrap().is_empty());
}
#[test]
fn artwork_ownership_is_bounded_and_never_follows_symlinks() {
let t = tempfile::tempdir().unwrap();
assert!(load_artwork_ownership(t.path()).unwrap().is_null());
let valid = json!({"version":1,"appId":0x92345678u32,"hashes":{"0":"a".repeat(64)}});
let file = t.path().join(".framely-artwork.json");
fs::write(&file, serde_json::to_vec(&valid).unwrap()).unwrap();
assert_eq!(load_artwork_ownership(t.path()).unwrap(), valid);
for invalid in [
json!({}),
json!({"version":1,"appId":42,"hashes":{}}),
json!({"version":1,"appId":0x92345678u32,"hashes":{"5":"a".repeat(64)}}),
json!({"version":1,"appId":0x92345678u32,"hashes":{"0":"not-a-hash"}}),
] {
fs::write(&file, serde_json::to_vec(&invalid).unwrap()).unwrap();
assert!(load_artwork_ownership(t.path()).is_err());
}
fs::remove_file(&file).unwrap();
std::os::unix::fs::symlink(t.path().join("other"), &file).unwrap();
assert!(load_artwork_ownership(t.path()).is_err());
}
}
+12
View File
@@ -57,6 +57,7 @@ pub struct Settings {
pub dlna: bool,
pub receiver_name: String,
pub recording_region_visible: bool,
pub recording_indicator_visible: bool,
}
impl Default for Settings {
fn default() -> Self {
@@ -78,6 +79,7 @@ impl Default for Settings {
dlna: false,
receiver_name: "Framely".into(),
recording_region_visible: false,
recording_indicator_visible: true,
}
}
}
@@ -88,6 +90,7 @@ impl Settings {
capture.dlna = other.dlna;
capture.receiver_name.clone_from(&other.receiver_name);
capture.recording_region_visible = other.recording_region_visible;
capture.recording_indicator_visible = other.recording_indicator_visible;
capture == *other
}
pub fn validate(&self) -> Result<()> {
@@ -129,6 +132,15 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn recording_indicator_defaults_on_and_is_not_capture_configuration() {
let legacy: Settings = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(legacy.recording_indicator_visible);
let mut hidden = legacy.clone();
hidden.recording_indicator_visible = false;
assert!(legacy.same_capture(&hidden));
assert_eq!(serde_json::to_value(hidden).unwrap()["recordingIndicatorVisible"], false);
}
#[test]
fn legacy_settings_keep_h264_and_hevc_is_explicit() {
let legacy: Settings=serde_json::from_value(serde_json::json!({"source":"screen"})).unwrap();
assert_eq!(legacy.codec,Codec::H264);
+37 -5
View File
@@ -1,5 +1,14 @@
use anyhow::{bail, ensure, Context, Result};
use std::time::Duration;
pub(crate) mod github_fallback;
pub(crate) fn with_github_fallback<T>(initial: &str, headers: &[(&str, &str)], proxy: &crate::model::ProxySettings, mut attempt: impl FnMut(&crate::model::ProxySettings) -> Result<T>) -> Result<T> {
github_fallback::run(proxy.github_auto_fallback, initial, headers, |fallback| {
let mut route=proxy.clone();route.github_auto_fallback=false;
if fallback {route.github_enabled=true;route.github=github_fallback::PREFIX.into();route.github_fallback_attempt=true;}
attempt(&route)
})
}
/// Follow at most five redirects, validating each target before requesting it.
/// HTTPS can never be downgraded; HTTP requires explicit source opt-in.
@@ -117,6 +126,9 @@ pub fn get_with_proxy_cancel(
proxy: &crate::model::ProxySettings,
cancel: &crate::jobs::Cancellation,
) -> Result<ureq::Response> {
if proxy.github_auto_fallback {
return with_github_fallback(initial, headers, proxy, |route| get_with_proxy_cancel(initial, allow_http, timeout, headers, route, cancel));
}
let initial = initial.to_owned();
let proxy = proxy.clone();
let headers: Vec<_> = headers
@@ -140,6 +152,9 @@ pub fn get_download_with_proxy(
headers: &[(&str, &str)],
proxy: &crate::model::ProxySettings,
) -> Result<ureq::Response> {
if proxy.github_auto_fallback {
return with_github_fallback(initial, headers, proxy, |route| get_download_with_proxy(initial, allow_http, idle_timeout, headers, route));
}
let initial = initial.to_owned();
let proxy = proxy.clone();
let headers: Vec<_> = headers
@@ -194,7 +209,11 @@ fn get_blocking(
for hop in 0..=5 {
cancel.check()?;
let routed = routed_url(&current, proxy);
let mut request = agent.get(&routed);
#[cfg(test)]
let transport_url = github_fallback::test_url(&routed);
#[cfg(not(test))]
let transport_url = routed.clone();
let mut request = agent.get(&transport_url);
for (key, value) in headers {
request = request.set(key, value);
}
@@ -249,11 +268,9 @@ pub fn read_cancel(
}
fn routed_url(url: &url::Url, proxy: &crate::model::ProxySettings) -> String {
if proxy.github_fallback_attempt && !github_fallback::eligible(url.as_str(), &[]) { return url.to_string(); }
let host = url.host_str().unwrap_or("");
let github = host == "github.com"
|| host.ends_with(".github.com")
|| host == "githubusercontent.com"
|| host.ends_with(".githubusercontent.com");
let github = github_fallback::github_host(host);
if github
&& proxy.github_enabled
&& !url
@@ -270,6 +287,17 @@ fn routed_url(url: &url::Url, proxy: &crate::model::ProxySettings) -> String {
mod tests {
use super::*;
#[test]
fn github_fallback_restarts_a_truncated_plugin_download_from_zero() {
let (target,server)=github_fallback::local_truncated_server();
let source=format!("https://github.com/framely-test/{}/asset",rand::random::<u64>());
let _routes=github_fallback::TestRoutes::new(vec![(source.clone(),target.clone()),(format!("{}/{}",github_fallback::PREFIX,source),target)]);
let mut progress=vec![];
let staged=crate::package::stage_request_proxy(&serde_json::json!({"url":source}),&Default::default(),|received,_|{progress.push(received);Ok(())}).unwrap();
assert_eq!(std::fs::read(&staged.path).unwrap(),b"new-data");
assert!(progress.iter().filter(|&&received| received==0).count()>=2);
server.join().unwrap();
}
#[test]
fn download_progress_outlives_idle_timeout() {
use std::{
io::{Read, Write},
@@ -441,6 +469,8 @@ mod proxy_tests {
#[test]
fn proxy_routing_and_validation() {
let proxy = ProxySettings {
github_auto_fallback: true,
github_fallback_attempt: false,
http_enabled: false,
github_enabled: true,
http: String::new(),
@@ -493,6 +523,7 @@ mod proxy_tests {
github_enabled: false,
http: format!("http://{}", server.server_addr()),
github: String::new(),
..Default::default()
};
let worker = std::thread::spawn(move || {
let request = server
@@ -550,6 +581,7 @@ mod proxy_tests {
github_enabled: true,
http: String::new(),
github: format!("http://{}", server.server_addr()),
..Default::default()
};
let worker = std::thread::spawn(move || {
let request = server
+261
View File
@@ -0,0 +1,261 @@
//! Shared public-GitHub fallback policy for the core and desktop installer.
use anyhow::Result;
use std::io;
pub const PREFIX: &str = "https://gh-proxy.com";
#[cfg(test)]
static TEST_ROUTES: std::sync::Mutex<Vec<(String, String)>> = std::sync::Mutex::new(Vec::new());
#[cfg(test)]
pub(crate) fn test_url(url: &str) -> String {
TEST_ROUTES
.lock()
.unwrap()
.iter()
.find(|(source, _)| source == url)
.map(|(_, target)| target.clone())
.unwrap_or_else(|| url.into())
}
#[cfg(test)]
pub(crate) struct TestRoutes(pub Vec<(String, String)>);
#[cfg(test)]
impl TestRoutes {
pub fn new(routes: Vec<(String, String)>) -> Self {
TEST_ROUTES.lock().unwrap().extend(routes.clone());
Self(routes)
}
}
#[cfg(test)]
impl Drop for TestRoutes {
fn drop(&mut self) {
TEST_ROUTES
.lock()
.unwrap()
.retain(|(url, _)| !self.0.iter().any(|(source, _)| source == url));
}
}
#[cfg(test)]
pub(crate) fn local_truncated_server() -> (String, std::thread::JoinHandle<()>) {
use std::{
io::{Read, Write},
net::TcpListener,
time::{Duration, Instant},
};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
let worker = std::thread::spawn(move || {
for body in [b"old".as_slice(), b"new-data".as_slice()] {
let deadline = Instant::now() + Duration::from_secs(5);
let mut stream = loop {
if let Ok((stream, _)) = listener.accept() {
break stream;
}
assert!(Instant::now() < deadline, "local fallback server timed out");
std::thread::sleep(Duration::from_millis(5));
};
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut request = [0u8; 8192];
let _ = stream.read(&mut request).unwrap();
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\n")
.unwrap();
stream.write_all(body).unwrap();
}
});
(address, worker)
}
pub fn github_host(host: &str) -> bool {
matches!(
host,
"github.com"
| "api.github.com"
| "raw.githubusercontent.com"
| "objects.githubusercontent.com"
| "release-assets.githubusercontent.com"
)
}
pub fn eligible(initial: &str, headers: &[(&str, &str)]) -> bool {
let Ok(url) = url::Url::parse(initial) else {
return false;
};
url.scheme() == "https" && url.host_str().is_some_and(github_host)
&& url.port_or_known_default() == Some(443)
&& url.username().is_empty() && url.password().is_none()
&& url.fragment().is_none()
// Allow only known public API pagination parameters, never signed URLs.
&& url.query_pairs().all(|(key, value)| matches!(key.as_ref(), "page" | "per_page") && !value.is_empty() && value.bytes().all(|c|c.is_ascii_digit()))
&& headers.iter().all(|(key, _)| matches!(key.to_ascii_lowercase().as_str(), "accept" | "accept-encoding" | "user-agent" | "if-none-match" | "if-modified-since" | "cache-control"))
}
fn certificate_error(error: &(dyn std::error::Error + 'static)) -> bool {
let message = error.to_string().to_ascii_lowercase();
message.contains("certificate")
|| message.contains("certvalid")
|| message.contains("unknownissuer")
|| message.contains("invalid peer")
}
pub fn retryable(error: &anyhow::Error) -> bool {
if error.chain().any(certificate_error) {
return false;
}
if error.to_string() == "网络请求超时" {
return true;
}
error.chain().any(|cause| {
if let Some(error) = cause.downcast_ref::<ureq::Error>() {
return match error {
ureq::Error::Status(code, response) => {
matches!(code, 408 | 429 | 500..=599)
|| *code == 403
&& (response.header("X-RateLimit-Remaining") == Some("0")
|| response.header("Retry-After").is_some())
}
ureq::Error::Transport(error) => matches!(
error.kind(),
ureq::ErrorKind::Dns
| ureq::ErrorKind::ConnectionFailed
| ureq::ErrorKind::Io
| ureq::ErrorKind::ProxyConnect
),
};
}
cause.downcast_ref::<io::Error>().is_some_and(|e| {
matches!(
e.kind(),
io::ErrorKind::TimedOut
| io::ErrorKind::ConnectionReset
| io::ErrorKind::ConnectionAborted
| io::ErrorKind::BrokenPipe
| io::ErrorKind::UnexpectedEof
)
})
})
}
// Error display from HTTP clients can contain signed redirect URLs. Do not
// surface those URLs when reporting both routes' errors.
pub fn diagnostic(error: &anyhow::Error) -> String {
error
.chain()
.map(|cause| {
if let Some(ureq::Error::Status(code, _)) = cause.downcast_ref::<ureq::Error>() {
return format!("HTTP {code}");
}
if let Some(ureq::Error::Transport(t)) = cause.downcast_ref::<ureq::Error>() {
return format!("network {:?}", t.kind());
}
cause
.to_string()
.split_whitespace()
.map(|word| {
if word.contains("://") {
"[URL hidden]"
} else {
word
}
})
.collect::<Vec<_>>()
.join(" ")
})
.collect::<Vec<_>>()
.join(": ")
}
pub fn run<T>(
enabled: bool,
initial: &str,
headers: &[(&str, &str)],
mut attempt: impl FnMut(bool) -> Result<T>,
) -> Result<T> {
match attempt(false) {
Err(first) if enabled && eligible(initial, headers) && retryable(&first) => {
eprintln!("GitHub connection failed; trying gh-proxy.com once");
attempt(true).map_err(|second| {
anyhow::anyhow!(
"GitHub primary route failed: {}; gh-proxy.com fallback failed: {}",
diagnostic(&first),
diagnostic(&second)
)
})
}
result => result,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn github_fallback_only_public_exact_hosts_without_credentials() {
assert!(eligible(
"https://api.github.com/repos/a/b/releases?page=1&per_page=100",
&[]
));
for u in [
"https://github.com.evil/a",
"https://foo.github.com/a",
"https://user@github.com/a",
"https://github.com/a?token=secret",
"https://release-assets.githubusercontent.com/a?X-Amz-Signature=secret",
"https://gh-proxy.com/https://github.com/a",
"http://github.com/a",
"https://github.com:8443/a",
] {
assert!(!eligible(u, &[]), "{u}");
}
for h in ["Authorization", "cookie", "X-API-KEY", "X-GitHub-Token"] {
assert!(!eligible("https://github.com/a", &[(h, "secret")]));
}
}
#[test]
fn github_fallback_runs_once_and_does_not_retry_validation_or_certificates() {
let mut calls = vec![];
let result = run(true, "https://github.com/a", &[], |fallback| {
calls.push(fallback);
if fallback {
Ok(7)
} else {
Err(io::Error::from(io::ErrorKind::ConnectionReset).into())
}
})
.unwrap();
assert_eq!(result, 7);
assert_eq!(calls, vec![false, true]);
for enabled in [false, true] {
for message in [
"Download SHA256 mismatch",
"Operation cancelled",
"invalid peer certificate",
] {
let mut calls = 0;
assert!(run::<()>(enabled, "https://github.com/a", &[], |_| {
calls += 1;
anyhow::bail!("{message}")
})
.is_err());
assert_eq!(calls, 1);
}
}
for (code, remaining, retry) in [
(404, false, false),
(403, false, false),
(403, true, true),
(429, false, true),
(503, false, true),
] {
let response: ureq::Response = format!(
"HTTP/1.1 {code} Test\r\n{}\r\n",
if remaining {
"X-RateLimit-Remaining: 0\r\n"
} else {
""
}
)
.parse()
.unwrap();
assert_eq!(
retryable(&ureq::Error::Status(code, response).into()),
retry
);
}
}
}
+118 -11
View File
@@ -9,7 +9,7 @@ use std::{
collections::BTreeMap,
fs,
io::{Read, Write},
os::unix::fs::{symlink, FileExt, MetadataExt, PermissionsExt},
os::{fd::AsRawFd, unix::fs::{symlink, FileExt, MetadataExt, OpenOptionsExt, PermissionsExt}},
path::{Component, Path, PathBuf},
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
@@ -68,7 +68,6 @@ fn save_json(path: &Path, v: &Value) -> Result<()> {
fs::rename(&temp, path)?;
Ok(())
}
use std::os::unix::fs::OpenOptionsExt;
#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ViewPreferences {
@@ -190,6 +189,9 @@ impl Drop for Download {
}
fn entry(p: &Path) -> Result<Value> {
let m = fs::symlink_metadata(p)?;
entry_metadata(p, &m)
}
fn entry_metadata(p: &Path, m: &fs::Metadata) -> Result<Value> {
let link = m.file_type().is_symlink();
let dir = if link {
fs::metadata(p).is_ok_and(|m| m.is_dir())
@@ -220,6 +222,43 @@ fn entry(p: &Path) -> Result<Value> {
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 account_name(id: u32, group: bool) -> Option<String> {
let mut buffer = vec![0u8; 65536];
// Reentrant NSS lookups, with numeric IDs retained if no name is available.
unsafe {
let name = if group {
let mut record: libc::group = std::mem::zeroed();
let mut found = std::ptr::null_mut();
if libc::getgrgid_r(id, &mut record, buffer.as_mut_ptr().cast(), buffer.len(), &mut found) != 0 || found.is_null() { return None; }
record.gr_name
} else {
let mut record: libc::passwd = std::mem::zeroed();
let mut found = std::ptr::null_mut();
if libc::getpwuid_r(id, &mut record, buffer.as_mut_ptr().cast(), buffer.len(), &mut found) != 0 || found.is_null() { return None; }
record.pw_name
};
(!name.is_null()).then(|| std::ffi::CStr::from_ptr(name).to_string_lossy().into_owned())
}
}
fn properties(p: &Path) -> Result<Value> {
let m = fs::symlink_metadata(p)?;
let mut result = entry_metadata(p, &m)?;
let time = |value: std::io::Result<SystemTime>| value.ok().and_then(|v|v.duration_since(UNIX_EPOCH).ok()).map(|v|v.as_secs());
result["regular"] = json!(m.is_file());
result["accessed"] = json!(time(m.accessed()));
result["created"] = json!(time(m.created()));
result["changed"] = json!(m.ctime());
result["allocated"] = json!(m.blocks().saturating_mul(512));
result["inode"] = json!(m.ino().to_string());
result["device"] = json!(m.dev().to_string());
result["links"] = json!(m.nlink());
result["owner"] = json!(account_name(m.uid(), false));
result["group"] = json!(account_name(m.gid(), true));
result["canChmod"] = json!(!m.file_type().is_symlink() && m.uid() == unsafe {libc::geteuid()});
// Metadata describes the link itself, never a silently followed target.
result["resolvedPath"] = json!(p.canonicalize().ok());
Ok(result)
}
fn list(p: &Value, home: &Path) -> Result<Value> {
let dir = PathBuf::from(
p["path"]
@@ -304,22 +343,30 @@ fn binary(bytes: &[u8]) -> bool {
.any(|byte| (*byte < 0x20 && !matches!(*byte, b'\t' | b'\r' | b'\n')) || *byte == 0x7f)
}
fn read_text(p: &Path) -> Result<Value> {
let m = fs::metadata(p)?;
read_text_mode(p, false)
}
fn read_text_mode(p: &Path, as_text: bool) -> Result<Value> {
let file = fs::OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK).open(p)?;
let m = file.metadata()?;
ensure!(
m.is_file() && m.len() <= 5 * 1024 * 1024,
"Text editor supports files up to 5 MiB"
);
let mut bytes = Vec::new();
fs::File::open(p)?
file
.take(5 * 1024 * 1024 + 1)
.read_to_end(&mut bytes)?;
ensure!(
bytes.len() <= 5 * 1024 * 1024,
"Text editor supports files up to 5 MiB"
);
ensure!(!binary(&bytes), "Binary files cannot be edited as text");
let text = std::str::from_utf8(&bytes).context("Only UTF-8 text can be edited")?;
Ok(json!({"text":text,"revision":fingerprint(&bytes)}))
let read_only = binary(&bytes) || std::str::from_utf8(&bytes).is_err();
if !as_text {
ensure!(!binary(&bytes), "Binary files cannot be edited as text");
std::str::from_utf8(&bytes).context("Only UTF-8 text can be edited")?;
}
let text = String::from_utf8_lossy(&bytes);
Ok(json!({"text":text,"revision":fingerprint(&bytes),"readOnly":read_only}))
}
fn remove(p: &Path) -> Result<()> {
let m = fs::symlink_metadata(p)?;
@@ -558,7 +605,7 @@ impl Files {
);
let operation = p["operation"].as_str().unwrap_or("list");
match operation {
"list" => list(p, home),"properties" => entry(&path(p,"path")?),
"list" => list(p, home),"properties" => properties(&path(p,"path")?),
"view.preferences" => {
let _guard = self.preferences.lock().unwrap();
view_preferences(home, p)
@@ -581,7 +628,7 @@ impl Files {
json!([])
})
},
"read" => read_text(&path(p,"path")?),
"read" => read_text_mode(&path(p,"path")?, p["asText"] == true),
"write" => {
let file = path(p,"path")?.canonicalize()?;
let current = read_text(&file)?;
@@ -621,10 +668,17 @@ impl Files {
},
"chmod" => {
let file = path(p,"path")?;
ensure!(!fs::symlink_metadata(&file)?.file_type().is_symlink(),"Cannot change symlink permissions");
// O_PATH permits changing an owned file even when it has mode
// 000. Pin the inode so path replacement cannot redirect chmod.
let handle = fs::OpenOptions::new().read(true).custom_flags(libc::O_PATH|libc::O_NOFOLLOW).open(&file)?;
let metadata = handle.metadata()?;
ensure!(!metadata.file_type().is_symlink(),"Cannot change symlink permissions");
if let Some(expected) = p.get("identity") {
ensure!(expected["device"] == metadata.dev().to_string() && expected["inode"] == metadata.ino().to_string(), "File changed outside the properties panel; reopen properties");
}
let mode = p["mode"].as_u64().context("Missing file permissions")?;
ensure!(mode<=0o777,"Only normal user permission bits can be changed");
fs::set_permissions(file, fs::Permissions::from_mode(mode as u32))?;
fs::set_permissions(format!("/proc/self/fd/{}",handle.as_raw_fd()), fs::Permissions::from_mode((metadata.mode()&0o7000)|mode as u32))?;
Ok(json!(true))
},
"trash.list" => Ok(json!(read_trash(home)?.into_iter().map(|t|json!({
@@ -1241,6 +1295,59 @@ fn target_resolution(path: &Path, p: &Value) -> Result<Option<PathBuf>> {
mod tests {
use super::*;
#[test]
fn detailed_properties_and_safe_permission_changes() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("private.txt");
fs::write(&file, "data").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o2600)).unwrap();
let details = properties(&file).unwrap();
assert_eq!(details["regular"], true);
assert_eq!(details["size"], 4);
assert!(details["inode"].is_string() && details["device"].is_string());
assert!(details["allocated"].is_number() && details["links"].is_number());
assert_eq!(details["canChmod"], true);
let manager = Files::default();
let identity = json!({"device":details["device"],"inode":details["inode"]});
manager.api(temp.path(), &json!({"operation":"chmod","path":file,"mode":0o640,"identity":identity})).unwrap();
assert_eq!(fs::metadata(&file).unwrap().mode() & 0o7777, 0o2640);
assert!(manager.api(temp.path(), &json!({"operation":"chmod","path":file,"mode":0o1000})).is_err());
let replacement = temp.path().join("replacement");
fs::write(&replacement, "other").unwrap();
fs::rename(&replacement, &file).unwrap();
assert!(manager.api(temp.path(), &json!({"operation":"chmod","path":file,"mode":0o777,"identity":identity})).is_err());
let link = temp.path().join("link");
symlink(&file,&link).unwrap();
let details = properties(&link).unwrap();
assert_eq!(details["canChmod"], false);
assert_eq!(details["symlink"], true);
assert!(manager.api(temp.path(), &json!({"operation":"chmod","path":link,"mode":0o777})).is_err());
fs::set_permissions(&file, fs::Permissions::from_mode(0)).unwrap();
manager.api(temp.path(), &json!({"operation":"chmod","path":file,"mode":0o600})).unwrap();
assert_eq!(fs::metadata(&file).unwrap().mode() & 0o777, 0o600);
}
#[test]
fn explicit_text_preview_never_allows_binary_overwrite() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("unknown.data");
fs::write(&file, "const answer = 42;\n").unwrap();
assert_eq!(read_text_mode(&file,true).unwrap()["readOnly"], false);
for data in [vec![255,254,0,1],b"binary\x01data".to_vec()] {
fs::write(&file, &data).unwrap();
let preview = read_text_mode(&file,true).unwrap();
assert_eq!(preview["readOnly"], true);
assert_eq!(preview["revision"], fingerprint(&data));
assert!(Files::default().api(temp.path(), &json!({"operation":"write","path":file,"revision":preview["revision"],"text":"new","approve":true})).is_err());
assert_eq!(fs::read(&file).unwrap(),data);
}
let fifo = temp.path().join("pipe");
let path = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
assert_eq!(unsafe {libc::mkfifo(path.as_ptr(),0o600)},0);
assert!(read_text_mode(&fifo,true).is_err());
let oversized = temp.path().join("large");
fs::File::create(&oversized).unwrap().set_len(5*1024*1024+1).unwrap();
assert!(read_text_mode(&oversized,true).is_err());
}
#[test]
fn media_revisions_track_replacements_and_symlink_targets() {
let temp = tempfile::tempdir().unwrap();
let image = temp.path().join("sample.png");
+9 -1
View File
@@ -751,9 +751,13 @@ impl NetworkPanel {
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProxySettings {
#[serde(skip)]
pub(crate) github_fallback_attempt: bool,
#[serde(default = "proxy_fallback_default")]
pub github_auto_fallback: bool,
#[serde(default)]
pub http_enabled: bool,
#[serde(default)]
@@ -763,6 +767,10 @@ pub struct ProxySettings {
#[serde(default)]
pub github: String,
}
pub fn proxy_fallback_default() -> bool { true }
impl Default for ProxySettings {
fn default() -> Self { Self { http_enabled:false, github_enabled:false, http:String::new(), github:String::new(), github_auto_fallback:true, github_fallback_attempt:false } }
}
impl ProxySettings {
pub fn validate(&self) -> Result<()> {
ensure!(
+15 -5
View File
@@ -211,6 +211,12 @@ pub fn stage_request_proxy(
proxy: &ProxySettings,
mut progress: impl FnMut(u64, Option<u64>) -> Result<()>,
) -> Result<Arc<Staged>> {
if let Some(url) = v["url"].as_str() {
return crate::http::with_github_fallback(url, &[], proxy, |route| stage_request_once(v, route, &mut progress));
}
stage_request_once(v, proxy, &mut progress)
}
fn stage_request_once(v: &serde_json::Value, proxy: &ProxySettings, progress: &mut dyn FnMut(u64, Option<u64>) -> Result<()>) -> Result<Arc<Staged>> {
if let Some(encoded) = v.get("package").and_then(|v| v.as_str()) {
let staged = Staged::create()?;
let mut file = fs::OpenOptions::new().write(true).open(&staged.path)?;
@@ -249,7 +255,7 @@ pub fn stage_request_proxy(
received += n as u64;
progress(received, total)?;
}
ensure!(total.is_none_or(|n| n == received), "Truncated download");
if total.is_some_and(|n| n != received) { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "Truncated download").into()); }
file.sync_all()?;
let staged = staged.seal()?;
if let Some(expected) = v["sha256"].as_str() {
@@ -413,6 +419,13 @@ pub fn from_request_proxy(
proxy: &ProxySettings,
mut progress: impl FnMut(u64, Option<u64>) -> Result<()>,
) -> Result<Vec<u8>> {
if let Some(url) = v["url"].as_str() {
return crate::http::with_github_fallback(url, &[], proxy, |route| from_request_once(v, route, &mut progress));
}
from_request_once(v, proxy, &mut progress)
}
#[cfg(test)]
fn from_request_once(v: &serde_json::Value, proxy: &ProxySettings, progress: &mut dyn FnMut(u64, Option<u64>) -> Result<()>) -> Result<Vec<u8>> {
if let Some(s) = v.get("package").and_then(|v| v.as_str()) {
let bytes = STANDARD.decode(s)?;
progress(bytes.len() as u64, Some(bytes.len() as u64))?;
@@ -444,10 +457,7 @@ pub fn from_request_proxy(
bytes.extend_from_slice(&buffer[..n]);
progress(bytes.len() as u64, total)?;
}
ensure!(
total.is_none_or(|t| t == bytes.len() as u64),
"Truncated download"
);
if total.is_some_and(|n| n != bytes.len() as u64) { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "Truncated download").into()); }
if let Some(expected) = v["sha256"].as_str() {
ensure!(digest(&bytes) == expected, "Download SHA256 mismatch");
}
+12 -2
View File
@@ -1140,6 +1140,16 @@ impl Service {
if let Err(error)=self.save() {self.db.casting.recording_region_visible=previous;return Err(error);}
Ok(json!(true))
}
"cast.recordingIndicator.visible.save" => {
let enabled = p["enabled"].as_bool().context("Missing indicator visibility")?;
let previous = self.db.casting.recording_indicator_visible;
self.db.casting.recording_indicator_visible = enabled;
if let Err(error) = self.save() {
self.db.casting.recording_indicator_visible = previous;
return Err(error);
}
Ok(json!({"enabled":enabled}))
}
"cast.panel.start" => {
let executable = self.root.join("current/bin/framely-panel-grab");
ensure!(executable.is_file(), "缺少内置屏幕采集助手");
@@ -1632,8 +1642,8 @@ impl Service {
config.http = config.http.trim().to_owned();
config.github = config.github.trim().trim_end_matches('/').to_owned();
config.validate()?;
self.db.proxy = config;
self.save()?;
let previous = std::mem::replace(&mut self.db.proxy, config);
if let Err(error) = self.save() { self.db.proxy = previous; return Err(error); }
Ok(json!(true))
}
"network.save" | "network.password.setup" => {
+10 -2
View File
@@ -447,7 +447,7 @@ impl Agent {
.unwrap_or(json!({}));
return send_json(
r,
{let (recording,region,framing)=if let Ok(cast)=self.casting.try_lock() {let status=cast.recording_status();*self.recording_overlay_cache.lock().unwrap()=status.clone();(status,cast.region_status(),cast.region.editor_state())}else{(self.recording_overlay_cache.lock().unwrap().clone(),json!({"visible":false}),Value::Null)};json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"],"recording":recording,"recordingRegion":region,"framingEditor":framing})},
{let (mut recording,region,framing)=if let Ok(cast)=self.casting.try_lock() {let status=cast.recording_status();*self.recording_overlay_cache.lock().unwrap()=status.clone();(status,cast.region_status(),cast.region.editor_state())}else{(self.recording_overlay_cache.lock().unwrap().clone(),json!({"visible":false}),Value::Null)};recording["indicatorVisible"]=json!(database["casting"]["recordingIndicatorVisible"].as_bool().unwrap_or(true));json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"],"recording":recording,"recordingRegion":region,"framingEditor":framing})},
);
}
if let Some(rel) = path.strip_prefix("/plugin-assets/") {
@@ -1060,6 +1060,7 @@ impl Agent {
self.receivers.lock().unwrap().configure(&settings)?;
Ok(result)
},
"cast.recordingIndicator.visible.save" => self.core(method, p),
"cast.region.visible.save" => {
let enabled=p["enabled"].as_bool().context("Missing region visibility")?;
let mut settings:crate::cast::settings::Settings=serde_json::from_value(self.core("status",json!({}))?["database"]["casting"].clone())?;
@@ -1280,9 +1281,10 @@ impl Agent {
let _gate = APK_STORAGE_API_GATE.lock().unwrap();
ensure!(!self.jobs.active_kind("apk:storage") && !self.jobs.active_kind("apk:operation"), "请等待 APK 操作或存储迁移完成");
let home = crate::steam::home()?;
let proxy:crate::model::ProxySettings = serde_json::from_value(self.core("status", json!({}))?["database"]["proxy"].clone())?;
let ticket = p["ticket"].as_str().context("Missing APK review")?.to_owned();
self.jobs.task_progress("apk:operation", move |cancel, progress| {
crate::apk::q2f_convert(&home, &ticket, &cancel, |value| { progress(value); Ok(()) })
crate::apk::q2f_convert(&home, &ticket, &cancel, |value| { progress(value); Ok(()) }, &proxy)
})
}
"apk.cleanup.list" => crate::apk::cleanup_list(&crate::steam::home()?),
@@ -1752,6 +1754,9 @@ pub(crate) fn fetch_catalog_proxy_cancel(
) -> Result<Catalog> {
cancel.check()?;
source.validate()?;
if proxy.github_auto_fallback {
return crate::http::with_github_fallback(&source.url, &[], proxy, |route| fetch_catalog_proxy_cancel(source, route, cancel));
}
let response = crate::http::get_with_proxy_cancel(
&source.url,
source.allow_http,
@@ -1790,6 +1795,9 @@ pub(crate) fn fetch_plugin_versions_cancel(
source.validate()?;
valid_id(plugin_id)?;
let url = url::Url::parse(&source.url)?.join(&format!("plugins/{plugin_id}/versions.json"))?;
if proxy.github_auto_fallback {
return crate::http::with_github_fallback(url.as_str(), &[], proxy, |route| fetch_plugin_versions_cancel(source, plugin_id, route, cancel));
}
let response = crate::http::get_with_proxy_cancel(
url.as_str(),
source.allow_http,
+5 -1
View File
@@ -369,12 +369,16 @@ pub fn fetch_cancel(
ensure!(v.len() <= 2048 && !v.contains(['\r', '\n']), "无效更新时间");
headers.push(("If-Modified-Since", v));
}
let response = crate::http::get_with_proxy(
if proxy.github_auto_fallback {
return crate::http::with_github_fallback(url, &headers, proxy, |route| fetch_cancel(url, etag, modified, route, cancel));
}
let response = crate::http::get_with_proxy_cancel(
url,
false,
std::time::Duration::from_secs(20),
&headers,
proxy,
cancel,
)?;
if response.status() == 304 {
return Ok((None, None, None));
+30
View File
@@ -1344,6 +1344,25 @@ fn network_panel_defaults_persist_and_validate() {
assert_eq!(restored.db.network_panel.port, 16000);
}
#[test]
fn recording_indicator_visibility_persists_and_rolls_back_on_save_failure() {
let root = tempfile::tempdir().unwrap();
let mut core = accepted_service(root.path(), 1000).unwrap();
assert!(core.db.casting.recording_indicator_visible);
core.handle("cast.recordingIndicator.visible.save", json!({"enabled":false})).unwrap();
assert!(!accepted_service(root.path(), 1000).unwrap().db.casting.recording_indicator_visible);
assert!(core.handle("cast.recordingIndicator.visible.save", json!({"enabled":"yes"})).is_err());
// Making the state directory unavailable must not change the in-memory flag.
let moved = root.path().with_extension("indicator-save-failure");
fs::rename(root.path(), &moved).unwrap();
fs::write(root.path(), "not a directory").unwrap();
let result = core.handle("cast.recordingIndicator.visible.save", json!({"enabled":true}));
fs::remove_file(root.path()).unwrap();
fs::rename(moved, root.path()).unwrap();
assert!(result.is_err());
assert!(!core.db.casting.recording_indicator_visible);
}
#[test]
fn proxy_settings_persist_and_reject_invalid_values() {
let root = tempfile::tempdir().unwrap();
@@ -1376,6 +1395,17 @@ fn proxy_settings_persist_and_reject_invalid_values() {
assert_eq!(core.db.proxy, crate::model::ProxySettings::default());
}
#[test]
fn github_fallback_defaults_on_and_opt_out_persists() {
let legacy: ProxySettings = serde_json::from_value(json!({"http":"","github":""})).unwrap();
assert!(legacy.github_auto_fallback);
let root = tempfile::tempdir().unwrap();
let mut core = accepted_service(root.path(), 1000).unwrap();
core.handle("proxy.save", json!({"http":"","github":"","githubAutoFallback":false})).unwrap();
assert!(!accepted_service(root.path(), 1000).unwrap().db.proxy.github_auto_fallback);
assert!(core.handle("proxy.save", json!({"githubAutoFallback":"false"})).is_err());
}
#[test]
fn first_launch_agreement_gates_operations_and_persists_consent() {
let root = tempfile::tempdir().unwrap();
+9 -5
View File
@@ -291,6 +291,8 @@ impl Updater {
cancel.check()?;
let result = resolve_release(&source, channel, &cancel, &mut |url, limit| {
crate::model::validate_url(url, false)?;
let headers=[("User-Agent", "Framely"),("Accept", "application/vnd.github+json")];
crate::http::with_github_fallback(url,&headers,&proxy,|proxy| {
let response = crate::http::get_with_proxy_cancel(
url,
false,
@@ -303,6 +305,7 @@ impl Updater {
&cancel,
)?;
crate::http::read_cancel(response, limit, &cancel)
})
})?;
if let Some((release, bytes, version)) = result {
let info = json!({"release":release,"version":version,"channel":channel});
@@ -348,7 +351,8 @@ impl Updater {
progress(value);
Ok(())
};
let result = (|| -> Result<Value> {
let result = crate::http::with_github_fallback(&release.url, &[], &proxy, |proxy| (|| -> Result<Value> {
cancel.check()?;
report(json!({"phase":"downloading","received":0,"total":release.size,"version":release.version}))?;
let response = crate::http::get_download_with_proxy(&release.url, false, Duration::from_secs(120), &[], &proxy)?;
if let Some(length) = response.header("Content-Length") {
@@ -374,16 +378,16 @@ impl Updater {
}
output.sync_all()?;
drop(output);
ensure!(received == release.size, "下载未完成");
if received != release.size { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "下载未完成").into()); }
verify_archive(&archive, &release, &cancel, report)?;
fs::write(stage.join("descriptor.json"), descriptor)?;
fs::write(stage.join("descriptor.json"), &descriptor)?;
cancel.commit(|| {
report(json!({"phase":"ready","version":release.version,"received":received,"verified":received,"total":release.size}))?;
*updater.prepared.lock().unwrap() = Some((stage.clone(), release.clone(), source_url));
*updater.prepared.lock().unwrap() = Some((stage.clone(), release.clone(), source_url.clone()));
Ok(())
})?;
Ok(json!(release))
})();
})());
if let Err(e) = &result {
let _ = fs::remove_dir_all(stage);
let _ = write_status(&state_path, json!({"phase":"failed","error":e.to_string()}));
+15
View File
@@ -0,0 +1,15 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
globalThis.document={documentElement:{lang:''}};
const bundle=await build({entryPoints:['ui/src/apk-errors.ts'],bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};
new Function('module','exports',bundle.outputFiles[0].text)(module,module.exports);
const {apkError}=module.exports;
assert.equal(apkError(new Error('New installation failed; newly created container was cleaned up: /test/baked: missing')),'安装失败,本次新建的容器已清理:/test/baked: missing');
assert.equal(apkError(new Error('Installation failed; container retained because safe cleanup failed: install; cleanup: ownership')),'安装失败,安全清理未完成,容器已保留:install; cleanup: ownership');
assert.equal(apkError(new Error('Installation failed; existing container and data retained (backup: backup-1): missing\npath')),'更新失败,原容器和数据已保留。备份:backup-1。详情:missing\npath');
assert.match(apkError('APK state is unknown; reconcile before changing the application'),/APK 状态未知/);
assert.equal(apkError(new Error('unmapped diagnostic /path')),'unmapped diagnostic /path');
console.log('APK failure outcomes are localized and diagnostic details are preserved.');
+45
View File
@@ -0,0 +1,45 @@
import React,{useRef,useState} from 'react';
import {createRoot} from 'react-dom/client';
import {FramingEditor} from '../ui/src/cast-framing-editor';
import type {CastSettings} from '../ui/src/casting';
import {configureLanguage} from '../ui/src/i18n';
import '../ui/src/style.css';
import '../ui/src/casting.css';
import '../ui/src/cast-spatial-framing.css';
configureLanguage('zh-CN',[]);
let commits:Partial<CastSettings>[]=[];
const initial:CastSettings={source:'screen',codec:'h265',output:'eye',eye:'right',width:1600,height:1200,fps:60,bitrateMbps:8,horizontalFov:60,centerX:0,centerY:0,systemAudio:true,microphone:false,airplay:false,dlna:false,receiverName:'Framely'};
function Fixture(){
const handle=useRef<HTMLButtonElement>(null),[settings,setSettings]=useState(initial);
return <div className="app"><FramingEditor spatial moveHandle={handle} settings={settings} view={{tanHalfHorizontal:1.4,centerTanX:0,centerTanY:0,aspect:1}} onChange={patch=>{commits.push(patch);setSettings(s=>({...s,...patch}));}} onError={error=>{throw Error(error);}}/><button ref={handle}>拖动调整录制位置</button></div>;
}
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));
const check=(value:unknown,message:string)=>{if(!value)throw Error(message);};
const close=(a:number,b:number)=>Math.abs(a-b)<.03;
const pointer=(target:EventTarget,type:string,x:number,y:number,buttons=1,id=8)=>target.dispatchEvent(new PointerEvent(type,{bubbles:true,cancelable:true,button:0,buttons,pointerId:id,clientX:x,clientY:y}));
(window as any).runInstallReviewChecks=async()=>{try{
for(let i=0;i<100&&!document.querySelector('.cast-crop-box');i++)await wait(20);
await wait(100);
const box=document.querySelector<HTMLElement>('.cast-crop-box')!,handle=document.querySelector<HTMLButtonElement>('.app>button')!;
const before=box.getBoundingClientRect();
pointer(handle,'pointerdown',200,300);pointer(handle,'pointermove',230,280);
const moved=box.getBoundingClientRect();check(close(moved.x-before.x,30)&&close(moved.y-before.y,-20),`Handle did not translate crop: ${moved.x-before.x}, ${moved.y-before.y}`);
check(close(moved.width,before.width)&&close(moved.height,before.height),'Pan resized crop');
pointer(document,'pointerup',230,280,0);await wait();
check(commits.length===1&&commits[0].centerX!>0&&commits[0].centerY!<0,'Pan not saved');
check(close(commits[0].horizontalFov!,60),'Pan changed FOV');
const count=commits.length;pointer(handle,'pointermove',400,400,0);pointer(handle,'pointermove',450,450,1);await wait();
check(commits.length===count&&close(box.getBoundingClientRect().x,moved.x),'Hover continued pan');
pointer(box,'pointerdown',200,300);pointer(box,'pointermove',190,310);pointer(document,'pointerup',190,310,0);await wait();
check(commits.length===count+1,'Frame interior no longer draggable');
pointer(handle,'pointerdown',200,300);pointer(handle,'pointermove',210,310);window.dispatchEvent(new Event('framely.pointerRelease'));await wait();
check(!handle.hasAttribute('data-dragging'),'Native release left drag armed');
const released=box.getBoundingClientRect();pointer(handle,'pointermove',500,500,1);await wait();
check(close(box.getBoundingClientRect().x,released.x),'Native release did not stop pan');
pointer(handle,'pointerdown',200,300);pointer(handle,'pointermove',220,320);pointer(document,'pointercancel',220,320,0);await wait();
check(!handle.hasAttribute('data-dragging'),'Cancel left drag armed');
console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}
};
createRoot(document.getElementById('root')!).render(<Fixture/>);
console.log('FRAMELY_VIEW_READY');
+7
View File
@@ -22,6 +22,13 @@ for(const ratio of [4/3,3/4,16/9,9/16,1]){
assert.equal(gesture.move(2,300,300,0,{width:1000,height:1000},ratio,minimum),null);
}
const reference={tanHalfHorizontal:1.4,centerTanX:0,centerTanY:0,aspect:1};
const pan=new FrameGesture();pan.begin(3,400,400,rect,null);
const moved=pan.move(3,440,370,1,{width:1000,height:1000},ratio,minimum);
close(moved.x,rect.x+.04);close(moved.y,rect.y-.03);close(moved.width,rect.width);close(moved.height,rect.height);
assert.equal(pan.move(4,600,600,1,{width:1000,height:1000},ratio,minimum),null);
const edge=pan.move(3,5000,-5000,1,{width:1000,height:1000},ratio,minimum);
close(edge.x,1-rect.width);close(edge.y,0);close(edge.width,rect.width);
assert.equal(pan.finish(3),true);assert.equal(pan.move(3,400,400,1,{width:1000,height:1000},ratio,minimum),null);
const saved=normalizeRegion({width:Math.round(1600*ratio),height:1600,horizontalFov:10,centerX:0,centerY:0},reference,{width:.24,height:.28});
const displayed=frameFromSettings(saved,saved.width/saved.height,reference);assert.ok(displayed.width>=.24-1e-10&&displayed.height>=.28-1e-10);
}
+60
View File
@@ -0,0 +1,60 @@
// Render the real quick panel with simulated API responses. No device changes.
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));
const until=async(f:()=>unknown)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out: '+document.body.innerText);};
history.replaceState(null,'','/?fixture=1#casting');
const casting={source:'screen',codec:'h265',output:'eye',eye:'right',width:2560,height:1920,fps:90,bitrateMbps:12,horizontalFov:null,centerX:.5,centerY:.5,systemAudio:true,microphone:false,airplay:false,dlna:false,receiverName:'Framely',recordingIndicatorVisible:true,recordingRegionVisible:false};
let streaming=false,recording=false,failSave=false,framing=0,opens=0;
const calls:string[]=[];
window.fetch=async(_url,init)=>{
const {method,params}=JSON.parse(String(init?.body));calls.push(method);let result:any=true;
if(method==='status')result={version:'test',agreement:{version:1,accepted:true},database:{casting,language:new URLSearchParams(location.search).get('lang')??'zh-CN',plugins:{},sources:[],safeMode:false},running:[],notifications:[],inbox:[]};
else if(method==='language.list')result={packs:[],invalidFiles:[]};
else if(method==='ui.events')result={cursor:0,events:[]};
else if(method==='cast.status')result={running:streaming,recording:{active:recording,elapsedSeconds:recording?83:0},watchAddresses:['http://192.168.5.67:15915/cast/watch']};
else if(method==='cast.start')streaming=true;
else if(method==='cast.stop')streaming=false;
else if(method==='cast.record.start')recording=true;
else if(method==='cast.record.stop')recording=false;
else if(method==='cast.recordingIndicator.visible.save'){if(failSave)return new Response(JSON.stringify({error:'Fixture save failed'}));casting.recordingIndicatorVisible=params.enabled;}
else if(method==='cast.region.visible.save')casting.recordingRegionVisible=params.enabled;
else if(method==='cast.settings.save'){if(params.width!==2560||params.codec!=='h265')throw Error('Receiver reset capture config');Object.assign(casting,params);}
else if(method==='cast.framing.open')framing++;
else if(method==='host.manager.open')opens++;
return new Response(JSON.stringify({result}));
};
const button=(selector:string)=>document.querySelector<HTMLButtonElement>(selector)!;
const click=async(selector:string)=>{await until(()=>!button(selector).disabled);button(selector).click();await wait(150);};
const checkLayout=()=>{
if(document.documentElement.scrollWidth>innerWidth)throw Error('Horizontal overflow');
for(const e of document.querySelectorAll<HTMLElement>('.cast-quick button')){
const r=e.getBoundingClientRect();if(r.width<44||r.height<44||r.left<0||r.right>innerWidth)throw Error('Invalid hit target: '+e.textContent);
}
for(const e of document.querySelectorAll<HTMLElement>('.cast-quick .ui-switch'))if(e.getBoundingClientRect().width!==60)throw Error('Switch width regressed');
if(innerWidth>=500){const a=button('.cast-stream-actions>button'),b=button('.cast-record-button');if(a.getBoundingClientRect().top!==b.getBoundingClientRect().top)throw Error('Primary actions not aligned');}
};
(window as any).runInstallReviewChecks=async()=>{try{
await until(()=>!!document.querySelector('nav button'));[...document.querySelectorAll<HTMLButtonElement>('nav button')].find(b=>b.textContent==='串流')!.click();
await until(()=>document.querySelector('.cast-quick')&&!button('.cast-stream-actions>button').disabled);await wait(180);checkLayout();
console.log('FRAMELY_PREVIEW_INSTALL_CAST_IDLE');
await click('.cast-record-button');await until(()=>recording&&!button('.cast-record-button').disabled);
if(streaming||calls.includes('cast.start'))throw Error('Recording started streaming');
if(!button('.cast-quick-framing-row>button').disabled)throw Error('Framing allowed during recording');
failSave=true;await click('[aria-label="显示录制指示器"]');await until(()=>document.body.textContent?.includes('Fixture save failed'));
if(button('[aria-label="显示录制指示器"]').getAttribute('aria-checked')!=='true')throw Error('Failed save changed switch');
failSave=false;await click('[aria-label="显示录制指示器"]');await until(()=>!casting.recordingIndicatorVisible&&!button('.cast-record-button').disabled);
if(!recording)throw Error('Hiding indicator stopped recording');
await click('.cast-record-button');await until(()=>!recording&&!button('.cast-stream-actions>button').disabled);
await click('.cast-quick-framing-row>button');await until(()=>framing===1&&!button('.cast-stream-actions>button').disabled);
await click('[aria-label="录制区域显示"]');await until(()=>casting.recordingRegionVisible&&!button('.cast-stream-actions>button').disabled);
await click('[aria-label="AirPlay"]');await until(()=>casting.airplay&&!button('.cast-stream-actions>button').disabled);
await click('[aria-label="DLNA"]');await until(()=>casting.dlna&&!button('.cast-stream-actions>button').disabled);
await click('.cast-stream-actions>button');await until(()=>streaming&&!button('.cast-record-button').disabled);
await click('.cast-record-button');await until(()=>recording&&!button('.cast-record-button').disabled);await wait(200);checkLayout();
console.log('FRAMELY_PREVIEW_INSTALL_CAST_ACTIVE');await wait(100);
await click('.cast-stream-actions>button');await until(()=>!streaming&&!button('.cast-record-button').disabled);
await click('.cast-record-button');await until(()=>!recording&&!button('.cast-quick>header button').disabled);
await click('.cast-quick>header button');await until(()=>opens===1);
console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}
};
void import('../ui/src/main');
+78
View File
@@ -0,0 +1,78 @@
// The actual FileManager/CodeMirror workspace with generated demonstration code.
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';
configureLanguage('zh-CN',[]);
const home='/home/steamos',code=`// 串流配置示例,仅用于界面验证\r\ninterface CaptureSettings {\r\n width: number;\r\n height: number;\r\n fps: number;\r\n codec: 'h264' | 'h265';\r\n}\r\n\r\nconst settings: CaptureSettings = {\r\n width: 2560,\r\n height: 1920,\r\n fps: 90,\r\n codec: 'h265',\r\n};\r\n\r\nfunction resolution(config: CaptureSettings): string {\r\n const { width, height } = config;\r\n return width + ' × ' + height;\r\n}\r\n\r\nexport { settings, resolution };\r\n`;
let text=code;
window.fetch=async(url,init)=>{
if(String(url)==='/api')return new Response(JSON.stringify({result:true}));
const p=JSON.parse(String(init?.body));let result:any;
if(p.operation==='preferences')result=[];
else if(p.operation==='view.preferences')result={view:'list',folders:{}};
else if(p.operation==='list')result={path:p.path??home,parent:'/',entries:p.hidden?[]:[{name:'capture.ts',path:home+'/capture.ts',directory:false,symlink:false,size:code.length,mode:420,uid:1000,gid:1000}],total:p.hidden?0:1,roots:[{name:'Home',path:home}]};
else if(p.operation==='read')result={text,revision:'example',readOnly:false};
else if(p.operation==='write'){text=p.text;result={text,revision:'saved',readOnly:false};}
else throw Error('Unexpected operation '+p.operation);
return new Response(JSON.stringify({result}));
};
createRoot(document.getElementById('root')!).render(<div className="app manager"><header className="shell-header"><Brand wordmark/><span>文件</span></header><div className="workspace tools-workspace"><main className="content file-content"><div className="file-workspace-frame"><FileManager/></div></main></div></div>);
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);};
(window as any).runInstallReviewChecks=async()=>{try{
await until(()=>document.querySelector('.file-list-scroll')?.getAttribute('aria-busy')==='false');document.querySelector<HTMLButtonElement>('.file-name')!.click();await until(()=>document.querySelector('.cm-content[contenteditable=true]'));await wait(250);
const workspace=document.querySelector<HTMLElement>('.file-editor-workspace')!,area=workspace.getBoundingClientRect(),root=document.querySelector('.file-manager')!.getBoundingClientRect(),surface=document.querySelector('.file-code-surface')!.getBoundingClientRect();
if(area.width<root.width-2||area.height<root.height-2||surface.height<300)throw Error('Editor does not fill workspace');
const line=document.querySelector('.cm-line')!.getBoundingClientRect(),number=[...document.querySelectorAll<HTMLElement>('.cm-lineNumbers .cm-gutterElement')].find(e=>e.getBoundingClientRect().height>0&&getComputedStyle(e).visibility!=='hidden')!.getBoundingClientRect();
if(Math.abs(line.top-number.top)>2)throw Error(`Gutter is not aligned: ${line.top} / ${number.top}`);
if(document.querySelectorAll('.cm-line span').length<8||!document.body.textContent?.includes('CRLF'))throw Error('Syntax or line ending status missing');
if(!document.querySelector('.file-layout')?.hasAttribute('inert'))throw Error('Editor left background controls interactive');
if(document.querySelector('.cm-tooltip-autocomplete'))throw Error('Unexpected completion popup');
document.querySelector<HTMLButtonElement>('[aria-label="语言模式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));const menu=document.querySelector('[role=listbox]')!.getBoundingClientRect();if(menu.bottom>innerHeight||menu.height<100)throw Error('Language dropdown clipped');
[...document.querySelectorAll<HTMLButtonElement>('[role=option]')].find(e=>e.textContent==='JSON')!.click();await wait();document.querySelector<HTMLButtonElement>('[aria-label="语言模式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));[...document.querySelectorAll<HTMLButtonElement>('[role=option]')].find(e=>e.textContent==='TypeScript')!.click();await wait(200);
document.querySelector<HTMLButtonElement>('[aria-label="语言模式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));document.querySelector('[role=option]')!.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await until(()=>!document.querySelector('[role=listbox]'));if(!document.querySelector('.file-editor-workspace'))throw Error('Language menu Escape closed editor');
console.log('FRAMELY_PREVIEW_INSTALL_CODE_EDITOR_ZH');await wait(200);
[...document.querySelectorAll<HTMLButtonElement>('.file-code-toolbar button')].find(e=>e.textContent?.includes('查找替换'))!.click();await until(()=>document.querySelector('.file-code-search'));
await wait();const searchSurface=document.querySelector('.file-code-surface')!.getBoundingClientRect(),scroller=document.querySelector('.cm-scroller')!.getBoundingClientRect();if(Math.abs(searchSurface.top-scroller.top)>2||Math.abs(searchSurface.height-scroller.height)>2)throw Error('Find widget consumes document space');
const search=document.querySelector<HTMLElement>('.file-code-search')!,find=search.querySelector<HTMLInputElement>('input[name=Find]')!,replace=search.querySelector<HTMLInputElement>('input[name=Replace]')!;
const set=(input:HTMLInputElement,value:string)=>{input.value=value;input.dispatchEvent(new Event('input',{bubbles:true}));};
set(find,'width');await wait();if(!search.querySelector('output')?.textContent?.includes('/ 4'))throw Error('Wrong search match count');
find.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));await wait();if(!search.querySelector('output')?.textContent?.startsWith('1 /'))throw Error('Find did not select first result');
find.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',shiftKey:true,bubbles:true}));await wait();if(!search.querySelector('output')?.textContent?.startsWith('4 /'))throw Error('Previous did not wrap');
search.querySelector<HTMLButtonElement>('[name="Toggle replace"]')!.click();if(replace.closest<HTMLElement>('.file-search-replacement')!.hidden)throw Error('Replace row did not expand');
set(replace,'pixels');search.querySelector<HTMLButtonElement>('[name="replace all"]')!.click();await wait();if(!document.querySelector('.cm-content')?.textContent?.includes('pixels')||search.querySelector('output')?.textContent!=='– / 0')throw Error('Replace all failed');
document.querySelector<HTMLButtonElement>('[aria-label="撤销"]')!.click();await wait();if(!search.querySelector('output')?.textContent?.includes('/ 4'))throw Error('Undo did not restore matches');
search.querySelector<HTMLButtonElement>('[name=regexp]')!.click();set(find,'[');await wait();if(find.getAttribute('aria-invalid')!=='true'||!search.querySelector<HTMLButtonElement>('[name=next]')!.disabled||!search.textContent?.includes('正则表达式无效'))throw Error('Invalid regex not reported');
set(find,'width');search.querySelector<HTMLButtonElement>('[name=regexp]')!.click();
search.querySelector<HTMLButtonElement>('[name="match case"]')!.click();set(find,'WIDTH');await wait();if(!search.textContent?.includes('没有匹配结果'))throw Error('Case toggle ignored');
search.querySelector<HTMLButtonElement>('[name="match case"]')!.click();search.querySelector<HTMLButtonElement>('[name="by word"]')!.click();set(find,'wid');await wait();if(!search.textContent?.includes('没有匹配结果'))throw Error('Whole word toggle ignored');
set(find,'');find.focus();(window as any).__framelyCommitKeyboard('width');await wait();if(!search.querySelector('output')?.textContent?.includes('/ 4'))throw Error('Native keyboard did not update search');
await wait(1200);console.log('FRAMELY_PREVIEW_INSTALL_CODE_EDITOR_SEARCH_ZH');await wait(200);
if(search.getBoundingClientRect().right>innerWidth+1||find.getBoundingClientRect().width<100)throw Error('Find widget is clipped');
const status=document.querySelector('.file-editor-status')!;if(status.scrollWidth>status.clientWidth||status.getBoundingClientRect().right>area.right+1)throw Error('Status bar is clipped');
find.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await until(()=>!document.querySelector('.file-code-search'));if(!document.querySelector('.file-editor-workspace'))throw Error('Search Escape closed file');
const content=document.querySelector<HTMLElement>('.cm-content')!;content.focus();content.dispatchEvent(new KeyboardEvent('keydown',{key:'Home',code:'Home',ctrlKey:true,bubbles:true}));await wait();(window as any).__framelyEditKeyboard('// ');await wait();
if(!document.querySelector('.file-editor-status output')?.textContent?.includes('列 4'))throw Error('Cursor status not updated');
const [save,close]=[...document.querySelectorAll<HTMLButtonElement>('.file-editor-heading button')];save.click();await until(()=>text.startsWith('// // ')&&save.disabled&&!close.disabled);
if(text.replaceAll('\r\n','').includes('\n'))throw Error('Saving changed CRLF line endings');
const host=document.createElement('div');host.style.cssText='position:fixed;left:-5000px;width:500px;height:300px';document.body.append(host);let modified=false;
let saves=0;
const writable=window.FramelyCodeEditor!.mount({parent:host,path:'save.txt',value:'hello hello',readOnly:false,label:'Save shortcut test',phrases:{},onChange:()=>{modified=true;},onSave:()=>{saves++;}});writable.find();
host.querySelector<HTMLButtonElement>('[name="Toggle replace"]')!.click();
const mac=/Mac|iPhone|iPad/.test(navigator.platform);
for(const target of [host.querySelector<HTMLElement>('.cm-content')!,...host.querySelectorAll<HTMLInputElement>('.file-code-search input')]){
target.focus();const before=saves,event=new KeyboardEvent('keydown',{key:'s',code:'KeyS',ctrlKey:!mac,metaKey:mac,bubbles:true,cancelable:true});target.dispatchEvent(event);
if(saves!==before+1||!event.defaultPrevented)throw Error('Save shortcut failed in '+(target.getAttribute('name')??'document'));
}
if(modified)throw Error('Save shortcut changed document text');writable.destroy();
const readonly=window.FramelyCodeEditor!.mount({parent:host,path:'readonly.txt',value:'hello hello',readOnly:true,label:'Readonly test',phrases:{},onChange:()=>{modified=true;},onSave:()=>{}});readonly.find();
const panel=host.querySelector('.file-code-search')!,readonlyFind=panel.querySelector<HTMLInputElement>('[name=Find]')!;set(readonlyFind,'hello');panel.querySelector<HTMLButtonElement>('[name="Toggle replace"]')!.click();
if(!panel.querySelector<HTMLInputElement>('[name=Replace]')!.disabled||!panel.querySelector<HTMLButtonElement>('[name="replace all"]')!.disabled)throw Error('Read-only replacement allowed');
readonlyFind.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));if(modified)throw Error('Read-only search mutated text');readonly.destroy();host.remove();
console.log('FRAMELY_BRIDGE_PASS');
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}
};
console.log('FRAMELY_VIEW_READY');
+30
View File
@@ -0,0 +1,30 @@
import React,{useState} from 'react';
import {createRoot} from 'react-dom/client';
import {FileCodeEditor} from '../ui/src/file-code-editor';
import {configureLanguage} from '../ui/src/i18n';
import {installKeyboard} from '../sdk/src/keyboard';
import '../ui/src/style.css';
import '../ui/src/file-manager.css';
configureLanguage('en-US',[]);installKeyboard(async()=>true);
let attempts=0;
const append=document.head.append.bind(document.head);
document.head.append=(...nodes:(Node|string)[])=>{const script=nodes.find(n=>n instanceof HTMLScriptElement) as HTMLScriptElement|undefined;if(script?.src.endsWith('/assets/code-editor.js')&&attempts++===0){setTimeout(()=>script.dispatchEvent(new Event('error')),20);return;}append(...nodes);};
function Fixture(){const [value,setValue]=useState('original'),[readOnly,setReadOnly]=useState(false);return <div className="app"><div className="file-manager"><h1>Editor fallback test</h1><FileCodeEditor path="test.ts" value={value} onChange={setValue} readOnly={readOnly} onSave={()=>{}}/><output id="fallback-state">{value}</output><button onClick={()=>setReadOnly(true)}>Read-only</button></div></div>;}
createRoot(document.getElementById('root')!).render(<Fixture/>);
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);};
(window as any).runInstallReviewChecks=async()=>{try{
await until(()=>document.querySelector('textarea'));
const fallback=document.querySelector('textarea')!;fallback.focus();(window as any).__framelyCommitKeyboard('汉😃\nline');await until(()=>document.querySelector('#fallback-state')?.textContent==='汉😃\nline');
fallback.setSelectionRange(fallback.value.length,fallback.value.length);
(window as any).__framelyEditKeyboard('\b');await until(()=>document.querySelector('#fallback-state')?.textContent==='汉😃\nlin');
[...document.querySelectorAll('button')].find(b=>b.textContent==='Retry')!.click();await until(()=>document.querySelector('.cm-content[contenteditable=true]'));
if(attempts!==2||document.querySelector('textarea')||!document.querySelector('.cm-content')!.textContent?.includes('lin'))throw Error('Retry lost fallback content');
const content=document.querySelector<HTMLElement>('.cm-content')!;content.focus();await wait();(window as any).__framelyEditKeyboard('中文');await wait();
if(!document.querySelector('#fallback-state')?.textContent?.includes('中文'))throw Error('Native editor input failed after retry');
[...document.querySelectorAll('button')].find(b=>b.textContent==='Read-only')!.click();await until(()=>content.contentEditable==='false');const previous=document.querySelector('#fallback-state')?.textContent;
(window as any).__framelyEditKeyboard('not allowed');await wait();if(document.querySelector('#fallback-state')?.textContent!==previous)throw Error('Read-only editor accepted native input');
console.log('FRAMELY_BRIDGE_PASS');
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error+' '+(error as Error).stack);}
};
console.log('FRAMELY_VIEW_READY');
+45 -2
View File
@@ -9,6 +9,7 @@ const until=async(f:()=>unknown)=>{for(let n=0;n<150;n++){if(f())return;await wa
const home='/home/steamos',downloads=home+'/Downloads';
const longFolder='一个很长的文件夹名称 - Project documents and archived screenshots';
let binaryMode=false;
let savedText='fixture text',permissionMode=420,failPermissions=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)=>[
@@ -17,6 +18,7 @@ const files=(directory:string)=>[
{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)==='/api'){const request=JSON.parse(String(init?.body));calls.push(request);return new Response(JSON.stringify({result:true}));}
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=[];
@@ -35,7 +37,10 @@ window.fetch=async(url,init)=>{
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==='read'){if(p.path.endsWith('notes.txt'))result={text:savedText,revision:'revision',readOnly:false};else if(p.asText)result={text:'binary\u0000preview',revision:'binary',readOnly:true};else return new Response(JSON.stringify({error:'Binary files cannot be edited as text'}));}
else if(p.operation==='write'){savedText=p.text;result={text:savedText,revision:'saved',readOnly:false};}
else if(p.operation==='properties'){const e=files(home).find(e=>e.path===p.path)!;result={...e,regular:!e.directory&&!e.symlink,mode:permissionMode,owner:'steamos',group:'steamos',canChmod:!e.symlink,inode:'9007199254740993',device:'2049',links:1,allocated:8192,accessed:1791360000,changed:1791360000,linkTarget:e.symlink?'notes.txt':undefined,resolvedPath:e.symlink?home+'/notes.txt':e.path};}
else if(p.operation==='chmod'){if(failPermissions)return new Response(JSON.stringify({error:'Fixture permission denied'}));if(p.identity.inode!=='9007199254740993')throw Error('Properties lost inode precision');permissionMode=p.mode;result=true;}
else if(p.operation==='download')result={url:'/fixture-download'};
else throw Error('Unexpected operation '+p.operation);
return new Response(JSON.stringify({result}));
@@ -49,6 +54,17 @@ 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(innerWidth<1000){
const item=(name:string)=>[...document.querySelectorAll<HTMLButtonElement>('.file-name')].find(e=>e.textContent?.trim()===name)!;
const context=async(name:string)=>{await until(ready);item(name).dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:300,clientY:400}));await until(()=>document.querySelector('.file-context-menu'));};
await context('notes.txt');button('Properties').click();await until(()=>document.querySelector('.file-properties-modal')?.getAttribute('aria-busy')==='false');
if(document.querySelectorAll('.file-permission-grid button').length!==9)throw Error('Mobile permissions missing');await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PROPERTIES_EN');await wait(100);button('Close').click();await until(()=>!document.querySelector('.file-properties-modal'));
await context('notes.txt');button('Edit as text').click();await until(()=>document.querySelector('.cm-content[contenteditable=true]'));const content=document.querySelector<HTMLElement>('.cm-content')!;content.focus();await wait();(window as any).__framelyEditKeyboard('中文');await wait();if(!content.textContent?.includes('中文'))throw Error('Mobile native editing failed');await wait(100);console.log('FRAMELY_PREVIEW_INSTALL_CODE_EDITOR_EN');await wait(100);button('Save').click();await until(()=>!button('Close').disabled&&button('Save').disabled&&savedText.includes('中文'));button('Close').click();await until(()=>!document.querySelector('.file-editor-modal'));
await context('A very long photograph name with spaces.png');await until(()=>!button('Edit as text').disabled);button('Edit as text').click();await until(()=>document.querySelector('.cm-content[contenteditable=false]'));if(!button('Save').disabled)throw Error('Mobile binary preview writable');button('Close').click();await until(()=>!document.querySelector('.file-editor-modal'));
configureLanguage('zh-CN',[]);render('mobile-zh');await wait();await until(ready);await context('notes.txt');button('属性').click();await until(()=>document.querySelector('.file-properties-modal')?.getAttribute('aria-busy')==='false');await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PROPERTIES_ZH');await wait(100);
if(document.documentElement.scrollWidth>innerWidth)throw Error('Mobile page overflow');
HTMLAnchorElement.prototype.click=anchorClick;console.log('FRAMELY_BRIDGE_PASS');return;
}
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));
@@ -72,7 +88,34 @@ const changeAddress=async(value:string)=>{const input=document.querySelector<HTM
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();
const tile=document.querySelector<HTMLButtonElement>('.file-tile-open[title="notes.txt"]')!;
tile.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:500,clientY:400}));await until(()=>document.querySelector('.file-context-menu'));button('Properties').click();await until(()=>document.querySelector('.file-properties-modal')?.getAttribute('aria-busy')==='false');
await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PROPERTIES_EN');await wait(100);
if(document.querySelectorAll('.file-permission-grid [role=checkbox]').length!==9||!document.querySelector('.file-property-grid')?.textContent?.includes('9007199254740993'))throw Error('Detailed properties missing');
document.querySelector<HTMLButtonElement>('[aria-label="Owner: Execute / enter"]')!.click();await wait();
if(document.querySelector<HTMLInputElement>('.file-permissions input')!.value!=='744')throw Error('Permission grid and octal mode disagree');
const modeInput=document.querySelector<HTMLInputElement>('.file-permissions input')!;
const setMode=async(value:string)=>{Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(modeInput,value);modeInput.dispatchEvent(new Event('input',{bubbles:true}));await wait();};
await setMode('888');if(!button('Save permissions').disabled)throw Error('Invalid octal mode accepted');
await setMode('640');failPermissions=true;button('Save permissions').click();await until(()=>document.body.textContent?.includes('Fixture permission denied'));if(!document.querySelector('.file-properties-modal'))throw Error('Permission failure closed panel');
failPermissions=false;await wait();button('Save permissions').click();await until(()=>!document.querySelector('.file-properties-modal'));if(permissionMode!==416)throw Error('Permission save failed');
tile.click();await until(()=>document.querySelector('.cm-content[contenteditable=true]'));await wait(100);
if(!document.querySelector('.cm-lineNumbers')||document.querySelector('.cm-tooltip-autocomplete'))throw Error('Editor features incorrect');
await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_CODE_EDITOR_EN');await wait(100);
const content=document.querySelector<HTMLElement>('.cm-content')!;content.focus();await wait();(window as any).__framelyEditKeyboard('中文');await wait();if(!content.textContent?.includes('中文'))throw Error('Native keyboard input missing');
(window as any).__framelyEditKeyboard('\n');(window as any).__framelyEditKeyboard('\b');await wait();
document.querySelector<HTMLButtonElement>('.file-code-toolbar [aria-label="Undo"]')!.click();await wait();document.querySelector<HTMLButtonElement>('.file-code-toolbar [aria-label="Redo"]')!.click();await wait();
button('Close').click();await until(()=>document.querySelector('.modal[aria-label="Unsaved changes"]'));button('Cancel').click();await until(()=>!document.querySelector('.modal[aria-label="Unsaved changes"]'));if(document.querySelector('.cm-content')!==content)throw Error('Canceling close discarded editor history');
const probe=document.createElement('div');probe.style.cssText='position:fixed;left:-10000px;width:400px;height:200px';document.body.append(probe);
const syntax=window.FramelyCodeEditor!.mount({parent:probe,value:'const enabled: boolean = true;\nfunction demo() {\n return enabled;\n}\n',path:'settings.ts',readOnly:false,label:'Syntax test',phrases:{},onChange:()=>{},onSave:()=>{}});await wait(150);
if(probe.querySelectorAll('.cm-line span').length<3||!probe.querySelector('.cm-foldGutter'))throw Error('Code highlighting or folding missing');if(document.querySelector('.cm-tooltip-autocomplete'))throw Error('Completion popup enabled');syntax.destroy();probe.remove();
button('Find and replace').click();await until(()=>document.querySelector('.cm-search'));document.querySelector<HTMLInputElement>('.cm-search input')!.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await wait();if(!document.querySelector('.file-editor-modal'))throw Error('Search Escape closed file');
button('Save').click();await until(()=>savedText.includes('中文'));await until(()=>!button('Close').disabled);button('Close').click();await until(()=>!document.querySelector('.file-editor-modal'));
// Text entry is available even for images, without invoking media preview.
await until(ready);document.querySelector<HTMLButtonElement>('.file-tile-open[title="A very long photograph name with spaces.png"]')!.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:500,clientY:400}));await until(()=>document.querySelector('.file-context-menu')&&!button('Edit as text').disabled);button('Edit as text').click();await until(()=>document.querySelector('.cm-content[contenteditable=false]'));
if(!button('Save').disabled||!document.body.textContent?.includes('read-only to protect'))throw Error('Binary preview is writable');button('Close').click();await until(()=>!document.querySelector('.file-editor-modal'));
document.querySelector<HTMLButtonElement>('.file-tile-open[title="movie.webm"]')!.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:500,clientY:400}));await until(()=>document.querySelector('.file-context-menu'));button('Properties').click();await until(()=>document.querySelector('.file-properties-modal')?.getAttribute('aria-busy')==='false');
if(!button('Save permissions').disabled||document.querySelector('.file-permission-grid button:not(:disabled)'))throw Error('Symlink permissions editable');button('Close').click();await until(()=>!document.querySelector('.file-properties-modal'));
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'));
+3
View File
@@ -4,6 +4,9 @@
#include <cassert>
int main(){
RecordingIndicatorClick click;
RecordingIndicator hidden;hidden.cancel_input();hidden.destroy();
assert(!hidden.active());assert(!hidden.hovered(0));
vr::HmdMatrix34_t pose{};assert(hidden.pointer(0,pose,true,false).empty());
assert(!click.update(true,true));assert(!click.update(false,true));
assert(!click.update(true,false));assert(!click.update(false,true));
assert(!click.update(true,true));assert(click.update(false,true));assert(!click.update(false,true));
+41
View File
@@ -0,0 +1,41 @@
import React,{useState} from 'react';
import {createRoot} from 'react-dom/client';
import {CastQuick} from '../ui/src/cast-quick';
import {Casting,type CastSettings} from '../ui/src/casting';
import {configureLanguage} from '../ui/src/i18n';
import '../ui/src/style.css';
let config:CastSettings={source:'screen',codec:'h264',output:'eye',eye:'right',width:1920,height:1440,fps:30,bitrateMbps:8,horizontalFov:null,centerX:0,centerY:0,systemAudio:true,microphone:false,airplay:false,dlna:false,receiverName:'Framely'};
let recording={active:true,id:'same-recording',elapsedSeconds:75},fail=true;
const calls:string[]=[];
window.fetch=async(_url,init)=>{
const {method,params}=JSON.parse(String(init?.body));calls.push(method);
if(method==='cast.recordingIndicator.visible.save'){
if(fail)return new Response(JSON.stringify({error:'Simulated save failure'}));
config={...config,recordingIndicatorVisible:params.enabled};
}
if(method==='cast.record.stop')recording={...recording,active:false};
return new Response(JSON.stringify({result:method==='cast.status'?{running:false,recording,dominantEye:1,watchPath:'/cast/watch'}:true}));
};
const root=createRoot(document.getElementById('root')!);
function Preview({manager}:{manager:boolean}){const [value,setValue]=useState(config);const refresh=async()=>{setValue({...config});};return <div className="app"><main className="content">{manager?<Casting config={value} refresh={refresh} network={{enabled:true,port:15915,passwordEnabled:false}}/>:<CastQuick config={value} refresh={refresh}/>}</main></div>;}
const wait=()=>new Promise(r=>setTimeout(r,40));
const until=async(check:()=>unknown)=>{for(let i=0;i<100;i++){if(check())return;await wait();}throw Error('UI timeout '+JSON.stringify({config,recording,calls,checked:toggle()?.getAttribute('aria-checked')})+' '+document.body.innerText);};
const toggle=()=>document.querySelector<HTMLButtonElement>('[role=switch][aria-label="显示录制指示器"]')!;
const stop=()=>Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent?.includes('停止录制'))!;
(async()=>{try{
configureLanguage('zh-CN',[]);root.render(<Preview manager={false}/>);
await until(()=>toggle()&&!toggle().disabled&&stop());
if(toggle().getAttribute('aria-checked')!=='true')throw Error('Legacy default is not visible');
toggle().click();await until(()=>document.body.textContent?.includes('Simulated save failure')&&!toggle().disabled);
if(toggle().getAttribute('aria-checked')!=='true')throw Error('Failed save changed switch');
fail=false;toggle().click();await until(()=>toggle().getAttribute('aria-checked')==='false'&&!toggle().disabled);
if(recording.id!=='same-recording'||recording.elapsedSeconds!==75||!recording.active||stop().disabled)throw Error('Visibility changed the recording');
stop().click();await until(()=>!recording.active&&!toggle().disabled&&!stop());
recording={...recording,active:true};root.render(<Preview key="manager" manager/>);
await until(()=>document.querySelector('.cast-page-heading')&&toggle()&&toggle().getAttribute('aria-checked')==='false'&&!toggle().disabled&&stop());
toggle().click();await until(()=>toggle().getAttribute('aria-checked')==='true'&&!toggle().disabled);
if(calls.some(m=>['cast.settings.save','cast.start','cast.stop','cast.record.start'].includes(m)))throw Error('Visibility restarted capture');
stop().click();await until(()=>!recording.active);
console.log('FRAMELY_BRIDGE_PASS');
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}})();
+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import {webcrypto} from 'node:crypto';
const read=name=>fs.readFileSync(new URL('../src/apk/'+name,import.meta.url),'utf8');
const palette=vm.runInNewContext('('+read('steam_artwork_palette.js')+')');
const rgba=color=>Uint8Array.from(Array.from({length:64*64},()=>color).flat());
const pink=palette(rgba([255,105,153,255]),64,64);
assert.equal(pink.selected,'#ff6999');assert.equal(pink.accent,'#cc547a');
assert.equal(pink.neutral,false);
assert.equal(JSON.stringify(pink),JSON.stringify(palette(rgba([255,105,153,255]),64,64)));
assert.equal(palette(rgba([250,250,250,255]),64,64).neutral,true);
assert.equal(palette(rgba([255,0,0,0]),64,64).neutral,true);
const blue=rgba([26,114,230,255]);blue.set([255,0,0,255],0);
assert.equal(palette(blue,64,64).selected,'#1a72e6');
assert.throws(()=>palette(new Uint8Array(2),64,64));
// Real Steam contract: unsupported type 4 aliases the landscape destination.
const suffix={0:'p',1:'_hero',2:'_logo',3:'',4:''},id=0x92345678;
const encode=color=>Buffer.from(JSON.stringify({width:1,height:1,color})).toString('base64');
class Image {
set src(uri){const v=JSON.parse(Buffer.from(uri.split(',')[1],'base64').toString());Object.assign(this,v);queueMicrotask(()=>this.onload());}
}
class Canvas {
constructor(color){this.width=1;this.height=1;this.color=color;}
getContext(){return {drawImage:image=>{this.color=image.color;},getImageData:()=>({data:Uint8ClampedArray.from(this.color)})};}
}
class FileReader {
readAsDataURL(blob){blob.arrayBuffer().then(b=>{this.result='data:image/png;base64,'+Buffer.from(b).toString('base64');this.onload();});}
}
const files=new Map(),writes=[];let race=null,failOnce=false,failKind=null;
const fetch=async url=>{const key=url.split('?')[0];if(race){const run=race;race=null;run(key);}const data=files.get(key);return data?new Response(Buffer.from(data,'base64')):new Response(null,{status:404});};
const SteamClient={Apps:{SetCustomArtworkForApp:async(app,data,ext,kind)=>{
assert.equal(app,id);assert.equal(ext,'png');
assert.ok([0,1,2,3].includes(kind),'Never send the shortcut icon as custom artwork');
if(kind===failKind)throw Error('persistent simulated Steam failure');
if(failOnce){failOnce=false;throw Error('simulated Steam failure');}
files.set(`/customimages/${id}${suffix[kind]}.png`,data);writes.push(kind);
}}};
const apply=vm.runInNewContext('('+read('steam_artwork_apply.js')+')',{Image,FileReader,document:{createElement:()=>new Canvas()},TextEncoder,Uint8Array,crypto:webcrypto,fetch,SteamClient,setTimeout,clearTimeout,queueMicrotask});
let color=[100,50,10,255];
const render=async()=>[0,1,2,3].map(kind=>({kind,data:encode(color),canvas:new Canvas(color)}));
const params={image:'unused',ownership:null,legacy:[]};
let owned=await apply(params,id,render,palette);
assert.deepEqual(writes,[0,1,2,3]);assert.equal(Object.keys(owned.hashes).length,4);
params.ownership=owned;writes.length=0;
await apply(params,id,render,palette);assert.equal(writes.length,0,'Identical artwork should not be rewritten');
files.set(`/customimages/${id}.png`,encode([250,0,0,255]));
color=[20,60,90,255];writes.length=0;
owned=await apply(params,id,render,palette);
assert.ok(!writes.includes(3),'User landscape must survive');assert.equal(owned.hashes[3],undefined);assert.equal(writes.length,3);
params.ownership=owned;files.set(`/customimages/${id}p.jpg`,encode([0,250,0,255]));
color=[30,70,90,255];writes.length=0;
owned=await apply(params,id,render,palette);assert.ok(!writes.includes(0),'Alternate custom extension must survive');
params.ownership={...owned,appId:id+1};writes.length=0;
await apply(params,id,render,palette);assert.equal(writes.length,0,'Ownership from another shortcut must not be trusted');
files.clear();params.ownership=null;params.legacy=[{kind:0,data:encode([1,2,3,255])}];
files.set(`/customimages/${id}p.png`,params.legacy[0].data);writes.length=0;failOnce=true;
owned=await apply(params,id,render,palette);assert.equal(writes.length,4,'Exact legacy artwork should migrate, with bounded retry');
params.ownership=owned;params.legacy=[];writes.length=0;
// Change a file between inspection and the last pre-write check.
let inspections=0;
const racingFetch=async url=>{if(url.startsWith(`/customimages/${id}p.png`)&&++inspections===2)files.set(`/customimages/${id}p.png`,encode([210,20,20,255]));return fetch(url);};
const racingApply=vm.runInNewContext('('+read('steam_artwork_apply.js')+')',{Image,FileReader,document:{createElement:()=>new Canvas()},TextEncoder,Uint8Array,crypto:webcrypto,fetch:racingFetch,SteamClient,setTimeout,clearTimeout,queueMicrotask});
color=[90,80,70,255];await racingApply(params,id,render,palette);assert.ok(!writes.includes(0),'Concurrent user change must survive');
files.clear();writes.length=0;params.ownership=null;failKind=1;
const checkpoint=await apply(params,id,render,palette);
assert.equal(checkpoint.failed,true);assert.ok(checkpoint.hashes[0]);assert.deepEqual(writes,[0]);
params.ownership=checkpoint;failKind=null;writes.length=0;
const recovered=await apply(params,id,render,palette);
assert.equal(recovered.failed,undefined);assert.deepEqual(writes,[1,2,3]);
params.ownership=recovered;params.ownership.hashes[4]=recovered.hashes[3];params.ownership.hashes[3]='f'.repeat(64);
color=[70,60,50,255];writes.length=0;
const repaired=await apply(params,id,render,palette);
assert.ok(writes.includes(3),'Exact type-4 icon overwrite must be repaired');assert.equal(repaired.hashes[4],undefined);
const unsupported=await apply(params,id,async()=>[{kind:4,data:encode(color),canvas:new Canvas(color)}],palette);
assert.equal(unsupported.failed,true,'Unsupported type must be rejected before writing');
console.log('Steam artwork: generic palette, 80% intensity, deterministic output, ownership, custom images, legacy upgrade, retry, race and partial-failure recovery checks passed');
+44
View File
@@ -0,0 +1,44 @@
window.runInstallReviewChecks=async()=>{
try{
await document.fonts.ready;
const check=(ok,message)=>{if(!ok)throw Error(message);};
const source=document.createElement('canvas');source.width=256;source.height=256;
const c=source.getContext('2d');c.fillStyle='#ff6999';c.fillRect(0,0,256,256);
c.fillStyle='white';c.fillRect(60,60,136,136);
const p={image:source.toDataURL(),name:'哔哩哔哩HD',package:'test.example'};
const sample=document.createElement('canvas');sample.width=sample.height=64;sample.getContext('2d').drawImage(source,0,0,64,64);
const palette=selectPalette(sample.getContext('2d').getImageData(0,0,64,64).data,64,64);
check(palette.selected==='#ff6999'&&palette.accent==='#cc547a','Wrong 80% palette');
const a=await renderArtwork(p,selectPalette);
const expected={0:[600,900],1:[3840,1240],2:[1280,360],3:[920,430]};
check(a.length===4&&!a.some(v=>v.kind===4),'Only four supported custom-artwork types may be rendered');
for(const v of a){check(v.canvas.width===expected[v.kind][0]&&v.canvas.height===expected[v.kind][1],'Wrong artwork dimensions');check(v.data.length>100,'Missing PNG');}
const logo=a.find(v=>v.kind===2).canvas;
check(logo.getContext('2d').getImageData(0,0,1,1).data[3]===0,'Logo lost alpha');
const decoded=await new Promise((resolve,reject)=>{const image=new Image();image.onload=()=>resolve(image);image.onerror=reject;image.src='data:image/png;base64,'+a.find(v=>v.kind===2).data;});
const roundtrip=document.createElement('canvas');roundtrip.width=logo.width;roundtrip.height=logo.height;roundtrip.getContext('2d').drawImage(decoded,0,0);
const before=logo.getContext('2d').getImageData(0,0,logo.width,logo.height).data,after=roundtrip.getContext('2d').getImageData(0,0,logo.width,logo.height).data;
check(before.every((v,i)=>v===after[i]),'PNG roundtrip changes ownership pixel hashes');
const portrait=a.find(v=>v.kind===0).canvas,ctx=portrait.getContext('2d');
const pixels=ctx.getImageData(0,475,600,100).data;let min=600,max=0,count=0;
for(let i=0;i<pixels.length;i+=4)if(pixels[i]>245&&pixels[i+1]>245&&pixels[i+2]>245){const x=(i/4)%600;min=Math.min(min,x);max=Math.max(max,x);count++;}
check(count>500,'Chinese title missing');check(Math.abs((min+max)/2-300)<8,'Portrait title is not centered');
let accentPixels=0;const all=ctx.getImageData(0,0,600,900).data;
for(let i=0;i<all.length;i+=4)if(all[i]===204&&all[i+1]===84&&all[i+2]===122)accentPixels++;
check(accentPixels>1000,'Main ornaments do not preserve 80% source color');
const b=await renderArtwork(p,selectPalette);check(a.every((v,i)=>v.data===b[i].data),'Rendering is not deterministic');
const long=await renderArtwork({...p,name:'极长的应用名称 & <script>window.bad=true</script> '.repeat(3)},selectPalette);
check(long.length===4&&!window.bad,'Unsafe title rendering');
const hero=a.find(v=>v.kind===1).canvas;
const cropped=document.createElement('canvas');cropped.width=1280;cropped.height=307;
const sourceHeight=307/(1280/3840);
cropped.getContext('2d').drawImage(hero,0,(1240-sourceHeight)/2,3840,sourceHeight,0,0,1280,307);
const visible=cropped.getContext('2d').getImageData(0,0,1280,307).data;let visibleAccent=0;
for(let i=0;i<visible.length;i+=4)if(visible[i]===204&&visible[i+1]===84&&visible[i+2]===122)visibleAccent++;
check(visibleAccent>6000,'Main hero motif is lost under Frame cover cropping');
document.getElementById('root').textContent='Steam artwork renderer passed: 4 artwork types, CJK title, alpha, centering, palette, deterministic output and real Frame hero crop';
// Let the existing probe observe a completed frame before announcing success.
await new Promise(r=>setTimeout(r,250));console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}
};
console.log('FRAMELY_VIEW_READY');
+28 -12
View File
@@ -106,18 +106,34 @@ class CEFRuntimeTests(unittest.TestCase):
requirement = json.loads((core / 'CEF_RUNTIME.json').read_text())
data = (folder / requirement['archive']).read_bytes()
# The downloaded helper imports the shipped bootstrap module.
for bad in (True, False):
store = folder / ('bad-store' if bad else 'download-store')
store.mkdir()
response = io.BytesIO(data[:-1] if bad else data)
with patch('urllib.request.OpenerDirector.open', return_value=response):
if bad:
with self.assertRaises(ValueError):
runtime.prepare(core, store)
self.assertFalse((store / 'cef' / requirement['id']).exists())
else:
cached = runtime.prepare(core, store)
self.assertEqual((cached / 'lib/cef/libcef.so').read_text(), 'browser')
tampered = data[:-1] + bytes([data[-1] ^ 1])
for case, payload, failure, message, attempts in [
('tampered', tampered, ValueError, 'SHA256 mismatch', 1),
('truncated', data[:-1], RuntimeError, 'IncompleteRead', 2),
('valid', data, None, '', 1)]:
with self.subTest(case=case):
store = folder / (case + '-store')
store.mkdir()
# Each request owns a fresh HTTP-like response, including
# the fallback request after a truncated primary body.
def response(*_args, **_kwargs):
result = io.BytesIO(payload)
result.headers = {'Content-Length': str(len(payload))}
return result
with patch.dict('os.environ', {'FRAMELY_GITHUB_AUTO_FALLBACK': '1'}), \
patch('urllib.request.OpenerDirector.open', side_effect=response) as request:
if failure:
with self.assertRaisesRegex(failure, message):
runtime.prepare(core, store)
self.assertFalse((store / 'cef' / requirement['id']).exists())
else:
cached = runtime.prepare(core, store)
self.assertEqual((cached / 'lib/cef/libcef.so').read_text(), 'browser')
self.assertEqual(request.call_count, attempts)
self.assertEqual(request.call_args_list[0].args[0].full_url, requirement['url'])
if attempts == 2:
self.assertEqual(request.call_args_list[1].args[0].full_url,
'https://gh-proxy.com/' + requirement['url'])
def test_runtime_extraction_rejects_links(self):
with tempfile.TemporaryDirectory() as temp:
+133
View File
@@ -0,0 +1,133 @@
"""Focused network fallback tests: no public network or device access."""
import http.server
import errno
import importlib.util
import io
import os
import pathlib
import ssl
import subprocess
import tempfile
import threading
import unittest
from unittest.mock import patch
import urllib.error
import urllib.request
ROOT = pathlib.Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('bootstrap', ROOT / 'tools/bootstrap.py')
network = importlib.util.module_from_spec(spec)
spec.loader.exec_module(network)
class FallbackTests(unittest.TestCase):
def test_unreachable_network_retries_once_but_local_io_errors_do_not(self):
url = 'https://github.com/a/b/releases/download/v1/archive'
for code in (errno.ENETDOWN, errno.ENETUNREACH, errno.EHOSTUNREACH):
for wrapped in (False, True):
with self.subTest(code=code, wrapped=wrapped):
calls = []
def attempt(fallback):
calls.append(fallback)
error = OSError(code, 'simulated network failure')
raise urllib.error.URLError(error) if wrapped else error
with self.assertRaisesRegex(RuntimeError, 'primary route failed.*fallback failed'):
network.github_attempt(url, attempt, enabled=True)
self.assertEqual(calls, [False, True])
calls.clear()
with self.assertRaises((OSError, urllib.error.URLError)):
network.github_attempt(url, attempt, enabled=False)
self.assertEqual(calls, [False])
for code in (errno.ENOSPC, errno.EACCES, errno.ENOENT, errno.EIO):
with self.subTest(code=code):
calls = []
def attempt(fallback):
calls.append(fallback)
raise OSError(code, 'simulated local failure')
with self.assertRaises(OSError):
network.github_attempt(url, attempt, enabled=True)
self.assertEqual(calls, [False])
def test_q2f_checksum_failure_does_not_retry(self):
spec = importlib.util.spec_from_file_location('q2f', ROOT / 'tools/q2f-runtime.py')
runtime = importlib.util.module_from_spec(spec); spec.loader.exec_module(runtime)
calls = []
def fetch(_url, fallback=False):
calls.append(fallback)
response = io.BytesIO(b'payload'); response.headers = {'Content-Length':'7'}
return response
with tempfile.TemporaryDirectory() as temp, patch.object(runtime._network, 'fetch_once', fetch):
with self.assertRaisesRegex(ValueError, 'checksum mismatch'):
runtime.download({'url':'https://github.com/a/b/releases/download/v1/q2f','size':7,'sha256':'0'*64}, pathlib.Path(temp) / 'runtime')
self.assertEqual(calls, [False])
def test_scope_and_status_policy(self):
self.assertTrue(network.public_github('https://api.github.com/repos/a/b/releases?page=1'))
for url in ['http://github.com/a', 'https://github.com.evil/a', 'https://a.github.com/a',
'https://user@github.com/a', 'https://github.com/a?token=secret',
'https://gh-proxy.com/https://github.com/a', 'https://github.com/a?X-Amz-Signature=secret']:
self.assertFalse(network.public_github(url), url)
for code, headers, expected in [(404, {}, False), (403, {}, False),
(403, {'X-RateLimit-Remaining': '0'}, True), (429, {}, True), (500, {}, True)]:
self.assertEqual(network.retryable(urllib.error.HTTPError('https://github.com/a', code, '', headers, None)), expected)
self.assertFalse(network.retryable(ssl.SSLCertVerificationError('bad certificate')))
def test_primary_success_disabled_fallback_and_dual_failure(self):
calls = []
self.assertEqual(network.github_attempt('https://github.com/a', lambda f: calls.append(f) or 9), 9)
self.assertEqual(calls, [False])
with patch.dict(os.environ, {'FRAMELY_GITHUB_AUTO_FALLBACK':'0'}):
with self.assertRaises(ConnectionResetError):
network.github_attempt('https://github.com/a', lambda _: (_ for _ in ()).throw(ConnectionResetError()))
with self.assertRaisesRegex(RuntimeError, 'primary route failed.*fallback failed') as error:
network.github_attempt('https://github.com/a', lambda _: (_ for _ in ()).throw(ConnectionResetError('https://secret?token=value')))
self.assertNotIn('secret', str(error.exception))
def test_partial_body_is_discarded_using_local_http(self):
calls = []
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_): pass
def do_GET(self):
calls.append(self.path)
self.send_response(200)
self.send_header('Content-Length', '8')
self.end_headers()
self.wfile.write(b'old' if self.path == '/primary' else b'new-data')
self.close_connection = True
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with tempfile.TemporaryDirectory() as temp:
dest = pathlib.Path(temp) / 'download'
progress = []
def fetch(_url, fallback=False):
return urllib.request.urlopen(f'http://127.0.0.1:{server.server_port}/' + ('fallback' if fallback else 'primary'))
with patch.object(network, 'fetch_once', fetch), patch.dict(os.environ, {'FRAMELY_GITHUB_AUTO_FALLBACK':'1'}):
network.download('https://github.com/a/b/releases/download/v1/file', dest, 100, progress.append)
self.assertEqual(dest.read_bytes(), b'new-data')
self.assertEqual(calls, ['/primary', '/fallback'])
self.assertEqual(progress, [0, 0, 8])
finally:
server.shutdown(); server.server_close(); thread.join()
def test_bootstrap_curl_failure_policy(self):
# Fake curl and Python only; execute the actual public bootstrap script.
for code, status, enabled, attempts in [(7, '000', '1', 2), (22, '404', '1', 1), (60, '000', '1', 1), (7, '000', '0', 1)]:
with self.subTest(code=code, status=status, enabled=enabled), tempfile.TemporaryDirectory() as temp:
root = pathlib.Path(temp); log = root / 'calls'
curl = root / 'curl'
curl.write_text(f'''#!/bin/sh
echo called >> '{log}'
case "$*" in *gh-proxy.com*) exit 0;; esac
printf '%s' '{status}'
exit {code}
''')
python = root / 'python3'; python.write_text('#!/bin/sh\nexit 0\n')
curl.chmod(0o755); python.chmod(0o755)
env = dict(os.environ, PATH=temp + os.pathsep + os.environ['PATH'], FRAMELY_GITHUB_AUTO_FALLBACK=enabled)
subprocess.run(['bash', str(ROOT / 'install.sh')], env=env, capture_output=True)
self.assertEqual(len(log.read_text().splitlines()), attempts)
if __name__ == '__main__':
unittest.main()
+6
View File
@@ -5,11 +5,17 @@ import pathlib
import subprocess
import tempfile
import unittest
from unittest.mock import patch
ROOT = pathlib.Path(__file__).parents[1]
class InstallHandoffTests(unittest.TestCase):
def test_auto_proxy_preference_survives_service_handoff(self):
with patch.dict(os.environ, {'FRAMELY_GITHUB_AUTO_FALLBACK':'0'}):
result, args, _ = self.run_handoff('0::/system.slice/framely-session.service')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('--setenv=FRAMELY_GITHUB_AUTO_FALLBACK=0', args)
def run_handoff(self, cgroup, repair=False, launch_status=0):
source = (ROOT / 'packaging/install.sh').read_text()
block = source.split('# A terminal opened by Framely', 1)[1].split('cd "$base"', 1)[0]
+3 -1
View File
@@ -31,7 +31,9 @@ const SteamClient={{Apps:{{
def test_clears_every_injected_image_and_shortcut_icon(self):
result = self.run_cleanup('"/owned/./base.apk"')
self.assertTrue(result['ok'])
self.assertEqual(result['calls'], [['clear', 2452903544, kind] for kind in [0, 1, 3, 4]]
# Portrait, hero, transparent logo and landscape are artwork types
# 0..3. The shortcut icon is cleared separately, never as type 4.
self.assertEqual(result['calls'], [['clear', 2452903544, kind] for kind in [0, 1, 2, 3]]
+ [['icon', 2452903544, '']])
def test_refuses_another_shortcut_with_the_same_id(self):
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {Casting,type CastSettings} from '../ui/src/casting';
import {configureLanguage} from '../ui/src/i18n';
import '../ui/src/style.css';
const config:CastSettings={source:'screen',codec:'h264',output:'eye',eye:'right',width:1920,height:1440,fps:30,bitrateMbps:8,horizontalFov:null,centerX:0,centerY:0,systemAudio:true,microphone:false,airplay:false,dlna:false,receiverName:'Framely'};
let running=false,enabled=false,fail=false,loaded=false,release:(()=>void)|undefined,copied='',phase='loading';
const calls:string[]=[];
const status=()=>({running,preview:running,error:null,watchAccessEnabled:enabled,watchPath:enabled?'/cast/watch?token=test-token':'/cast/watch',watchAddresses:[`http://192.168.5.67:15915/cast/watch${enabled?'?token=test-token':''}`]});
Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async(value:string)=>{copied=value;}}});
window.fetch=async(_input,init)=>{
const {method,params={}}=JSON.parse(String(init?.body));calls.push(method);
if(method==='cast.status'&&!loaded)await new Promise<void>(resolve=>{release=resolve;});
if(method==='cast.watchAccess.save'){
await new Promise(r=>setTimeout(r,250));
if(fail)return new Response(JSON.stringify({error:'Simulated watch-access save failure'}));
enabled=params.enabled;
}
return new Response(JSON.stringify({result:method==='cast.status'?status():true}));
};
const wait=()=>new Promise(r=>setTimeout(r,25));
const toggle=()=>document.querySelector<HTMLButtonElement>('[role=switch][aria-label="免验证"]');
const until=async(check:()=>unknown)=>{for(let i=0;i<200;i++){if(check())return;await wait();}throw Error('UI timeout at '+phase+': '+JSON.stringify({running,enabled,fail,copied,calls})+' '+document.body.innerText);};
(async()=>{try{
configureLanguage('zh-CN',[]);
createRoot(document.getElementById('root')!).render(<div className="app"><main className="content"><Casting config={config} refresh={async()=>{}} network={{enabled:false,port:15915,passwordEnabled:false}}/></main></div>);
await until(()=>toggle()&&release);
if(!toggle()!.disabled||document.querySelector('.cast-share-link'))throw Error('Loading state exposes an actionable or incorrect link');
loaded=true;release!();
await until(()=>toggle()&&!toggle()!.disabled&&document.querySelector('.cast-share-link'));
if(running||toggle()!.getAttribute('aria-checked')!=='false')throw Error('Stopped stream must expose switch');
phase='enable';toggle()!.click();await until(()=>toggle()!.disabled);
await until(()=>toggle()!.getAttribute('aria-checked')==='true'&&!toggle()!.disabled);
const link=()=>document.querySelector<HTMLAnchorElement>('.cast-share-link a')!;
const shown=()=>document.querySelector('.cast-share-link code')!.textContent;
if(shown()!==link().href||!shown()!.endsWith('?token=test-token'))throw Error('Displayed/open links differ');
phase='copy';document.querySelector<HTMLButtonElement>('[aria-label="复制观看地址"]')!.click();
await wait();
await until(()=>copied===shown()&&!toggle()!.disabled&&document.querySelector('[aria-label="复制观看地址"]')?.textContent?.includes('已复制'));
phase='failed-save';fail=true;toggle()!.click();
await until(()=>document.body.textContent?.includes('Simulated watch-access save failure')&&!toggle()!.disabled);
if(toggle()!.getAttribute('aria-checked')!=='true'||shown()!==copied)throw Error('Failed save changed access state');
phase='running';fail=false;running=true;
await until(()=>document.querySelector('.cast-status.live'));
if(!toggle()||toggle()!.getAttribute('aria-checked')!=='true')throw Error('Running state hid switch');
phase='stopped';running=false;await until(()=>!document.querySelector('.cast-status.live'));
if(!toggle()||toggle()!.disabled)throw Error('Stopping stream hid or disabled switch');
phase='disable';toggle()!.click();await until(()=>toggle()!.getAttribute('aria-checked')==='false'&&!toggle()!.disabled);
if(shown()!.includes('token=')||calls.some(c=>['cast.start','cast.stop','cast.settings.save'].includes(c)))throw Error('Access setting changed capture or retained old token');
console.log('FRAMELY_BRIDGE_PASS');
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}})();
+134 -21
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Shared device-side installation engine, also embedded in the desktop installer."""
import argparse
import errno
import hashlib
import json
import os
@@ -14,6 +15,10 @@ import tempfile
import time
import urllib.parse
import urllib.request
import urllib.error
import socket
import ssl
import http.client
DEFAULT_REPO = 'SteamFramelyHomebrew/framely'
MAX_ARCHIVE = 2 * 1024**3
@@ -33,29 +38,140 @@ class HTTPSRedirect(urllib.request.HTTPRedirectHandler):
return super().redirect_request(req, fp, code, msg, headers, newurl)
def fetch(url):
GITHUB_HOSTS = {'github.com', 'api.github.com', 'raw.githubusercontent.com',
'objects.githubusercontent.com', 'release-assets.githubusercontent.com'}
GITHUB_FALLBACK = 'https://gh-proxy.com/'
def device_proxy_settings():
try:
with open('/var/lib/framely/state.json', encoding='utf-8') as state:
return json.load(state).get('proxy', {})
except (OSError, ValueError):
return {}
def fallback_enabled():
if 'FRAMELY_GITHUB_AUTO_FALLBACK' in os.environ:
return os.environ['FRAMELY_GITHUB_AUTO_FALLBACK'] != '0'
return device_proxy_settings().get('githubAutoFallback', True)
def public_github(url):
parsed = urllib.parse.urlsplit(url)
return (parsed.scheme == 'https' and parsed.hostname in GITHUB_HOSTS
and parsed.port in (None, 443) and not parsed.username and not parsed.password
and not parsed.fragment and all(key in ('page', 'per_page') and value.isascii() and value.isdigit() for key, value in
urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)))
def retryable(error):
if isinstance(error, (ssl.SSLCertVerificationError, ssl.CertificateError)):
return False
if isinstance(error, urllib.error.HTTPError):
return (error.code in (408, 429) or 500 <= error.code <= 599
or error.code == 403 and (error.headers.get('X-RateLimit-Remaining') == '0'
or error.headers.get('Retry-After') is not None))
if isinstance(error, urllib.error.URLError):
return retryable(error.reason)
# These routing failures are plain OSError, not ConnectionError. Keep an
# explicit network-only allowlist so disk/permission failures never retry.
if isinstance(error, OSError) and error.errno in (errno.ENETDOWN, errno.ENETUNREACH, errno.EHOSTUNREACH):
return True
return isinstance(error, (socket.gaierror, TimeoutError, ConnectionError,
http.client.IncompleteRead, http.client.RemoteDisconnected))
def network_diagnostic(error):
# HTTPError and URLError strings may contain signed redirect URLs.
if isinstance(error, urllib.error.HTTPError):
return 'HTTP ' + str(error.code)
if isinstance(error, urllib.error.URLError):
return network_diagnostic(error.reason)
return type(error).__name__
def github_attempt(url, operation, enabled=None):
try:
return operation(False)
except Exception as primary:
if not (fallback_enabled() if enabled is None else enabled) or not public_github(url) or not retryable(primary):
raise
print('GitHub connection failed; trying gh-proxy.com once', file=sys.stderr, flush=True)
try:
return operation(True)
except Exception as secondary:
raise RuntimeError('GitHub primary route failed: ' + network_diagnostic(primary)
+ '; gh-proxy.com fallback failed: ' + network_diagnostic(secondary)) from None
def fetch_once(url, fallback=False):
if urllib.parse.urlparse(url).scheme != 'https':
raise ValueError('HTTPS is required')
return urllib.request.build_opener(HTTPSRedirect()).open(
settings = device_proxy_settings()
manual = os.environ.get('FRAMELY_GITHUB_PROXY', settings.get('github', '') if settings.get('githubEnabled') else '').strip().rstrip('/')
if fallback:
url = GITHUB_FALLBACK + url
elif manual and public_github(url):
if urllib.parse.urlsplit(manual).scheme != 'https':
raise ValueError('GitHub proxy must use HTTPS')
url = manual + '/' + url
handlers = [HTTPSRedirect()]
if 'FRAMELY_HTTP_PROXY' in os.environ or settings.get('httpEnabled'):
proxy = os.environ.get('FRAMELY_HTTP_PROXY', settings.get('http', ''))
handlers.append(urllib.request.ProxyHandler({'http': proxy, 'https': proxy} if proxy else {}))
return urllib.request.build_opener(*handlers).open(
urllib.request.Request(url, headers={'User-Agent': 'Framely-Installer', 'Accept': 'application/vnd.github+json'}), timeout=60)
def download(url, destination, limit, progress=None):
with fetch(url) as response, open(destination, 'xb') as output:
size = 0
reported_at = time.monotonic()
if progress:
progress(0)
while block := response.read(256 * 1024):
size += len(block)
if size > limit:
raise ValueError('Download exceeds size limit')
output.write(block)
if progress and time.monotonic() - reported_at >= 0.2:
def fetch(url):
return github_attempt(url, lambda fallback: fetch_once(url, fallback))
def read_metadata(url, limit):
def attempt(fallback):
with fetch_once(url, fallback) as response:
data = response.read(limit + 1)
if len(data) > limit:
raise ValueError('Release metadata too large')
length = response.headers.get('Content-Length')
if length is not None and len(data) != int(length):
raise http.client.IncompleteRead(b'', max(0, int(length) - len(data)))
return data
return github_attempt(url, attempt)
def download(url, destination, limit, progress=None, expected_size=None):
identity = None
def attempt(fallback):
nonlocal identity
with fetch_once(url, fallback) as response, open(destination, 'r+b' if identity else 'xb') as output:
metadata = os.fstat(output.fileno())
current = (metadata.st_dev, metadata.st_ino)
if identity is not None and identity != current:
raise ValueError('Download temporary file identity changed')
identity = current
if fallback:
output.truncate(0)
size = 0
reported_at = time.monotonic()
if progress:
progress(0)
while block := response.read(256 * 1024):
size += len(block)
if size > limit:
raise ValueError('Download exceeds size limit')
output.write(block)
if progress and time.monotonic() - reported_at >= 0.2 and (expected_size is None or size < expected_size):
progress(size)
reported_at = time.monotonic()
length = response.headers.get('Content-Length')
expected = expected_size if expected_size is not None else int(length) if length is not None else None
if expected is not None and size != expected:
raise http.client.IncompleteRead(b'', max(0, expected - size))
if progress:
progress(size)
reported_at = time.monotonic()
if progress:
progress(size)
return github_attempt(url, attempt)
def verify_archive(archive, checksums):
@@ -274,10 +390,7 @@ def main(argv=None):
parser.error('--archive and --checksums must be provided together')
if not args.archive:
endpoint = 'latest' if not args.version else 'tags/' + urllib.parse.quote(args.version, safe='')
with fetch(f'https://api.github.com/repos/{args.repo}/releases/{endpoint}') as response:
metadata = response.read(4 * 1024**2 + 1)
if len(metadata) > 4 * 1024**2:
raise ValueError('Release metadata too large')
metadata = read_metadata(f'https://api.github.com/repos/{args.repo}/releases/{endpoint}', 4 * 1024**2)
release = json.loads(metadata)
assets = {asset['name']: asset for asset in release['assets']}
package = select_package(assets, args.action)
+11
View File
@@ -0,0 +1,11 @@
import {build} from 'esbuild';
import {mkdir,copyFile} from 'node:fs/promises';
export async function buildCodeEditor(out='ui/dist/assets'){
await mkdir(out,{recursive:true});
await build({entryPoints:['ui/src/file-code-engine.ts'],bundle:true,minify:true,outfile:out+'/code-editor.js',globalName:'FramelyCodeEditor',format:'iife',target:'chrome140',legalComments:'eof'});
// Preserve third-party licenses in the installed assets as well as source.
const packages=['@codemirror/view','@codemirror/state','@codemirror/commands','@codemirror/language','@codemirror/search','@codemirror/lang-javascript','@codemirror/lang-json','@codemirror/lang-css','@codemirror/lang-html','@codemirror/lang-python','@codemirror/lang-rust','@codemirror/autocomplete','@codemirror/lint','@lezer/common','@lezer/highlight','@lezer/lr','@lezer/javascript','@lezer/json','@lezer/css','@lezer/html','@lezer/python','@lezer/rust','style-mod','w3c-keyname','crelt'];
await mkdir(out+'/code-editor-licenses',{recursive:true});
for(const name of packages)await copyFile(`node_modules/${name}/LICENSE`,`${out}/code-editor-licenses/${name.replaceAll('/','-')}.txt`);
}
if(import.meta.url===new URL(process.argv[1],'file://'+process.cwd()+'/').href)await buildCodeEditor(process.argv[2]);
+2
View File
@@ -1,5 +1,7 @@
import {build} from 'esbuild';
import {mkdir,copyFile,cp} from 'node:fs/promises';
import {buildCodeEditor} from './build-code-editor.mjs';
await buildCodeEditor();
await mkdir('ui/dist/assets',{recursive:true});
await build({entryPoints:['ui/src/main.tsx'],bundle:true,minify:true,outfile:'ui/dist/assets/app.js',define:{'process.env.NODE_ENV':'"production"'}});
await build({entryPoints:['sdk/src/bootstrap.ts'],bundle:true,minify:true,outfile:'ui/dist/assets/plugin-bootstrap.js'});
+1 -1
View File
@@ -74,7 +74,7 @@ def prepare(base, store):
def progress(completed):
if os.environ.get('FRAMELY_PROGRESS_JSON') == '1':
print('FRAMELY_PROGRESS ' + json.dumps(dict(step='cef-download', completed=completed, total=requirement['size'])), file=sys.stderr, flush=True)
engine.download(requirement['url'], archive, engine.MAX_ARCHIVE, progress)
engine.download(requirement['url'], archive, engine.MAX_ARCHIVE, progress, expected_size=requirement['size'])
if archive.stat().st_size != requirement['size']:
raise ValueError('CEF archive length mismatch')
with archive.open('rb') as source:
+1 -1
View File
@@ -86,7 +86,7 @@ mkdir -p "$stage/tools/source/assets"
cp -a assets/branding "$stage/tools/source/assets/"
cp -a examples/showcase "$stage/tools/source/examples/"
cp -a ui/src ui/locales ui/index.html ui/package.json "$stage/tools/source/ui/"
cp tools/build-ui.mjs tools/build-branding.mjs tools/plugin-dev.mjs "$stage/tools/source/tools/"
cp tools/build-ui.mjs tools/build-code-editor.mjs tools/build-branding.mjs tools/plugin-dev.mjs "$stage/tools/source/tools/"
cp tools/extract-release.py tools/bootstrap.py tools/cef-runtime.py tools/q2f-runtime.py tools/export-diagnostics.py "$stage/tools/"
find "$stage/tools/source" -type d -name __pycache__ -prune -exec rm -rf -- {} +
cp -a docs "$stage/share/"
+7 -16
View File
@@ -10,7 +10,11 @@ import sys
import tarfile
import tempfile
import urllib.parse
import urllib.request
import importlib.util
_spec = importlib.util.spec_from_file_location('framely_bootstrap', pathlib.Path(__file__).with_name('bootstrap.py'))
_network = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_network)
MAX_ARCHIVE = 512 * 1024 * 1024
MAX_EXPANDED = 1024 * 1024 * 1024
@@ -56,25 +60,12 @@ def validate(root, identity):
raise ValueError('Incomplete FrameBridge libraries: ' + abi)
class HTTPSRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
if urllib.parse.urlparse(newurl).scheme != 'https':
raise ValueError('HTTPS redirect downgrade rejected')
return super().redirect_request(req, fp, code, msg, headers, newurl)
def download(requirement, destination):
url = requirement['url']
if urllib.parse.urlparse(url).scheme != 'https':
raise ValueError('q2f URL must use HTTPS')
request = urllib.request.Request(url, headers={'User-Agent': 'Framely-q2f'})
with urllib.request.build_opener(HTTPSRedirect()).open(request, timeout=90) as response, destination.open('xb') as output:
total = 0
while block := response.read(256 * 1024):
total += len(block)
if total > requirement['size'] or total > MAX_ARCHIVE:
raise ValueError('q2f download exceeds declared size')
output.write(block)
_network.download(url, destination, min(requirement['size'], MAX_ARCHIVE), expected_size=requirement['size'])
total = destination.stat().st_size
if total != requirement['size'] or digest(destination) != requirement['sha256']:
raise ValueError('q2f archive checksum mismatch')
+44
View File
@@ -0,0 +1,44 @@
// Focused CEF check of the real quick streaming tab with a local mock API.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {spawn,execFileSync} from 'node:child_process';
import {build} from 'esbuild';
import {crc32,deflateSync} from 'node:zlib';
// Export the native probe's lossless screenshots without an image dependency.
function png(file){
const ppm=fs.readFileSync(file);let offset=0;
const line=()=>{const end=ppm.indexOf(10,offset),value=ppm.toString('ascii',offset,end);offset=end+1;return value;};
if(line()!=='P6')throw Error('Invalid PPM');const [w,h]=line().split(' ').map(Number);if(line()!=='255')throw Error('Invalid PPM depth');
const pixels=ppm.subarray(offset),stride=w*3;if(pixels.length!==stride*h)throw Error('Invalid PPM size');
const raw=Buffer.alloc((stride+1)*h);for(let y=0;y<h;y++)pixels.copy(raw,y*(stride+1)+1,y*stride,(y+1)*stride);
const chunk=(type,data)=>{const name=Buffer.from(type),size=Buffer.alloc(4),sum=Buffer.alloc(4);size.writeUInt32BE(data.length);sum.writeUInt32BE(crc32(Buffer.concat([name,data])));return Buffer.concat([size,name,data,sum]);};
const header=Buffer.alloc(13);header.writeUInt32BE(w);header.writeUInt32BE(h,4);header[8]=8;header[9]=2;
fs.writeFileSync(file.replace(/\.ppm$/,'.png'),Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',header),chunk('IDAT',deflateSync(raw)),chunk('IEND',Buffer.alloc(0))]));
}
const cef=path.resolve(process.argv[2]??'target/cef');
const framing=process.argv[3]==='framing';
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'framely-cast-quick-'));
await build({entryPoints:[framing?'tests/cast_framing_drag_fixture.tsx':'tests/cast_quick_fixture.tsx'],bundle:true,outfile:dir+'/fixture.js',define:{'process.env.NODE_ENV':'"production"'}});
fs.writeFileSync(dir+'/index.html','<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><div id="root"></div><script src="fixture.js"></script>');
fs.cpSync('ui/locales',dir+'/assets/locales',{recursive:true});
fs.cpSync('assets/branding',dir+'/assets/branding',{recursive:true});
execFileSync('node',['tools/build-scroll-gestures.mjs',dir]);
const binary=dir+'/framely-browser-probe';
execFileSync('g++',['-std=c++17','-O2','-I'+dir,'-I'+cef,'tests/browser_probe.cpp','-L'+cef+'/Release','-lcef','-pthread','-Wl,-rpath,'+cef+'/Release','-o',binary]);
const server=spawn('python3',['-u','-c',`import http.server,functools\ns=http.server.ThreadingHTTPServer(('127.0.0.1',0),functools.partial(http.server.SimpleHTTPRequestHandler,directory=${JSON.stringify(dir)}))\nprint(s.server_port,flush=True)\ns.serve_forever()`],{stdio:['ignore','pipe','ignore']});
try{
const port=await new Promise((resolve,reject)=>{server.stdout.once('data',d=>resolve(Number(d.toString().trim())));server.once('error',reject);});
for(const width of framing?[600]:[600,390]){
const output=dir+'/'+width;fs.mkdirSync(output);
const env={...process.env,FRAMELY_CEF_ROOT:cef,FRAMELY_INSTALL_REVIEW_TEST:'1',FRAMELY_TEST_VIEW_WIDTH:String(width),FRAMELY_PREVIEW_DIR:output,GSETTINGS_BACKEND:'memory'};
const probe=spawn(binary,[`http://127.0.0.1:${port}`,output+'/runtime','--ozone-platform=headless','--disable-gpu'],{env,stdio:['ignore','pipe','pipe']});
let log='';for(const stream of [probe.stdout,probe.stderr])stream.on('data',d=>{log+=d;});
const code=await new Promise((resolve,reject)=>{probe.once('exit',resolve);probe.once('error',reject);});
fs.writeFileSync(output+'/test.log',log);
console.log(width,log.split('\n').filter(l=>l.includes('FRAMELY_')||l.includes('BROWSER_RESULT')).join('\n'));
if(code!==0)throw Error('Quick casting test failed: '+output+'/test.log');
for(const name of fs.readdirSync(output).filter(n=>n.endsWith('.ppm')))png(output+'/'+name);
}
console.log('Quick casting test artifacts: '+dir);
}finally{server.kill();}
+2
View File
@@ -12,7 +12,9 @@ export FRAMELY_INSTALL_REVIEW_FIXTURE="${2:-tests/install_review_fixture.tsx}"
node --input-type=module - <<'JS'
import {build} from 'esbuild';
import {writeFile} from 'node:fs/promises';
import {buildCodeEditor} from './tools/build-code-editor.mjs';
const out=process.env.FRAMELY_INSTALL_REVIEW_OUTPUT;
await buildCodeEditor(`${out}/assets`);
await build({entryPoints:[process.env.FRAMELY_INSTALL_REVIEW_FIXTURE],bundle:true,outfile:`${out}/fixture.js`,define:{'process.env.NODE_ENV':'"production"'}});
await writeFile(`${out}/index.html`,'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="fixture.css"><div id="root"></div><script src="fixture.js"></script></html>');
JS
+28
View File
@@ -0,0 +1,28 @@
// Dedicated real-CEF test; does not start or modify the user's Steam instance.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {spawn,execFileSync} from 'node:child_process';
const cef=path.resolve(process.argv[2]??'target/cef');
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'framely-steam-artwork-'));
const read=n=>fs.readFileSync('src/apk/'+n,'utf8');
const fixture=fs.readFileSync('tests/steam_artwork_browser.js','utf8');
fs.writeFileSync(dir+'/fixture.js',`const selectPalette=(${read('steam_artwork_palette.js')});const renderArtwork=(${read('steam_artwork_render.js')});\n${fixture}`);
fs.writeFileSync(dir+'/index.html','<!doctype html><meta charset="utf-8"><style>html,body{margin:0;background:transparent}#root{position:fixed;inset:0;background:#15161a;border-radius:28px;overflow:hidden;color:white;font:20px sans-serif;padding:40px;box-sizing:border-box}</style><div id="root">Steam artwork renderer test</div><script src="fixture.js"></script>');
execFileSync('node',['tools/build-scroll-gestures.mjs',dir]);
const binary=dir+'/framely-browser-probe';
execFileSync('g++',['-std=c++17','-O2','-I'+dir,'-I'+cef,'tests/browser_probe.cpp','-L'+cef+'/Release','-lcef','-pthread','-Wl,-rpath,'+cef+'/Release','-o',binary]);
fs.cpSync(cef+'/Resources',cef+'/Release',{recursive:true});
const py=`import http.server,functools\ns=http.server.ThreadingHTTPServer(('127.0.0.1',0),functools.partial(http.server.SimpleHTTPRequestHandler,directory=${JSON.stringify(dir)}))\nprint(s.server_port,flush=True)\ns.serve_forever()`;
const server=spawn('python3',['-u','-c',py],{stdio:['ignore','pipe','ignore']});
try{
const port=await new Promise((resolve,reject)=>{server.stdout.once('data',d=>resolve(Number(d.toString().trim())));server.once('error',reject);server.once('exit',c=>reject(Error('Preview server exited '+c)));});
const env={...process.env,FRAMELY_CEF_ROOT:cef,FRAMELY_INSTALL_REVIEW_TEST:'1',FRAMELY_STORE_TEST:'1',FRAMELY_PREVIEW_DIR:dir,GSETTINGS_BACKEND:'memory'};
const probe=spawn(binary,[`http://127.0.0.1:${port}`,dir+'/runtime','--ozone-platform=headless','--disable-gpu'],{env,stdio:['ignore','pipe','pipe']});
let log='';for(const stream of [probe.stdout,probe.stderr])stream.on('data',d=>{log+=d;});
const code=await new Promise((resolve,reject)=>{probe.once('exit',resolve);probe.once('error',reject);});
fs.writeFileSync(dir+'/test.log',log);
console.log(log.split('\n').filter(l=>l.includes('FRAMELY_')||l.includes('BROWSER_RESULT')).join('\n'));
if(code!==0)throw Error('CEF artwork test failed: '+dir+'/test.log');
console.log('CEF artwork test artifacts: '+dir);
}finally{server.kill();}
+60
View File
@@ -3,7 +3,67 @@
"locale": "en-US",
"name": "English",
"messages": {
"以文本编辑": "Edit as text",
"文本编辑器": "Text editor",
"纯文本": "Plain text",
"语言模式": "Language mode",
"可编辑": "Editable",
"行 {0},列 {1}": "Ln {0}, Col {1}",
"选中 {0}": "Selected {0}",
"缩进:{0} 个空格": "Indent: {0} spaces",
"普通文件": "Regular file",
"此文件包含二进制内容或不是 UTF-8,仅以文本预览,不允许保存以避免损坏原文件。": "This file contains binary data or is not UTF-8. Text preview is read-only to protect the original file.",
"完整路径": "Full path",
"文件类型": "File type",
"符号链接": "Symbolic link",
"未知": "Unknown",
"Inode": "Inode",
"特殊文件": "Special file",
"目录自身大小(不含内容)": "Directory entry size (excludes contents)",
"磁盘占用": "Allocated space",
"所有者": "Owner",
"所属组": "Group",
"访问时间": "Accessed",
"状态变更时间": "Metadata changed",
"创建时间": "Created",
"设备编号": "Device ID",
"硬链接数": "Hard links",
"链接目标": "Link target",
"解析后的路径": "Resolved path",
"访问权限": "Permissions",
"其他用户": "Others",
"读取": "Read",
"写入": "Write",
"执行/进入": "Execute / enter",
"保存权限": "Save permissions",
"正在读取属性…": "Reading properties…",
"请输入 000 至 777 的八进制权限。": "Enter an octal mode from 000 to 777.",
"符号链接权限不可修改;此处显示链接本身的属性。": "Link permissions cannot be changed. These properties describe the link itself.",
"当前用户不是文件所有者,无法修改权限。": "The current user does not own this file and cannot change its permissions.",
"现有特殊权限位会保留,本面板仅修改读、写、执行权限。": "Existing special bits are preserved. Only read, write and execute permissions are changed.",
"File changed outside the properties panel; reopen properties": "The file changed outside this panel. Reopen properties.",
"不可用": "Unavailable",
"撤销": "Undo",
"重做": "Redo",
"查找替换": "Find and replace",
"自动换行": "Word wrap",
"正在加载代码编辑器…": "Loading code editor…",
"代码编辑器加载失败,可以使用普通文本编辑。": "The code editor could not load. You can use the plain text editor.",
"GitHub 连接失败时自动使用 gh-proxy.com": "Automatically try gh-proxy.com when GitHub cannot be reached",
"公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。": "After a network failure, public GitHub requests go through a third-party proxy once. Download verification remains enabled.",
"显示录制指示器": "Show recording indicator",
"关闭后仅隐藏右上角录制提示,不会停止录制。": "Hides only the recording indicator in the upper-right corner; recording continues.",
"安装失败,本次新建的容器已清理:{0}": "Installation failed; the newly created container was cleaned up: {0}",
"安装失败,安全清理未完成,容器已保留:{0}": "Installation failed; safe cleanup failed, so the container was retained: {0}",
"更新失败,原容器和数据已保留。备份:{0}。详情:{1}": "Update failed; the existing container and data were retained. Backup: {0}. Details: {1}",
"清理失败安装": "Cleaning up failed installation",
"清理损坏容器": "Clean damaged container",
"容器状态无法读取,仍可能包含应用数据。确认后永久删除此容器、关联 APK 缓存和 Steam 库条目,历史备份保留。": "The container cannot be read and may still contain application data. Confirmation permanently deletes this container, associated APK cache and Steam entries. Historical backups are retained.",
"APK could not be read; reconcile before deleting data": "APK could not be read; reconcile before deleting data",
"This container cannot be read; inspect warnings before reconciliation": "This container cannot be read; inspect warnings before reconciliation",
"APK state is unknown; reconcile before changing the application": "APK state is unknown; reconcile before changing the application",
"重置大小": "Reset size",
"拖动调整录制位置": "Drag to move recording area",
"录制眼已改变,请重新打开画幅调整": "The recording eye has changed. Reopen frame adjustment.",
"保留比例和分辨率,恢复居中有效画幅": "Keep the aspect ratio and resolution, and restore the centered valid frame.",
"菜单尺寸超过当前可录制范围": "The menu is larger than the available recording area.",
+60
View File
@@ -3,7 +3,67 @@
"locale": "zh-CN",
"name": "简体中文",
"messages": {
"以文本编辑": "以文本编辑",
"文本编辑器": "文本编辑器",
"纯文本": "纯文本",
"语言模式": "语言模式",
"可编辑": "可编辑",
"行 {0},列 {1}": "行 {0},列 {1}",
"选中 {0}": "选中 {0}",
"缩进:{0} 个空格": "缩进:{0} 个空格",
"普通文件": "普通文件",
"此文件包含二进制内容或不是 UTF-8,仅以文本预览,不允许保存以避免损坏原文件。": "此文件包含二进制内容或不是 UTF-8,仅以文本预览,不允许保存以避免损坏原文件。",
"完整路径": "完整路径",
"文件类型": "文件类型",
"符号链接": "符号链接",
"未知": "未知",
"Inode": "Inode",
"特殊文件": "特殊文件",
"目录自身大小(不含内容)": "目录自身大小(不含内容)",
"磁盘占用": "磁盘占用",
"所有者": "所有者",
"所属组": "所属组",
"访问时间": "访问时间",
"状态变更时间": "状态变更时间",
"创建时间": "创建时间",
"设备编号": "设备编号",
"硬链接数": "硬链接数",
"链接目标": "链接目标",
"解析后的路径": "解析后的路径",
"访问权限": "访问权限",
"其他用户": "其他用户",
"读取": "读取",
"写入": "写入",
"执行/进入": "执行/进入",
"保存权限": "保存权限",
"正在读取属性…": "正在读取属性…",
"请输入 000 至 777 的八进制权限。": "请输入 000 至 777 的八进制权限。",
"符号链接权限不可修改;此处显示链接本身的属性。": "符号链接权限不可修改;此处显示链接本身的属性。",
"当前用户不是文件所有者,无法修改权限。": "当前用户不是文件所有者,无法修改权限。",
"现有特殊权限位会保留,本面板仅修改读、写、执行权限。": "现有特殊权限位会保留,本面板仅修改读、写、执行权限。",
"File changed outside the properties panel; reopen properties": "文件已在属性面板外发生变化,请重新打开属性。",
"不可用": "不可用",
"撤销": "撤销",
"重做": "重做",
"查找替换": "查找替换",
"自动换行": "自动换行",
"正在加载代码编辑器…": "正在加载代码编辑器…",
"代码编辑器加载失败,可以使用普通文本编辑。": "代码编辑器加载失败,可以使用普通文本编辑。",
"GitHub 连接失败时自动使用 gh-proxy.com": "GitHub 连接失败时自动使用 gh-proxy.com",
"公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。": "公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。",
"显示录制指示器": "显示录制指示器",
"关闭后仅隐藏右上角录制提示,不会停止录制。": "关闭后仅隐藏右上角录制提示,不会停止录制。",
"安装失败,本次新建的容器已清理:{0}": "安装失败,本次新建的容器已清理:{0}",
"安装失败,安全清理未完成,容器已保留:{0}": "安装失败,安全清理未完成,容器已保留:{0}",
"更新失败,原容器和数据已保留。备份:{0}。详情:{1}": "更新失败,原容器和数据已保留。备份:{0}。详情:{1}",
"清理失败安装": "清理失败安装",
"清理损坏容器": "清理损坏容器",
"容器状态无法读取,仍可能包含应用数据。确认后永久删除此容器、关联 APK 缓存和 Steam 库条目,历史备份保留。": "容器状态无法读取,仍可能包含应用数据。确认后永久删除此容器、关联 APK 缓存和 Steam 库条目,历史备份保留。",
"APK could not be read; reconcile before deleting data": "APK 无法读取;删除数据前请核对实际安装状态,或确认清理损坏容器。",
"This container cannot be read; inspect warnings before reconciliation": "此容器无法读取;请先查看警告和日志,或确认清理损坏容器。",
"APK state is unknown; reconcile before changing the application": "APK 状态未知;请先核对实际安装状态,或确认清理损坏容器。",
"重置大小": "重置大小",
"拖动调整录制位置": "拖动调整录制位置",
"录制眼已改变,请重新打开画幅调整": "录制眼已改变,请重新打开画幅调整",
"保留比例和分辨率,恢复居中有效画幅": "保留比例和分辨率,恢复居中有效画幅",
"菜单尺寸超过当前可录制范围": "菜单尺寸超过当前可录制范围",
+13
View File
@@ -0,0 +1,13 @@
import {t} from './i18n';
// Keep the original diagnostic chain (including paths) below a localized outcome.
export function apkError(error:unknown):string {
const text=String(error).replace(/^Error: /,'');
for(const [prefix,key] of [
['New installation failed; newly created container was cleaned up: ','安装失败,本次新建的容器已清理:{0}'],
['Installation failed; container retained because safe cleanup failed: ','安装失败,安全清理未完成,容器已保留:{0}'],
] as const) if(text.startsWith(prefix))return t(key,{0:text.slice(prefix.length)});
const update=text.match(/^Installation failed; existing container and data retained \(backup: (.*?)\): ([\s\S]*)$/);
if(update)return t('更新失败,原容器和数据已保留。备份:{0}。详情:{1}',{0:update[1],1:update[2]});
return t(text);
}
+13 -12
View File
@@ -1,4 +1,5 @@
import React,{useEffect,useRef,useState} from 'react';
import {apkError} from './apk-errors';
import {createPortal} from 'react-dom';
import {IconBrandAndroid,IconDownload,IconRefresh,IconTrash,IconPlayerPlay,IconPlayerStop,IconFolder,IconFileText,IconX} from '@tabler/icons-react';
import {api,uploadChunk} from './api';
@@ -21,7 +22,7 @@ type Review={ticket:string;metadata:Metadata;bytes:number;q2fVersion?:string};
type Q2fStatus={id:string;bytes:number;cached:boolean};
type Confirm={operation:string;app?:App;container?:Container;backup?:string;items?:string[];title:string;description:string;purge?:boolean;deleteContainer?:boolean};
function bytes(n:number){return n<1024?`${n} B`:n<1024*1024?`${(n/1024).toFixed(1)} KiB`:n<1024**3?`${(n/1024**2).toFixed(1)} MiB`:`${(n/1024**3).toFixed(2)} GiB`;}
const phases:Record<string,string>={loading:'正在读取',preparing:'准备中',uploading:'上传 APK',inspecting:'校验 APK',starting:'启动容器',launching:'启动应用',started:'应用已启动,正在检查',installing:'安装 APK',verifying:'核对安装结果','backing-up':'备份容器数据',uninstalling:'卸载应用','q2f-preparing':'准备 Quest 转换工具','q2f-converting':'正在转换 APK','q2f-verifying':'校验转换结果',done:'完成',failed:'失败',cancelled:'操作已取消'};
const phases:Record<string,string>={'rolling-back':'清理失败安装',loading:'正在读取',preparing:'准备中',uploading:'上传 APK',inspecting:'校验 APK',starting:'启动容器',launching:'启动应用',started:'应用已启动,正在检查',installing:'安装 APK',verifying:'核对安装结果','backing-up':'备份容器数据',uninstalling:'卸载应用','q2f-preparing':'准备 Quest 转换工具','q2f-converting':'正在转换 APK','q2f-verifying':'校验转换结果',done:'完成',failed:'失败',cancelled:'操作已取消'};
const operationLabels:Record<string,string>={'steam.settings':'保存 Steam 启动设置','steam.retention.settings':'保存 Steam 入口设置','lifecycle.settings':'保存容器退出设置','gamepad.settings':'保存手柄设置',install:'安装 APK',launch:'启动应用',close:'关闭应用',uninstall:'卸载应用',purge:'清除保留数据',clear:'清除应用数据',reconcile:'核对应用状态',settings:'保存设置','container.start':'启动容器','container.stop':'停止容器','container.delete':'删除容器',restore:'恢复备份',cleanup:'数据清理','root.add':'添加数据位置','root.remove':'移除数据位置'};
function SteamNameSetting({app,busy,onSave}:{app:App;busy:boolean;onSave:(name:string)=>void}){
const [name,setName]=useState(app.steamName??'');
@@ -38,27 +39,27 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
useEffect(()=>{if(!review||review.q2fVersion){setQ2f(null);setQ2fError('');return;}let active=true;setQ2f(null);setQ2fError('');void api<Q2fStatus>('apk.q2f.status').then(value=>{if(active)setQ2f(value);}).catch(e=>{if(active)setQ2fError(String(e));});return()=>{active=false;};},[review?.ticket]);
const input=useRef<HTMLInputElement>(null),controller=useRef<AbortController|null>(null),selection=useRef<{app:string;uninstall:boolean}|null>(null),alive=useRef(true),busyRef=useRef(false);
const updateJob=(v:Job)=>{if(alive.current)setJob(v);};
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');let saved:{job:string;app:App|null;context:string;review?:Review;displayMode?:string}|null=null;try{saved=JSON.parse(sessionStorage.getItem('framely.apk.q2f.job')||'null');}catch{}const id=active.job||saved?.job;if(!id||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean;operation?:string}>('job.status',{job:id});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(saved?.job===id)sessionStorage.removeItem('framely.apk.q2f.job');if(value.phase==='failed'||value.phase==='cancelled'){if(saved?.review){setReview(saved.review);setQuestEnabled(true);setDisplayMode(saved.displayMode??'vr');setUpdating(saved.app);setTarget(saved.context);}setError(t(value.phase==='cancelled'?'操作已取消':value.error??'操作失败'));}else if(value.result?.q2fVersion){setReview(value.result);setOriginalReview(saved?.review??null);setQuestEnabled(true);setDisplayMode(saved?.displayMode??'vr');setUpdating(saved?.app??null);setTarget(saved?.context??'');if(saved?.review)await perform('install',{ticket:value.result.ticket,context:saved.context,app:saved.app?.id,showWindow:saved.displayMode==='flat',approve:true},saved.review.ticket);else setNotice(t('转换完成,请确认安装。'));}else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(String(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}}
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');let saved:{job:string;app:App|null;context:string;review?:Review;displayMode?:string}|null=null;try{saved=JSON.parse(sessionStorage.getItem('framely.apk.q2f.job')||'null');}catch{}const id=active.job||saved?.job;if(!id||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean;operation?:string}>('job.status',{job:id});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(saved?.job===id)sessionStorage.removeItem('framely.apk.q2f.job');if(value.phase==='failed'||value.phase==='cancelled'){if(saved?.review){setReview(saved.review);setQuestEnabled(true);setDisplayMode(saved.displayMode??'vr');setUpdating(saved.app);setTarget(saved.context);}setError(apkError(value.phase==='cancelled'?'操作已取消':value.error??'操作失败'));}else if(value.result?.q2fVersion){setReview(value.result);setOriginalReview(saved?.review??null);setQuestEnabled(true);setDisplayMode(saved?.displayMode??'vr');setUpdating(saved?.app??null);setTarget(saved?.context??'');if(saved?.review)await perform('install',{ticket:value.result.ticket,context:saved.context,app:saved.app?.id,showWindow:saved.displayMode==='flat',approve:true},saved.review.ticket);else setNotice(t('转换完成,请确认安装。'));}else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(apkError(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}}
useEffect(()=>{if(data.retainSteamEntries===false||!data.apps.some(a=>a.installed&&!a.steam&&a.stateKnown!==false&&!a.pending&&(a.metadata.activities.length||a.activity)&&(a.steamLaunch??true)&&!a.steamRegistered))return;const timer=setInterval(()=>{if(!busyRef.current)void refresh();},20000);return()=>clearInterval(timer);},[data.apps,data.retainSteamEntries]);
async function refresh(){setLoading(true);try{const r=await runJob('apk.list.start');if(alive.current){setData(r.result);setDetails(old=>old?r.result.apps.find((a:App)=>a.id===old.id)??null:null);}}catch(e){if(alive.current)setError(String(e));}finally{if(alive.current)setLoading(false);}}
async function refresh(){setLoading(true);try{const r=await runJob('apk.list.start');if(alive.current){setData(r.result);setDetails(old=>old?r.result.apps.find((a:App)=>a.id===old.id)??null:null);}}catch(e){if(alive.current)setError(apkError(e));}finally{if(alive.current)setLoading(false);}}
useEffect(()=>{alive.current=true;void refresh();void resumeOperation();const select=(e:Event)=>{const v=(e as CustomEvent).detail;if(v?.kind==='apk.selection'){selection.current={app:v.app,uninstall:!!v.uninstall};void refresh();}};window.addEventListener('framely.event',select);return()=>{alive.current=false;window.removeEventListener('framely.event',select);controller.current?.abort();};},[]);
useEffect(()=>{if(selection.current){const a=data.apps.find(a=>a.id===selection.current!.app);if(a){if(a.steam){setError(t('请通过 Steam 管理此应用。'));selection.current=null;return;}if(selection.current.uninstall)askUninstall(a);else setDetails(a);selection.current=null;}}},[data]);
useEffect(()=>{const close=(e:Event)=>{if(e instanceof KeyboardEvent&&e.key!=='Escape')return;if(busyRef.current)return;if(confirm)setConfirm(null);else if(log)setLog(null);else if(review)void discard();else setDetails(null);};window.addEventListener('keydown',close);window.addEventListener('framely.back',close);return()=>{window.removeEventListener('keydown',close);window.removeEventListener('framely.back',close);};},[confirm,log,review]);
useEffect(()=>{const dialog=document.querySelector<HTMLElement>('.apk-dialog');if(!dialog)return;const prev=document.activeElement as HTMLElement;const controls=()=>Array.from(dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input,select,a[href]')).filter(e=>e.getClientRects().length);controls()[0]?.focus();const key=(e:KeyboardEvent)=>{if(e.key!=='Tab')return;const a=controls();if(!a.length)return;if(e.shiftKey&&(document.activeElement===a[0]||!dialog.contains(document.activeElement))){e.preventDefault();a.at(-1)?.focus();}else if(!e.shiftKey&&(document.activeElement===a.at(-1)||!dialog.contains(document.activeElement))){e.preventDefault();a[0].focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);prev?.focus();};},[review,details,confirm,log,job?.id]);
async function discard(){if(review)await api('apk.review.drop',{ticket:review.ticket}).catch(()=>{});if(originalReview)await api('apk.review.drop',{ticket:originalReview.ticket}).catch(()=>{});setReview(null);setOriginalReview(null);setQuestEnabled(false);setUpdating(null);}
async function installReviewed(){if(!review)return;const original=review;if(!questEnabled||review.q2fVersion){await perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true});return;}setBusy(true);busyRef.current=true;setError('');setNotice('');try{const started=await api<{job:string}>('apk.q2f.start',{ticket:original.ticket});sessionStorage.setItem('framely.apk.q2f.job',JSON.stringify({job:started.job,app:updating,context:target,review:original,displayMode}));const result=await waitJob(started.job,updateJob);sessionStorage.removeItem('framely.apk.q2f.job');const converted=result.result as Review;setOriginalReview(original);setReview(converted);await perform('install',{ticket:converted.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true},original.ticket);}catch(e){sessionStorage.removeItem('framely.apk.q2f.job');setError(String(e));}finally{setJob(null);setBusy(false);busyRef.current=false;}}
async function revertConversion(){if(!review?.q2fVersion||!originalReview)return;setBusy(true);try{await api('apk.review.drop',{ticket:review.ticket});setReview(originalReview);setOriginalReview(null);setQuestEnabled(false);setDisplayMode((updating?.showWindow??!originalReview.metadata.vr)?'flat':'vr');setError('');}catch(e){setError(String(e));}finally{setBusy(false);}}
async function inspect(file:File|string,selectedApp:App|null=updating){if(!(typeof file==='string'?file:file.name).toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob(typeof file==='string'?{id:'local',kind:'apk',phase:'inspecting',received:0,cancellable:false}:{id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{let result;if(typeof file==='string'){const response=await fetch('/host/files/apk-inspect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[file]}),signal:c.signal});if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));const data=await response.json();if(data.error)throw new Error(t(data.error));result=await waitJob(data.result.job,updateJob);}else{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});result=await runJob('apk.inspect.start',{upload},updateJob);}if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setOriginalReview(null);setQuestEnabled(false);setReview(result.result);setDisplayMode((selectedApp?.showWindow??!result.result.metadata.vr)?'flat':'vr');setTarget(selectedApp?.context??'');setJob(null);}catch(e){setJob(null);setError(String(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}}
async function installReviewed(){if(!review)return;const original=review;if(!questEnabled||review.q2fVersion){await perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true});return;}setBusy(true);busyRef.current=true;setError('');setNotice('');try{const started=await api<{job:string}>('apk.q2f.start',{ticket:original.ticket});sessionStorage.setItem('framely.apk.q2f.job',JSON.stringify({job:started.job,app:updating,context:target,review:original,displayMode}));const result=await waitJob(started.job,updateJob);sessionStorage.removeItem('framely.apk.q2f.job');const converted=result.result as Review;setOriginalReview(original);setReview(converted);await perform('install',{ticket:converted.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true},original.ticket);}catch(e){sessionStorage.removeItem('framely.apk.q2f.job');setError(apkError(e));}finally{setJob(null);setBusy(false);busyRef.current=false;}}
async function revertConversion(){if(!review?.q2fVersion||!originalReview)return;setBusy(true);try{await api('apk.review.drop',{ticket:review.ticket});setReview(originalReview);setOriginalReview(null);setQuestEnabled(false);setDisplayMode((updating?.showWindow??!originalReview.metadata.vr)?'flat':'vr');setError('');}catch(e){setError(apkError(e));}finally{setBusy(false);}}
async function inspect(file:File|string,selectedApp:App|null=updating){if(!(typeof file==='string'?file:file.name).toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob(typeof file==='string'?{id:'local',kind:'apk',phase:'inspecting',received:0,cancellable:false}:{id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{let result;if(typeof file==='string'){const response=await fetch('/host/files/apk-inspect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[file]}),signal:c.signal});if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));const data=await response.json();if(data.error)throw new Error(t(data.error));result=await waitJob(data.result.job,updateJob);}else{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});result=await runJob('apk.inspect.start',{upload},updateJob);}if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setOriginalReview(null);setQuestEnabled(false);setReview(result.result);setDisplayMode((selectedApp?.showWindow??!result.result.metadata.vr)?'flat':'vr');setTarget(selectedApp?.context??'');setJob(null);}catch(e){setJob(null);setError(apkError(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}}
function choose(a?:App){setUpdating(a??null);if(input.current)openLocalFileInput(input.current,path=>{if(alive.current)void inspect(path,a??null);});}
async function perform(operation:string,params:Record<string,unknown>={},originalTicket=originalReview?.ticket){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){if(originalTicket)await api('apk.review.drop',{ticket:originalTicket}).catch(()=>{});setOriginalReview(null);setQuestEnabled(false);setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(String(e));await refresh();}finally{setBusy(false);busyRef.current=false;}}
async function perform(operation:string,params:Record<string,unknown>={},originalTicket=originalReview?.ticket){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){if(originalTicket)await api('apk.review.drop',{ticket:originalTicket}).catch(()=>{});setOriginalReview(null);setQuestEnabled(false);setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(apkError(e));await refresh();}finally{setBusy(false);busyRef.current=false;}}
function canDeleteContainer(a:App){const members=data.apps.filter(other=>other.context===a.context);const container=data.containers.find(c=>c.id===a.context);return !!container&&!container.steam&&members.length===1&&members[0].id===a.id&&a.stateKnown!==false&&!a.pending;}
function askUninstall(a:App){setError('');setConfirm({operation:a.installed?'uninstall':'purge',app:a,title:a.installed?t('卸载应用'):t('清除保留数据'),description:'',purge:!a.installed,deleteContainer:!a.installed&&canDeleteContainer(a)});}
async function loadCleanup(){try{const items=await api<Cleanup[]>('apk.cleanup.list');setCleanup(items);setSelected([]);}catch(e){setError(String(e));}}
async function showLog(a:App|string){try{setLog(await api('apk.logs',{app:typeof a==='string'?a:a.id}));}catch(e){setError(String(e));}}
async function loadCleanup(){try{const items=await api<Cleanup[]>('apk.cleanup.list');setCleanup(items);setSelected([]);}catch(e){setError(apkError(e));}}
async function showLog(a:App|string){try{setLog(await api('apk.logs',{app:typeof a==='string'?a:a.id}));}catch(e){setError(apkError(e));}}
function downloadLog(){if(!log)return;const url=URL.createObjectURL(new Blob([log.text],{type:'text/plain;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download=log.name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
const listed=data.apps.filter(a=>(filter==='all'||(filter==='installed'?(a.installed||a.stateKnown===false):(!a.installed&&a.stateKnown!==false)))&&!a.steam&&`${a.metadata.name} ${a.metadata.package} ${a.context}`.toLowerCase().includes(query.toLowerCase()));
const contextFor=(id:string)=>data.containers.find(c=>c.id===id);
function containerAsk(c:Container,operation:string){setConfirm({operation,container:c,title:t(operation==='container.delete'?'删除容器':operation==='container.stop'?'停止容器':'启动容器'),description:`${t('此操作影响以下应用:')} ${data.apps.filter(a=>a.context===c.id).map(a=>a.metadata.name).join(', ')||c.name}${operation==='container.delete'?`\n${t('容器数据、关联 APK 缓存和 Steam 库条目将永久删除,历史备份保留。')}`:''}`});}
function containerAsk(c:Container,operation:string){setConfirm({operation,container:c,title:t(operation==='container.delete'?'删除容器':operation==='container.stop'?'停止容器':'启动容器'),description:`${t('此操作影响以下应用:')} ${data.apps.filter(a=>a.context===c.id).map(a=>a.metadata.name).join(', ')||c.name}${operation==='container.delete'?`\n${t(data.apps.some(a=>a.context===c.id&&a.stateKnown===false)?'容器状态无法读取,仍可能包含应用数据。确认后永久删除此容器、关联 APK 缓存和 Steam 库条目,历史备份保留。':'容器数据、关联 APK 缓存和 Steam 库条目将永久删除,历史备份保留。')}`:''}`});}
const containerTitle=(c:Container)=>data.apps.filter(a=>a.context===c.id&&a.installed).map(a=>a.metadata.name).join(', ')||t('空容器');
const installContainer=review?contextFor(target||updating?.context||data.apps.find(a=>!a.steam&&a.metadata.package===review.metadata.package)?.context||''):undefined;
const deletesData=!!confirm&&(['purge','clear','cleanup','container.delete'].includes(confirm.operation)||(confirm.operation==='uninstall'&&!!confirm.purge));
@@ -74,11 +75,11 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
{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>{details.installed&&details.q2fVersion&&<><dt>{t('Quest 转换')}</dt><dd><code>{details.q2fVersion}</code><small>{t('要使用新版转换运行包,请重新选择原始 Quest APK 安装。')}</small></dd></>}</dl>{details.installed&&details.stateKnown!==false&&!details.steam&&<section className="apk-steam-names"><h3>{t('自定义 Steam 名称')}</h3><p>{t('分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。')}</p><SteamNameSetting app={details} busy={busy} onSave={steamName=>void perform('settings',{app:details.id,steamName})}/></section>}{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('管理应用'))}
{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>{details.installed&&details.q2fVersion&&<><dt>{t('Quest 转换')}</dt><dd><code>{details.q2fVersion}</code><small>{t('要使用新版转换运行包,请重新选择原始 Quest APK 安装。')}</small></dd></>}</dl>{details.installed&&details.stateKnown!==false&&!details.steam&&<section className="apk-steam-names"><h3>{t('自定义 Steam 名称')}</h3><p>{t('分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。')}</p><SteamNameSetting app={details} busy={busy} onSave={steamName=>void perform('settings',{app:details.id,steamName})}/></section>}{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.stateKnown===false&&contextFor(details.context)?.managed&&!contextFor(details.context)?.steam&&<button className="danger" disabled={busy} onClick={()=>containerAsk(contextFor(details.context)!,'container.delete')}>{t('清理损坏容器')}</button>}{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('自动选择')},.Line truncated
{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} converted={!!review.q2fVersion} questEnabled={questEnabled} q2f={q2f?{cached:q2f.cached,size:bytes(q2f.bytes)}:undefined} q2fError={q2fError} onStop={()=>installContainer&&containerAsk(installContainer,'container.stop')} onCancel={()=>void discard()} onQuestChange={checked=>{if(!checked&&review.q2fVersion)void revertConversion();else setQuestEnabled(checked);}} onConfirm={()=>void installReviewed()}/>,t('安装 APK'))}
{confirm?.app&&!job&&['uninstall','purge'].includes(confirm.operation)&&dialog(<ApkUninstall app={confirm.app} size={bytes(confirm.app.size)} purge={!!confirm.purge} deleteContainer={!!confirm.deleteContainer&&!!confirm.purge&&canDeleteContainer(confirm.app)} exclusive={canDeleteContainer(confirm.app)} busy={busy} error={error} onPurge={purge=>setConfirm(c=>c?.app?{...c,purge,deleteContainer:purge&&canDeleteContainer(c.app)}:c)} onDeleteContainer={deleteContainer=>setConfirm(c=>c?{...c,deleteContainer}:c)} onCancel={()=>setConfirm(null)} onConfirm={()=>void perform(confirm.operation,{app:confirm.app!.id,purge:confirm.purge,deleteContainer:!!confirm.deleteContainer&&!!confirm.purge&&canDeleteContainer(confirm.app!),approve:true})}/>,confirm.title)}
{confirm&&!job&&!(confirm.app&&['uninstall','purge'].includes(confirm.operation))&&dialog(<><h2>{confirm.title}</h2><p className="apk-confirm-copy">{confirm.description}</p>{confirm.app&&<p><b>{confirm.app.metadata.name}</b><br/><code>{confirm.app.metadata.package}</code></p>}{deletesData&&<p>{t('永久删除的数据无法撤销。')}</p>}{error&&<p className="error">{error}</p>}<footer><button disabled={busy} onClick={()=>setConfirm(null)}>{t('取消')}</button><button className={deletesData||confirm.operation==='uninstall'?'danger':'primary'} disabled={busy} onClick={()=>void perform(confirm.operation,{app:confirm.app?.id,context:confirm.container?.id,backup:confirm.backup,items:confirm.items,purge:confirm.purge,approve:true})}>{t('确认')}</button></footer></>,confirm.title)}
{job&&dialog(<><h2>{t(job.phase==='preparing'&&job.operation?operationLabels[job.operation]??phases[job.phase]:phases[job.phase]??job.phase)}</h2><p>{t('操作进行中,请等待实际结果。')}</p><progress value={job.phase==='uploading'?job.received:undefined} max={job.phase==='uploading'?job.total:undefined}/>{job.phase==='uploading'&&<p>{bytes(job.received)} / {bytes(job.total??0)}</p>}{job.cancellable===false?<p>{t('操作已开始,请等待完成;此阶段无法取消。')}</p>:<button onClick={()=>{if(job.id==='upload')controller.current?.abort();else void api('job.cancel',{job:job.id}).catch(e=>setError(String(e)));}}>{t('取消')}</button>}</>,t('APK 操作进度'))}
{job&&dialog(<><h2>{t(job.phase==='preparing'&&job.operation?operationLabels[job.operation]??phases[job.phase]:phases[job.phase]??job.phase)}</h2><p>{t('操作进行中,请等待实际结果。')}</p><progress value={job.phase==='uploading'?job.received:undefined} max={job.phase==='uploading'?job.total:undefined}/>{job.phase==='uploading'&&<p>{bytes(job.received)} / {bytes(job.total??0)}</p>}{job.cancellable===false?<p>{t('操作已开始,请等待完成;此阶段无法取消。')}</p>:<button onClick={()=>{if(job.id==='upload')controller.current?.abort();else void api('job.cancel',{job:job.id}).catch(e=>setError(apkError(e)));}}>{t('取消')}</button>}</>,t('APK 操作进度'))}
{log&&!job&&dialog(<><h2>{t('APK 日志')}</h2><pre>{log.text||t('暂无日志')}</pre><footer><button onClick={()=>setLog(null)}>{t('关闭')}</button><button onClick={downloadLog}>{t('导出日志')}</button></footer></>,t('APK 日志'))}
</section>;
}
+13 -4
View File
@@ -8,9 +8,10 @@ import {t} from './i18n';
import {FrameGesture,fitMinimumFrame,minimumFrameWidth,resizeFrameCentered,type MinimumFrame} from './cast-framing-gesture';
import type {FrameLimiter} from './cast-region';
export function FramingEditor({settings,view,onChange,onError,spatial=false,minimumSize={width:0,height:0},limit}:{settings:CastSettings;view:ReferenceView|null;onChange:(patch:Partial<CastSettings>)=>void;onError:(error:string)=>void;spatial?:boolean;minimumSize?:MinimumFrame;limit?:FrameLimiter}){
export function FramingEditor({settings,view,onChange,onError,spatial=false,minimumSize={width:0,height:0},limit,moveHandle}:{settings:CastSettings;view:ReferenceView|null;onChange:(patch:Partial<CastSettings>)=>void;onError:(error:string)=>void;spatial?:boolean;minimumSize?:MinimumFrame;limit?:FrameLimiter;moveHandle?:React.RefObject<HTMLElement|null>}){
const stage=useRef<HTMLDivElement>(null),box=useRef<HTMLDivElement>(null),zoom=useRef<HTMLInputElement>(null),readout=useRef<HTMLOutputElement>(null);
const current=useRef<FrameRect>({x:0,y:0,width:1,height:1}),gesture=useRef(new FrameGesture()),pointer=useRef<number|null>(null),release=useRef<(id?:number)=>void>(()=>{});
const capture=useRef<HTMLElement|null>(null),external=useRef({down:(_e:PointerEvent)=>{},move:(_e:PointerEvent)=>{}});
const aspect=settings.width/settings.height;
const ratio=aspect/(view?.aspect??1),maxWidth=Math.min(1,ratio),minWidth=minimumFrameWidth(ratio,minimumSize,view?Math.tan(5*Math.PI/180)/view.tanHalfHorizontal:.1);
function draw(rect:FrameRect){
@@ -23,12 +24,20 @@ export function FramingEditor({settings,view,onChange,onError,spatial=false,mini
function commit(){if(view&&box.current?.style.visibility!=='hidden')onChange(settingsFromFrame(current.current,view));}
useEffect(()=>{if(view){const old=frameFromSettings(settings,aspect,view),next=draw(fitMinimumFrame(old,ratio,minWidth));if(next&&(Math.abs(next.width-old.width)>1e-9||Math.abs(next.x-old.x)>1e-9||Math.abs(next.y-old.y)>1e-9))commit();}},[view?.tanHalfHorizontal,view?.centerTanX,view?.centerTanY,aspect,settings.horizontalFov,settings.centerX,settings.centerY,minWidth]);
function scale(width:number){if(view)draw(resizeFrameCentered(current.current,width,ratio,minWidth));}
function finish(id?:number){if(!gesture.current.finish(id))return;const captured=pointer.current;pointer.current=null;try{if(captured!==null&&box.current?.hasPointerCapture(captured))box.current.releasePointerCapture(captured);}catch{}commit();}
function finish(id?:number){if(!gesture.current.finish(id))return;const captured=pointer.current,target=capture.current;pointer.current=null;capture.current=null;target?.removeAttribute('data-dragging');try{if(captured!==null&&target?.hasPointerCapture(captured))target.releasePointerCapture(captured);}catch{}commit();}
release.current=finish;
useEffect(()=>{const up=(e:PointerEvent)=>release.current(e.pointerId),cancel=()=>release.current();document.addEventListener('pointerup',up,true);document.addEventListener('pointercancel',up,true);window.addEventListener('blur',cancel);window.addEventListener('framely.pointerRelease',cancel);return()=>{document.removeEventListener('pointerup',up,true);document.removeEventListener('pointercancel',up,true);window.removeEventListener('blur',cancel);window.removeEventListener('framely.pointerRelease',cancel);gesture.current.finish();};},[]);
function down(e:React.PointerEvent<HTMLDivElement>){if(!view||e.button!==0)return;e.preventDefault();finish();gesture.current.begin(e.pointerId,e.clientX,e.clientY,current.current,(e.target as HTMLElement).closest<HTMLElement>('[data-corner]')?.dataset.corner??null);pointer.current=e.pointerId;try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}}
function move(e:React.PointerEvent<HTMLDivElement>){if(!gesture.current.active(e.pointerId))return;if(!(e.buttons&1)){finish(e.pointerId);return;}const bounds=stage.current?.getBoundingClientRect();if(!bounds||!view)return;const next=gesture.current.move(e.pointerId,e.clientX,e.clientY,e.buttons,bounds,ratio,minWidth);if(next)draw(next);
function begin(e:Pick<PointerEvent,'button'|'pointerId'|'clientX'|'clientY'|'preventDefault'>,target:HTMLElement,corner:string|null){if(!view||e.button!==0)return;e.preventDefault();finish();gesture.current.begin(e.pointerId,e.clientX,e.clientY,current.current,corner);pointer.current=e.pointerId;capture.current=target;target.setAttribute('data-dragging','');try{target.setPointerCapture(e.pointerId);}catch{}}
function down(e:React.PointerEvent<HTMLDivElement>){begin(e,e.currentTarget,(e.target as HTMLElement).closest<HTMLElement>('[data-corner]')?.dataset.corner??null);}
function move(e:Pick<PointerEvent,'pointerId'|'clientX'|'clientY'|'buttons'>){if(!gesture.current.active(e.pointerId))return;if(!(e.buttons&1)){finish(e.pointerId);return;}const bounds=stage.current?.getBoundingClientRect();if(!bounds||!view)return;const next=gesture.current.move(e.pointerId,e.clientX,e.clientY,e.buttons,bounds,ratio,minWidth);if(next)draw(next);
}
external.current={down:e=>{if(moveHandle?.current)begin(e,moveHandle.current,null);},move};
useEffect(()=>{
const target=moveHandle?.current;if(!target)return;
const down=(e:PointerEvent)=>external.current.down(e),move=(e:PointerEvent)=>external.current.move(e),end=(e:PointerEvent)=>release.current(e.pointerId);
target.addEventListener('pointerdown',down);target.addEventListener('pointermove',move);target.addEventListener('lostpointercapture',end);
return()=>{target.removeEventListener('pointerdown',down);target.removeEventListener('pointermove',move);target.removeEventListener('lostpointercapture',end);release.current();};
},[moveHandle]);
function end(e:React.PointerEvent<HTMLDivElement>){finish(e.pointerId);}
function keyboard(e:React.KeyboardEvent<HTMLDivElement>){if(!view)return;const delta=e.shiftKey?.05:.01,r=current.current;const changes:Record<string,Partial<FrameRect>>={ArrowLeft:{x:r.x-delta},ArrowRight:{x:r.x+delta},ArrowUp:{y:r.y-delta},ArrowDown:{y:r.y+delta}};if(changes[e.key]){e.preventDefault();draw(fitFrame({...r,...changes[e.key]},aspect,view.aspect));commit();}}
return <div className="cast-framing-editor">
+7 -2
View File
@@ -13,14 +13,19 @@ export function CastQuick({config,refresh}:{config:CastSettings;refresh:()=>Prom
async function run(task:()=>Promise<unknown>){if(busy)return;setBusy(true);setError('');try{await task();setRuntime(await api('cast.status'));await refresh();}catch(e){setError(e instanceof Error?e.message:String(e));}finally{setBusy(false);}}
const running=!!runtime?.running;
return <section className="cast-quick">
<header><IconCast size={25} stroke={1.8}/><h1>{t('串流')}</h1><button aria-label={t('投屏设置')} onClick={()=>void run(()=>api('host.manager.open',{page:'casting'}))}><IconArrowUpRight size={22}/></button></header>
<div className="cast-quick-stream"><IconDeviceVisionPro size={40} stroke={1.5}/><h2>{running?t('正在串流'):t('头显画面')}</h2><p>{runtime?`${config.width} × ${config.height} / ${config.fps} FPS`:t('正在连接')}</p><div className="cast-stream-actions"><button className={running?'':'primary'} disabled={busy||!runtime} onClick={()=>void run(()=>api(running?'cast.stop':'cast.start'))}>{running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void run(()=>api(runtime?.recording?.active?'cast.record.stop':'cast.record.start'))}/></div><p className="cast-record-path">{t('录制保存到 /home/steamos/Videos/framely')}</p></div>
<header><IconCast size={25} stroke={1.8}/><h1>{t('串流')}</h1><button onClick={()=>void run(()=>api('host.manager.open',{page:'casting'}))} disabled={busy}><span>{t('投屏设置')}</span><IconArrowUpRight size={20}/></button></header>
<div className={`cast-quick-stream${running?' is-live':''}`} aria-busy={!runtime}>
<div className="cast-quick-source"><div><h2>{t('头显画面')}</h2><span className="cast-quick-state" role="status">{!runtime?t('正在连接'):running?t('正在串流'):t('未开始串流')}</span></div><IconDeviceVisionPro size={36} stroke={1.5}/></div>
<p className={`cast-quick-specs${!runtime?' is-loading':''}`} title={t('投屏设置')}><span>{config.width} × {config.height}</span><span>{config.codec==='h265'?'H.265':'H.264'}</span><span>{config.fps} FPS</span></p>
<div className="cast-stream-actions"><button className={running?'':'primary'} disabled={busy||!runtime} onClick={()=>void run(()=>api(running?'cast.stop':'cast.start'))}>{running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void run(()=>api(runtime?.recording?.active?'cast.record.stop':'cast.record.start'))}/></div>
</div>
{runtime?.recording?.error&&<p className="error" role="alert">{runtime.recording.error}</p>}
<div className="cast-quick-framing"><div className="cast-quick-framing-row"><button disabled={busy||!runtime||!!runtime.recording?.active||!!runtime.preview||!!runtime.recordingRegion?.editing||config.source!=='screen'||config.output!=='eye'} onClick={()=>void run(()=>api('cast.framing.open'))}><IconCrop size={21}/>{t('画幅调整')}</button>
<SwitchRow label={t('录制区域显示')} checked={config.recordingRegionVisible??false} disabled={busy||!runtime||(!(config.source==='screen'&&config.output==='eye')&&!config.recordingRegionVisible)} onChange={enabled=>void run(()=>api('cast.region.visible.save',{enabled}))}/>
</div>{(config.source!=='screen'||config.output!=='eye')&&<p className="cast-help">{t('录制区域显示仅支持屏幕采集的单眼画面')}</p>}
{runtime?.recordingRegion?.error&&<p className="error" role="alert">{runtime.recordingRegion.error}</p>}
</div>
<div className="cast-quick-recording-options"><SwitchRow label={t('显示录制指示器')} description={t('关闭后仅隐藏右上角录制提示,不会停止录制。')} checked={config.recordingIndicatorVisible??true} disabled={busy||!runtime} onChange={enabled=>void run(()=>api('cast.recordingIndicator.visible.save',{enabled}))}/><p className="cast-record-path">{t('录制保存到 /home/steamos/Videos/framely')}</p></div>
{running&&runtime?.watchAddresses?.[0]&&<div className="cast-quick-address"><span>{t('网页观看')}</span><code>{runtime.watchAddresses[0]}</code></div>}
<div className="cast-quick-receive"><h2>{t('接收投屏')}</h2>{(['airplay','dlna'] as const).map(protocol=><label key={protocol}><span>{protocol==='airplay'?'AirPlay':'DLNA'}</span><Switch label={protocol==='airplay'?'AirPlay':'DLNA'} checked={config[protocol]} disabled={busy} onChange={value=>void run(()=>api('cast.settings.save',{...config,[protocol]:value}))}/></label>)}</div>
{(error||runtime?.error||runtime?.receiverError)&&<p className="error" role="alert">{error||runtime?.error||runtime?.receiverError}</p>}
+2
View File
@@ -9,3 +9,5 @@ html.cast-framing-view,html.cast-framing-view body,html.cast-framing-view #root{
.cast-native-framing-controls h1{font-size:23px;margin:0 0 10px}.cast-native-framing-controls p{font-size:14px;line-height:1.6;color:#c2ced6}
.cast-native-framing-field{display:flex;align-items:center;justify-content:space-between;gap:14px;font-size:15px;margin:16px 0}.cast-native-framing-field .framely-select{width:175px}.cast-native-framing-field .framely-select>button{margin:0;background:#252c32}.cast-native-framing-field [role=listbox]{z-index:20!important;background:#252c32!important}.cast-native-framing-field [role=option]{margin:0;min-height:44px}
.cast-native-framing-controls footer{display:flex;gap:12px;margin-top:20px}.cast-native-framing-controls footer button{flex:1}
.cast-native-framing-controls .cast-framing-move{width:100%;min-height:44px;margin:-6px 0 0;padding:4px 0;justify-content:space-between;cursor:grab;touch-action:none;user-select:none;font-size:23px;font-weight:600;gap:10px;background:transparent;border:0}
.cast-native-framing-controls .cast-framing-move[data-dragging]{cursor:grabbing;background:#39434b;border-color:#c1e0ef}
+4 -2
View File
@@ -8,6 +8,7 @@ import {framePresets,frameRatios,settingsFromFrame,type ReferenceView} from './c
import {normalizeRegion} from './cast-region';
import {FramingLimits,framingMenuScale,type VisibilityGrid} from './cast-framing-limits';
import {minimumFrameWidth} from './cast-framing-gesture';
import {IconArrowsMove} from '@tabler/icons-react';
import './casting.css';
import './cast-spatial-framing.css';
type Geometry={id:string;settings:CastSettings;referenceTan:number;view:ReferenceView&{visibility:VisibilityGrid;eye:0|1}};
@@ -19,6 +20,7 @@ export async function loadSpatialFraming(request:typeof api=api){
export function SpatialFraming(){
const [geometry,setGeometry]=useState<Geometry|null>(null),[draft,setDraft]=useState<CastSettings|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[closed,setClosed]=useState(false);
const finishing=useRef(false),finished=useRef(false);
const moveHandle=useRef<HTMLButtonElement>(null);
const root=useRef<HTMLElement>(null),menu=useRef<HTMLElement>(null),[minimum,setMinimum]=useState({width:0,height:0,referenceTan:0});
useEffect(()=>{const measure=()=>{const area=root.current?.getBoundingClientRect(),panel=menu.current?.getBoundingClientRect();if(area?.width&&area.height&&panel)setMinimum({width:(panel.width+32)/area.width,height:(panel.height+32)/area.height,referenceTan:geometry?.referenceTan??1});};measure();const observer=typeof ResizeObserver==='undefined'?null:new ResizeObserver(measure);if(menu.current)observer?.observe(menu.current);if(root.current)observer?.observe(root.current);window.addEventListener('resize',measure);return()=>{observer?.disconnect();window.removeEventListener('resize',measure);};},[geometry?.referenceTan]);
const reference:ReferenceView={tanHalfHorizontal:geometry?.referenceTan??1,centerTanX:0,centerTanY:0,aspect:1};
@@ -60,9 +62,9 @@ export function SpatialFraming(){
},[ready,geometry?.id,draft?.centerX,draft?.centerY,busy,closed]);
if(closed)return null;
return <main ref={root} className="cast-native-framing" aria-label={t('画幅调整')} inert={busy}>
{draft&&geometry&&ready&&<FramingEditor spatial limit={limit} minimumSize={minimum} settings={draft} view={reference} onChange={change} onError={message=>setError(t(message))}/>}
{draft&&geometry&&ready&&<FramingEditor spatial moveHandle={moveHandle} limit={limit} minimumSize={minimum} settings={draft} view={reference} onChange={change} onError={message=>setError(t(message))}/>}
<section ref={menu} className="cast-native-framing-controls" style={{'--framing-menu-scale':framingMenuScale(reference.tanHalfHorizontal)} as React.CSSProperties}>
<h1>{t('画幅调整')}</h1><p>{t('框内即录制区域,拖动移动,拖动四角等比例缩放。')}</p>
<h1><button ref={moveHandle} className="cast-framing-move" aria-label={t('拖动调整录制位置')} title={t('拖动调整录制位置')} disabled={busy||!ready||!draft}><span>{t('画幅调整')}</span><IconArrowsMove size={22}/></button></h1><p>{t('框内即录制区域,拖动移动,拖动四角等比例缩放。')}</p>
<div className="cast-native-framing-field"><span>{t('画面比例')}</span><Select label={t('画面比例')} disabled={busy||!draft} value={String(frameRatios.find(r=>Math.abs(r.value-aspect)<.004)?.value??aspect)} onChange={value=>{const ratio=Number(value),edge=Math.max(draft!.width,draft!.height);change(ratio>=1?{width:edge,height:Math.round(edge/ratio/2)*2}:{width:Math.round(edge*ratio/2)*2,height:edge});}} options={[
...(!frameRatios.some(r=>Math.abs(r.value-aspect)<.004)?[{value:String(aspect),label:t('自定义')}]:[]),
...frameRatios.map(r=>({value:String(r.value),label:r.label}))
+4 -4
View File
@@ -11,7 +11,7 @@ import {framePresets,frameRatios,type ReferenceView} from './cast-framing';
import './casting.css';
import {RecordingButton,type RecordingStatus} from './cast-recording';
export type CastSettings={source:'screen'|'steamVR';codec:'h264'|'h265';output:'eye'|'raw';eye:'steamVR'|'left'|'right';width:number;height:number;fps:number;bitrateMbps:number;horizontalFov:number|null;centerX:number;centerY:number;systemAudio:boolean;microphone:boolean;airplay:boolean;dlna:boolean;receiverName:string;recordingRegionVisible?:boolean};
export type CastSettings={source:'screen'|'steamVR';codec:'h264'|'h265';output:'eye'|'raw';eye:'steamVR'|'left'|'right';width:number;height:number;fps:number;bitrateMbps:number;horizontalFov:number|null;centerX:number;centerY:number;systemAudio:boolean;microphone:boolean;airplay:boolean;dlna:boolean;receiverName:string;recordingRegionVisible?:boolean;recordingIndicatorVisible?:boolean};
type CastSession={id:string;protocol:string;accepted:boolean;width?:number;height?:number};
type Device={id:string;name:string};
type Runtime={running:boolean;recording?:RecordingStatus;error:string|null;dominantEye:number|null;watchPath:string|null;watchAccessEnabled?:boolean;elapsedSeconds:number|null;preview?:boolean;view?:ReferenceView|null;receiverError?:string|null;sessions?:CastSession[];watchAddresses?:string[]};
@@ -24,7 +24,7 @@ export function Casting({config,refresh,network,page='casting'}:{config:CastSett
const [streamInfo,setStreamInfo]=useState<CastStreamInfo|null>(null);
const [draft,setDraft]=useState(config),[runtime,setRuntime]=useState<Runtime|null>(null),[pending,setPending]=useState(''),[error,setError]=useState(''),[saved,setSaved]=useState(false),[epoch,setEpoch]=useState(0),[devices,setDevices]=useState<Device[]>([]),[sending,setSending]=useState<string>(''),[copied,setCopied]=useState(false),[editing,setEditing]=useState(false),[searched,setSearched]=useState(false);
const beforeFraming=useRef<Pick<CastSettings,'horizontalFov'|'centerX'|'centerY'>|null>(null),editorActive=useRef(false),deviceScan=useRef(0),alive=useRef(true);
const captureSettings=({airplay,dlna,receiverName,recordingRegionVisible,...capture}:CastSettings)=>capture;
const captureSettings=({airplay,dlna,receiverName,recordingRegionVisible,recordingIndicatorVisible,...capture}:CastSettings)=>capture;
const captureDirty=JSON.stringify(captureSettings(draft))!==JSON.stringify(captureSettings(config)),dirty=JSON.stringify(draft)!==JSON.stringify(config),busy=!!pending,screen=draft.source==='screen',eye=screen&&draft.output==='eye';
const aspect=screen&&draft.output==='raw'?2:draft.width/draft.height;
const ratio=frameRatios.find(r=>Math.abs(r.value-aspect)<.004)?.label;
@@ -50,9 +50,9 @@ export function Casting({config,refresh,network,page='casting'}:{config:CastSett
{page==='casting'&&<>
<div className="cast-monitor"><CastVideo key={epoch} active={!!runtime?.running&&!runtime.preview} onError={setError} onStreamInfo={setStreamInfo}/>{!runtime?.running&&<div className="cast-monitor-empty"><IconDeviceVisionPro size={44} stroke={1.5}/><h2>{t('分享你看到的画面')}</h2><p>{t('启动后,手机和电脑可以通过网页实时观看。')}</p></div>}</div>
<div className="cast-stream-bar"><div><strong>{t(config.source==='screen'?'屏幕采集':'SteamVR')}</strong><span>{config.width} × {config.height}<i/> {config.fps} fps</span></div><div className="cast-stream-actions"><button className={runtime?.running?'':'primary'} disabled={busy||!runtime} onClick={()=>void action('start',async()=>{if(runtime?.running)await api('cast.stop');else{if(dirty)await apply();await api('cast.start');}setEpoch(v=>v+1);})}>{runtime?.running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{pending==='start'?t('正在启动…'):runtime?.running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void action('record',async()=>{if(!runtime?.recording?.active&&!runtime?.running&&dirty)await apply();await api(runtime?.recording?.active?'cast.record.stop':'cast.record.start');setEpoch(v=>v+1);})}/></div></div>
<p className="cast-record-path">{t('录制保存到 /home/steamos/Videos/framely')}{runtime?.recording?.path&&<> · <span>{runtime.recording.path.split('/').pop()}</span></>}</p>
<SwitchRow label={t('显示录制指示器')} description={t('关闭后仅隐藏右上角录制提示,不会停止录制。')} checked={config.recordingIndicatorVisible??true} disabled={busy} onChange={enabled=>void action('indicator',async()=>{await api('cast.recordingIndicator.visible.save',{enabled});await refresh();})}/><p className="cast-record-path">{t('录制保存到 /home/steamos/Videos/framely')}{runtime?.recording?.path&&<> · <span>{runtime.recording.path.split('/').pop()}</span></>}</p>
{runtime?.recording?.error&&<p className="error" role="alert">{runtime.recording.error}</p>}
{runtime?.running&&<section className="cast-share"><div><IconWifi size={24}/><div><h2>{t('在其他设备上观看')}</h2><p>{t('连接同一网络,打开观看地址。')}</p></div></div><SwitchRow label={t('免验证')} checked={runtime.watchAccessEnabled??false} disabled={busy} onChange={enabled=>void action('watch-access',async()=>{await api('cast.watchAccess.save',{enabled});setCopied(false);})}/><p>{t('持有此链接的人无需密码即可观看。')}</p><div className="cast-share-link"><code>{watchAddress}</code><button disabled={busy} onClick={()=>void action('copy',copy)} aria-label={t('复制观看地址')}>{copied?<IconCheck size={20}/>:<IconCopy size={20}/>}<span>{copied?t('已复制'):t('复制')}</span></button><a className="cast-icon-link" href={watchAddress} aria-label={t('打开观看网页')}><IconExternalLink size={21}/></a></div></section>}
<section className="cast-share"><div><IconWifi size={24}/><div><h2>{t('在其他设备上观看')}</h2><p>{t('连接同一网络,打开观看地址。')}</p></div></div><SwitchRow label={t('免验证')} checked={runtime?.watchAccessEnabled??false} disabled={busy||!runtime} onChange={enabled=>void action('watch-access',async()=>{await api('cast.watchAccess.save',{enabled});setCopied(false);})}/><p>{t('持有此链接的人无需密码即可观看。')}</p>{runtime&&<div className="cast-share-link"><code>{watchAddress}</code><button disabled={busy} onClick={()=>void action('copy',copy)} aria-label={t('复制观看地址')}>{copied?<IconCheck size={20}/>:<IconCopy size={20}/>}<span>{copied?t('已复制'):t('复制')}</span></button><a className="cast-icon-link" href={watchAddress} aria-label={t('打开观看网页')}><IconExternalLink size={21}/></a></div>}</section>
</>}
{page==='casting-picture'&&<>
+35
View File
@@ -0,0 +1,35 @@
import React,{useEffect,useRef,useState} from 'react';
import {IconArrowBackUp,IconArrowForwardUp,IconSearch} from '@tabler/icons-react';
import type {mount,MountedCodeEditor} from './file-code-engine';
import {t,currentLanguage} from './i18n';
import {Select} from './localized-select';
declare global {interface Window {FramelyCodeEditor?:{mount:typeof mount;languageForPath?:(path:string)=>string};}}
let loading:Promise<void>|null=null;
function loadEditor(){
if(window.FramelyCodeEditor)return Promise.resolve();
if(!loading)loading=new Promise<void>((resolve,reject)=>{const script=document.createElement('script');script.src='/assets/code-editor.js';script.onload=()=>{if(window.FramelyCodeEditor)resolve();else{script.remove();loading=null;reject(Error('Code editor failed to initialize'));}};script.onerror=()=>{script.remove();loading=null;reject(Error('Code editor failed to load'));};document.head.append(script);});
return loading;
}
export function FileCodeEditor({path,value,readOnly,onChange,onSave}:{path:string;value:string;readOnly:boolean;onChange:(text:string)=>void;onSave:()=>void}){
const parent=useRef<HTMLDivElement>(null),editor=useRef<MountedCodeEditor|null>(null),position=useRef<HTMLOutputElement>(null),latest=useRef({value,readOnly,onChange,onSave});latest.current={value,readOnly,onChange,onSave};
const [ready,setReady]=useState(false),[error,setError]=useState(''),[wrap,setWrap]=useState(false),[attempt,setAttempt]=useState(0);
const [language,setLanguage]=useState('text');
const languages=[{value:'text',label:t('纯文本')},{value:'javascript',label:'JavaScript'},{value:'typescript',label:'TypeScript'},{value:'json',label:'JSON'},{value:'html',label:'HTML'},{value:'css',label:'CSS'},{value:'python',label:'Python'},{value:'rust',label:'Rust'}];
useEffect(()=>{let alive=true;setReady(false);setError('');void loadEditor().then(()=>{
if(!alive||!parent.current)return;
const phrases:Record<string,string>=currentLanguage().startsWith('zh')?{Find:'查找',Replace:'替换',next:'下一个',previous:'上一个',all:'全部','match case':'区分大小写','by word':'全词匹配',regexp:'正则表达式',replace:'替换','replace all':'全部替换',close:'关闭','go to line':'跳转到行',go:'跳转','Toggle replace':'展开或收起替换','Invalid regular expression':'正则表达式无效','No results':'没有匹配结果','Read-only document':'只读文档,无法替换'}:{};
setLanguage(window.FramelyCodeEditor!.languageForPath?.(path)??'text');
editor.current=window.FramelyCodeEditor!.mount({parent:parent.current,path,value:latest.current.value,readOnly:latest.current.readOnly,label:t('文本内容'),phrases,onChange:text=>latest.current.onChange(text),onSave:()=>latest.current.onSave(),onCursor:status=>{if(position.current)position.current.textContent=t('行 {0},列 {1}',{0:status.line,1:status.column})+(status.selected?` / ${t('选中 {0}',{0:status.selected})}`:'');}});setReady(true);
}).catch(e=>{if(alive)setError(String(e));});return()=>{alive=false;editor.current?.destroy();editor.current=null;};},[path,attempt]);
useEffect(()=>{editor.current?.setValue(value);},[value,ready]);
useEffect(()=>{editor.current?.setReadOnly(readOnly);},[readOnly,ready]);
useEffect(()=>{editor.current?.wrap(wrap);},[wrap,ready]);
useEffect(()=>{editor.current?.setLanguage(language);},[language,ready]);
return <div className="file-code-editor" onKeyDownCapture={e=>{if(e.key==='Escape'&&editor.current?.closeSearch())e.stopPropagation();}} onKeyDown={e=>{if(e.key==='Escape'&&(e.target as HTMLElement).closest('.framely-select'))e.stopPropagation();}}>
<div className="file-code-toolbar"><div className="file-code-history"><button disabled={!ready||readOnly} onClick={()=>editor.current?.undo()} aria-label={t('撤销')} title={t('撤销')}><IconArrowBackUp size={18}/></button><button disabled={!ready||readOnly} onClick={()=>editor.current?.redo()} aria-label={t('重做')} title={t('重做')}><IconArrowForwardUp size={18}/></button></div><button disabled={!ready} onClick={()=>editor.current?.find()}><IconSearch size={18}/>{t('查找替换')}</button><button disabled={!ready} aria-pressed={wrap} onClick={()=>setWrap(v=>!v)}>{t('自动换行')}</button><Select label={t('语言模式')} options={languages} value={language} disabled={!ready} onChange={setLanguage}/></div>
{!ready&&!error&&<p role="status">{t('正在加载代码编辑器…')}</p>}
{error&&<div className="file-code-fallback" role="alert"><div><p>{t('代码编辑器加载失败,可以使用普通文本编辑。')}</p><button onClick={()=>setAttempt(v=>v+1)}>{t('重试')}</button></div><textarea aria-label={t('文本内容')} className="file-editor" readOnly={readOnly} value={value} onChange={e=>onChange(e.target.value)}/></div>}
<div className="file-code-surface file-editor" ref={parent} hidden={!ready}/>
<div className="file-editor-status"><span>{readOnly?t('只读'):t('可编辑')}</span><output ref={position}>{t('行 {0},列 {1}',{0:1,1:1})}</output><span>UTF-8</span><span>{value.includes('\r\n')?'CRLF':value.includes('\r')&&!value.includes('\n')?'CR':'LF'}</span><span className="file-editor-indent">{t('缩进:{0} 个空格',{0:2})}</span></div>
</div>;
}
+53
View File
@@ -0,0 +1,53 @@
// Separately bundled and loaded only when the text editor opens. No language
// servers, autocomplete, lint diagnostics or network requests are installed.
import {EditorState,Compartment,countColumn,Text} from '@codemirror/state';
import {EditorView,keymap,lineNumbers,highlightActiveLineGutter,highlightSpecialChars,drawSelection,highlightActiveLine} from '@codemirror/view';
import {history,historyKeymap,defaultKeymap,indentWithTab,undo,redo,deleteCharBackward,cursorCharLeft,cursorCharRight,cursorLineUp,cursorLineDown,insertNewlineAndIndent} from '@codemirror/commands';
import {foldGutter,foldKeymap,indentOnInput,indentUnit,bracketMatching,syntaxHighlighting,HighlightStyle} from '@codemirror/language';
import {tags} from '@lezer/highlight';
import {search,searchKeymap,highlightSelectionMatches,openSearchPanel,closeSearchPanel} from '@codemirror/search';
import {createSearchPanel} from './file-code-search';
import {javascript} from '@codemirror/lang-javascript';
import {json} from '@codemirror/lang-json';
import {css} from '@codemirror/lang-css';
import {html} from '@codemirror/lang-html';
import {python} from '@codemirror/lang-python';
import {rust} from '@codemirror/lang-rust';
export type CursorStatus={line:number;column:number;selected:number;lines:number};
export type CodeEditorOptions={parent:HTMLElement;value:string;path:string;readOnly:boolean;label:string;phrases:Record<string,string>;onChange:(text:string)=>void;onSave:()=>void;onCursor?:(status:CursorStatus)=>void};
function language(mode:string){switch(mode){case 'javascript':return javascript({jsx:true});case 'typescript':return javascript({typescript:true,jsx:true});case 'json':return json();case 'css':return css();case 'html':return html();case 'python':return python();case 'rust':return rust();default:return [];}}
export function languageForPath(path:string){const ext=path.split('.').pop()?.toLowerCase()??'';return ({js:'javascript',mjs:'javascript',cjs:'javascript',jsx:'javascript',ts:'typescript',tsx:'typescript',json:'json',css:'css',html:'html',htm:'html',py:'python',rs:'rust'} as Record<string,string>)[ext]??'text';}
const separator=(value:string)=>value.includes('\r\n')?'\r\n':value.includes('\r')&&!value.includes('\n')?'\r':'\n';
const highlighting=HighlightStyle.define([{tag:tags.keyword,color:'#c9a3e8'},{tag:tags.string,color:'#b6cea4'},{tag:[tags.number,tags.bool,tags.null],color:'#dfbe91'},{tag:tags.typeName,color:'#8ecbd1'},{tag:tags.function(tags.variableName),color:'#e0d0a0'},{tag:tags.comment,color:'#909f8c',fontStyle:'italic'},{tag:[tags.propertyName,tags.attributeName],color:'#a5c9eb'},{tag:tags.tagName,color:'#a5c9eb'},{tag:tags.operator,color:'#c5cbd5'}]);
export function mount(options:CodeEditorOptions){
const editable=new Compartment(),wrapping=new Compartment(),syntax=new Compartment(),endings=new Compartment();let updating=false;
const cursor=(state:EditorState)=>{const selection=state.selection.main,line=state.doc.lineAt(selection.head);options.onCursor?.({line:line.number,column:countColumn(state.sliceDoc(line.from,selection.head),state.tabSize)+1,selected:selection.to-selection.from,lines:state.doc.lines});};
const view=new EditorView({parent:options.parent,state:EditorState.create({doc:options.value,extensions:[
lineNumbers(),highlightActiveLineGutter(),highlightSpecialChars(),history(),foldGutter(),drawSelection(),indentOnInput(),bracketMatching(),syntaxHighlighting(highlighting),highlightActiveLine(),highlightSelectionMatches(),syntax.of(language(languageForPath(options.path))),indentUnit.of(' '),EditorState.tabSize.of(4),endings.of(EditorState.lineSeparator.of(separator(options.value))),
editable.of([EditorState.readOnly.of(options.readOnly),EditorView.editable.of(!options.readOnly)]),wrapping.of([]),
search({createPanel:createSearchPanel}),EditorState.phrases.of(options.phrases),EditorView.contentAttributes.of({'aria-label':options.label,'data-framely-editor-input':''}),
keymap.of([{key:'Mod-s',scope:'editor search-panel',run:()=>{options.onSave();return true;}},...defaultKeymap,...historyKeymap,...searchKeymap,...foldKeymap,indentWithTab]),
EditorView.updateListener.of(update=>{if(update.docChanged&&!updating)options.onChange(update.state.sliceDoc());if(update.docChanged||update.selectionSet)cursor(update.state);}),
EditorView.theme({'&':{height:'100%',backgroundColor:'#1c1e22',color:'#dce1e8',fontSize:'14px'},'.cm-scroller':{fontFamily:'ui-monospace,SFMono-Regular,Consolas,monospace',overflow:'auto',lineHeight:'1.7'},'.cm-content':{padding:'18px 0'},'.cm-line':{padding:'0 20px'},'.cm-gutters':{backgroundColor:'#1c1e22',color:'#7f8998',borderRight:'none',padding:'0 4px 0 12px'},'.cm-activeLineGutter':{backgroundColor:'transparent',color:'#e0e6ef'},'.cm-activeLine':{backgroundColor:'#ffffff05'},'.cm-cursor':{borderLeftColor:'#b9d8f4',borderLeftWidth:'2px'},'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection':{backgroundColor:'#42638480'},'.cm-panels':{backgroundColor:'#25282f',color:'#e0e2e6'},'.cm-textfield':{backgroundColor:'#1c1d20',color:'#e0e2e6',border:'1px solid #656b76'},'.cm-button':{backgroundImage:'none',backgroundColor:'#35383e',color:'#e0e2e6',border:'1px solid #656b76'}},{dark:true})
]})});
const input=view.contentDOM;
const open=(event:Event)=>{(event as CustomEvent).detail.existing=view.state.sliceDoc(view.state.selection.main.from,view.state.selection.main.to);};
const commit=(event:Event)=>{event.preventDefault();if(!view.state.readOnly)view.dispatch(view.state.replaceSelection(String((event as CustomEvent).detail)));};
const edit=(event:Event)=>{
event.preventDefault();if(view.state.readOnly)return;const value=String((event as CustomEvent).detail);
const commands:Record<string,(v:EditorView)=>boolean>={'\b':deleteCharBackward,'\x1b[D':cursorCharLeft,'\x1b[C':cursorCharRight,'\x1b[A':cursorLineUp,'\x1b[B':cursorLineDown,'\n':insertNewlineAndIndent,'\r':insertNewlineAndIndent};
if(commands[value])commands[value](view);else if(value&&value.charCodeAt(0)>=32)view.dispatch(view.state.replaceSelection(value));
view.scrollDOM.querySelector('.cm-cursor')?.scrollIntoView({block:'nearest'});
};
input.addEventListener('framely.keyboard.open',open);input.addEventListener('framely.keyboard.commit',commit);input.addEventListener('framely.keyboard.edit',edit);
cursor(view.state);
return {
setValue(value:string){if(value!==view.state.sliceDoc()){updating=true;try{view.dispatch({changes:{from:0,to:view.state.doc.length,insert:Text.of(value.split(/\r\n|\r|\n/))},effects:endings.reconfigure(EditorState.lineSeparator.of(separator(value)))});}finally{updating=false;}}},
setLanguage(mode:string){view.dispatch({effects:syntax.reconfigure(language(mode))});},
setReadOnly(readOnly:boolean){view.dispatch({effects:editable.reconfigure([EditorState.readOnly.of(readOnly),EditorView.editable.of(!readOnly)])});},
wrap(enabled:boolean){view.dispatch({effects:wrapping.reconfigure(enabled?EditorView.lineWrapping:[])});},
undo(){undo(view);},redo(){redo(view);},find(){openSearchPanel(view);},closeSearch(){const closed=closeSearchPanel(view);if(closed)view.focus();return closed;},
destroy(){input.removeEventListener('framely.keyboard.open',open);input.removeEventListener('framely.keyboard.commit',commit);input.removeEventListener('framely.keyboard.edit',edit);view.destroy();}
};
}
export type MountedCodeEditor=ReturnType<typeof mount>;
+55
View File
@@ -0,0 +1,55 @@
import {EditorView,runScopeHandlers,type Panel,type ViewUpdate} from '@codemirror/view';
import {SearchQuery,getSearchQuery,setSearchQuery,findNext,findPrevious,replaceNext,replaceAll,closeSearchPanel} from '@codemirror/search';
// A compact editor find widget. Matching, replacement and undo remain owned by
// CodeMirror, so regex captures, word boundaries and document edits stay in sync.
export function createSearchPanel(view:EditorView):Panel {
const dom=document.createElement('div');dom.className='cm-search file-code-search';
const phrase=(key:string)=>view.state.phrase(key);
const row=(name:string)=>{const el=document.createElement('div');el.className=name;dom.append(el);return el;};
const main=row('file-search-row'),replacement=row('file-search-row file-search-replacement');
const icons:Record<string,string>={previous:'M12 19V5m-6 6 6-6 6 6',next:'M12 5v14m-6-6 6 6 6-6',close:'m6 6 12 12M6 18 18 6','Toggle replace':'m9 5 7 7-7 7'};
const button=(parent:HTMLElement,name:string,glyph:string,action:()=>void)=>{const el=document.createElement('button');el.type='button';el.name=name;
if(icons[name]){const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 24 24');svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','1.7');svg.setAttribute('aria-hidden','true');const path=document.createElementNS(svg.namespaceURI,'path');path.setAttribute('d',icons[name]);svg.append(path);el.append(svg);}else el.textContent=glyph;
el.title=phrase(name);el.setAttribute('aria-label',phrase(name));el.addEventListener('click',action);parent.append(el);return el;};
const field=(parent:HTMLElement,name:string)=>{const el=document.createElement('input');el.type='text';el.name=name;el.placeholder=phrase(name);el.setAttribute('aria-label',phrase(name));el.autocomplete='off';el.spellcheck=false;parent.append(el);return el;};
let expanded=false,query=getSearchQuery(view.state),matches:{from:number;to:number}[]=[],truncated=false;
const expand=button(main,'Toggle replace','›',()=>{expanded=!expanded;refresh();view.requestMeasure();});expand.setAttribute('aria-expanded','false');
const inputGroup=document.createElement('div');inputGroup.className='file-search-input';main.append(inputGroup);
const input=field(inputGroup,'Find');input.setAttribute('main-field','true');
const options=document.createElement('div');options.className='file-search-options';inputGroup.append(options);
const toggles=(['caseSensitive','wholeWord','regexp'] as const).map((key,index)=>{const el=button(options,['match case','by word','regexp'][index],['Aa','ab','.*'][index],()=>{commit({[key]:!query[key]});input.focus();});return {key,el};});
const count=document.createElement('output');count.className='file-search-count';count.setAttribute('aria-live','polite');main.append(count);
const previous=button(main,'previous','↑',()=>findPrevious(view)),next=button(main,'next','↓',()=>findNext(view));
button(main,'close','×',()=>{closeSearchPanel(view);view.focus();});
const replaceInput=field(replacement,'Replace');
const one=button(replacement,'replace',phrase('Replace'),()=>replaceNext(view)),all=button(replacement,'replace all',phrase('replace all'),()=>replaceAll(view));
const message=row('file-search-message');message.setAttribute('role','status');
function commit(change:Partial<{caseSensitive:boolean;wholeWord:boolean;regexp:boolean}>={}) {
const nextQuery=new SearchQuery({...query,search:input.value,replace:replaceInput.value,...change});
if(!nextQuery.eq(query))view.dispatch({effects:setSearchQuery.of(nextQuery)});
}
function scan(){matches=[];truncated=false;if(query.valid){const cursor=query.getCursor(view.state);for(let step=cursor.next();!step.done;step=cursor.next()){if(matches.length===10000){truncated=true;break;}matches.push(step.value);}}}
function refresh(){
input.value=query.search;replaceInput.value=query.replace;
for(const {key,el} of toggles)el.setAttribute('aria-pressed',String(query[key]));
expand.setAttribute('aria-expanded',String(expanded));
replacement.hidden=!expanded;replaceInput.disabled=view.state.readOnly;
const selection=view.state.selection.main,index=matches.findIndex(m=>m.from===selection.from&&m.to===selection.to);
const invalid=!!query.search&&!query.valid;
input.setAttribute('aria-invalid',String(invalid));
count.textContent=query.search?(invalid?'—':`${index<0?'–':index+1} / ${matches.length}${truncated?'+':''}`):'';
previous.disabled=next.disabled=!matches.length;
one.disabled=all.disabled=view.state.readOnly||!matches.length;
message.textContent=invalid?phrase('Invalid regular expression'):query.search&&!matches.length?phrase('No results'):expanded&&view.state.readOnly?phrase('Read-only document'):'';
message.hidden=!message.textContent;
}
input.addEventListener('input',()=>commit());replaceInput.addEventListener('input',()=>commit());
dom.addEventListener('keydown',event=>{
if(event.key==='Escape'){event.preventDefault();event.stopPropagation();closeSearchPanel(view);view.focus();}
else if(runScopeHandlers(view,event,'search-panel')){event.preventDefault();event.stopPropagation();}
else if(event.key==='Enter'&&!event.isComposing){event.preventDefault();event.stopPropagation();if(event.target===replaceInput){if(!view.state.readOnly)(event.altKey?replaceAll:replaceNext)(view);}else(event.shiftKey?findPrevious:findNext)(view);}
});
scan();refresh();
return {dom,top:true,update(update:ViewUpdate){const nextQuery=getSearchQuery(update.state),changed=!query.eq(nextQuery);query=nextQuery;if(changed||update.docChanged)scan();refresh();}};
}
+47
View File
@@ -139,6 +139,15 @@
.file-manager .file-preview img,.file-manager .file-preview video{max-width:100%;max-height:60vh;object-fit:contain}
.file-manager .file-editor{width:100%;resize:vertical}
.file-manager .file-details{overflow-wrap:anywhere}
.file-manager .modal.file-properties-modal{left:max(16px,calc((100% - 720px)/2));right:max(16px,calc((100% - 720px)/2))}
.file-properties-heading{display:flex;align-items:center;gap:16px;margin-bottom:20px}.file-properties-heading>div{min-width:0}.file-manager .file-properties-heading h2{margin:0 0 4px;overflow-wrap:anywhere}.file-properties-heading p{margin:0;color:var(--file-muted)}
.file-property-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px 24px;margin:0;padding:18px 0;border-top:1px solid var(--file-line)}
.file-property-grid>div:first-child{grid-column:1/-1}.file-property-grid dt{font-size:12px;color:var(--file-muted);margin-bottom:5px}.file-property-grid dd{margin:0;font-size:14px;overflow-wrap:anywhere;user-select:text}.file-property-grid code{font:13px/1.6 ui-monospace,monospace}
.file-permissions{padding-top:18px;border-top:1px solid var(--file-line)}.file-manager .file-permissions h3{display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:16px;margin:0 0 16px}.file-permissions h3 code{font:12px ui-monospace,monospace;color:var(--file-muted);margin-left:auto}
.file-permission-grid{display:grid;grid-template-columns:minmax(80px,1.1fr) repeat(3,minmax(44px,1fr));gap:6px;align-items:center}.file-permission-grid>span{text-align:center;font-size:12px;color:var(--file-muted)}.file-permission-grid>strong{font-size:13px;font-weight:500}.file-manager .file-permission-grid button{min-height:44px;margin:0;border:1px solid var(--file-line);border-radius:8px}.file-permission-grid [aria-checked=true]{background:#d7dce0;color:#202125}.file-manager .file-permissions label{display:grid;grid-template-columns:1fr 120px;align-items:center;margin-top:16px}.file-manager .file-permissions input{font:15px ui-monospace,monospace}.file-manager .file-permissions p{color:var(--file-muted);font-size:12px;margin:8px 0}
.file-manager .file-editor-path{font:12px ui-monospace,monospace;color:var(--file-muted);overflow-wrap:anywhere;user-select:text;margin:-8px 0 12px}
.file-code-editor{border:1px solid var(--file-line);border-radius:8px;overflow:hidden;min-width:0}.file-code-toolbar{display:flex;align-items:center;gap:6px;padding:6px;background:#292b30;flex-wrap:wrap}.file-manager .file-code-toolbar button{min-height:44px;padding:8px 10px;margin:0;font-size:13px;border-radius:6px}.file-code-toolbar span{font:12px ui-monospace,monospace;color:var(--file-muted);margin-left:auto;padding:0 8px}.file-manager .modal .file-code-surface{height:clamp(240px,48dvh,600px);min-height:0;resize:none}.file-code-surface .cm-editor.cm-focused{outline:2px solid var(--blue);outline-offset:-2px}.file-manager .file-code-surface .cm-content{outline:none;min-height:100%;white-space:pre}.file-manager .file-code-surface .cm-lineWrapping{white-space:pre-wrap}.file-manager .file-code-surface .cm-panels input{width:auto;min-height:32px;margin:4px;font-size:13px;padding:5px}.file-manager .file-code-surface .cm-panels input[type=checkbox]{width:18px;min-height:18px;height:18px}.file-manager .file-code-surface .cm-panels label{display:inline-flex;gap:4px;margin:4px;font-size:12px}.file-manager .file-code-surface .cm-panels button{min-height:32px;margin:4px;font-size:13px}.file-code-editor>p{padding:20px}
@media(max-width:480px){.file-property-grid{grid-template-columns:minmax(0,1fr);gap:14px}.file-permissions h3 code{margin-left:0}.file-code-toolbar span{display:none}}
@media(prefers-reduced-motion:reduce){.file-manager button:active:not(:disabled){transform:none}.file-manager *{transition:none!important;animation:none!important}}
.file-manager .file-address,.file-manager .file-search{margin:0;font-size:13px}
@@ -332,3 +341,41 @@
@media(max-width:767px){.file-upload-confirm h2{margin-bottom:20px}.file-upload-review .file-upload-destination code{font-size:16px}.file-upload-policies label{min-height:70px}.file-upload-summary{margin-bottom:20px}}
.file-manager:has(.modal) .file-upload-queue{visibility:hidden}
/* The editor replaces the file workspace, not a generic floating form. */
.file-manager .file-editor-workspace{position:absolute;inset:0;z-index:12;display:flex;flex-direction:column;min-width:0;min-height:0;background:#1c1e22;color:#dce1e8;overflow:hidden;border:0;border-radius:0;padding:0;box-shadow:none}
.file-manager .file-editor-workspace[hidden]{display:none}
.file-editor-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:8px 16px;background:#23262c;border-bottom:1px solid #343941;flex:none}
.file-editor-heading>div{display:flex;align-items:center;gap:10px;min-width:0}.file-editor-heading strong{font-size:14px;font-weight:550}.file-editor-heading>div:first-child>svg{color:#a5c9eb}
.file-manager .file-editor-heading button{min-height:40px;border:1px solid #454c57;border-radius:5px;padding:8px 13px;font-size:13px;background:transparent}.file-manager .file-editor-heading .file-editor-save{background:#a5c9eb;color:#182330;border-color:#a5c9eb}
.file-editor-tabs{display:flex;align-items:center;gap:20px;min-height:44px;background:#181a1e;border-bottom:1px solid #343941;flex:none;overflow:hidden}
.file-editor-tab{align-self:stretch;display:flex;align-items:center;gap:10px;padding:10px 18px;border-top:2px solid #a5c9eb;border-right:1px solid #343941;background:#1c1e22;min-width:120px;max-width:60%;flex:none}.file-editor-tab>svg{color:#a5c9eb}.file-editor-tab strong{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-editor-dirty{width:7px;height:7px;border-radius:50%;background:#dce1e8;flex:none}
.file-editor-tabs>code{font:12px ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#9ca6b6;padding-right:16px;user-select:text}
.file-manager .file-editor-workspace>.error,.file-manager .file-editor-workspace>.banner{font-size:13px;padding:10px 18px;margin:0;border-radius:0;flex:none}
.file-editor-workspace .file-code-editor{display:flex;flex-direction:column;flex:1;min-height:0;border:0;border-radius:0;background:#1c1e22;overflow:hidden}
.file-editor-workspace .file-code-toolbar{flex:none;position:relative;z-index:2;padding:5px 12px;gap:4px;background:#1c1e22;border-bottom:1px solid #30353d;flex-wrap:nowrap}
.file-code-history{display:flex;align-items:center;gap:2px;margin-right:6px;padding-right:10px;border-right:1px solid #343941}
.file-manager .file-editor-workspace .file-code-toolbar button{background:transparent;border:1px solid transparent;min-height:40px;padding:8px 10px;border-radius:4px;font-size:12px;white-space:nowrap}.file-manager .file-editor-workspace .file-code-toolbar button:hover:not(:disabled){background:#2d333d}.file-manager .file-editor-workspace .file-code-toolbar [aria-pressed=true]{background:#2d333d;color:#bcdcf6}
.file-code-toolbar .framely-select{margin-left:auto;width:148px;flex:none}.file-code-toolbar [role=listbox]{background:#252931!important;border-color:#4c5664!important;box-shadow:0 8px 24px #0005}
.file-manager .file-editor-workspace .file-code-surface{flex:1;width:100%;height:0;min-height:0;resize:none;overflow:hidden}
.file-editor-workspace .file-code-surface .cm-editor.cm-focused{outline:none}.file-editor-workspace .file-code-surface:focus-within{box-shadow:inset 2px 0 #a5c9eb}
.file-manager .file-code-surface .cm-panels-top{position:absolute;top:8px;right:18px;left:auto;z-index:4;width:min(540px,calc(100% - 36px));border:0;background:transparent}
.file-manager .file-code-surface .file-code-search{box-sizing:border-box;width:100%;padding:8px;background:#252a32;border:1px solid #495361;border-top:2px solid #8aaac3;border-radius:5px;box-shadow:0 5px 18px #0006;font:12px/1.4 ui-sans-serif,system-ui,sans-serif}
.file-search-row{display:flex;align-items:center;gap:4px;min-width:0}.file-search-row[hidden],.file-search-message[hidden]{display:none}
.file-manager .file-code-surface .file-code-search input{box-sizing:border-box;flex:1;width:0;min-width:48px;height:32px;min-height:32px;margin:0;padding:5px 8px;border:1px solid #505967;border-radius:3px;background:#1b1f25;color:#e0e6ef;font:13px/1.4 ui-monospace,monospace;outline:none}
.file-search-input{display:flex;align-items:center;flex:1;min-width:0;border:1px solid #505967;border-radius:3px;background:#1b1f25;padding-right:3px}.file-search-input:focus-within{border-color:#8bb9dd;box-shadow:0 0 0 1px #8bb9dd30}.file-search-input:has([aria-invalid=true]){border-color:#e29a90}.file-manager .file-code-surface .file-search-input input{border:0;background:transparent}.file-manager .file-code-surface .file-search-input input:focus{box-shadow:none}
.file-manager .file-code-surface .file-code-search input:focus{border-color:#8bb9dd;box-shadow:0 0 0 1px #8bb9dd40}.file-manager .file-code-surface .file-code-search input[aria-invalid=true]{border-color:#e29a90}
.file-manager .file-code-surface .file-search-input input:focus{border-color:transparent;box-shadow:none}
.file-manager .file-code-surface .file-code-search button{display:inline-flex;align-items:center;justify-content:center;flex:none;min-width:28px;width:auto;min-height:32px;height:32px;padding:0 5px;margin:0;border:1px solid transparent;border-radius:3px;background:transparent;color:#bcc6d3;font:13px/1 ui-monospace,monospace;cursor:pointer}.file-code-search button svg{width:17px;height:17px;flex:none}.file-code-search button[name="Toggle replace"][aria-expanded=true] svg{transform:rotate(90deg)}
.file-manager .file-code-surface .file-code-search button:hover:not(:disabled){background:#3b434f;color:#fff}.file-manager .file-code-surface .file-code-search button[aria-pressed=true]{border-color:#7299b7;background:#354b5e;color:#d6edff}.file-manager .file-code-surface .file-code-search button:focus-visible{outline:2px solid #8bb9dd;outline-offset:-2px}.file-manager .file-code-surface .file-code-search button:disabled{opacity:.3;cursor:default}
.file-search-options{display:flex;gap:1px}.file-search-options button:nth-child(2){text-decoration:underline;text-underline-offset:3px}.file-search-count{min-width:58px;text-align:center;color:#aeb9c8;font:11px ui-monospace,monospace;white-space:nowrap}
.file-manager .file-code-surface .file-code-search button[name=close]{position:static;top:auto;right:auto;font-size:22px}.file-manager .file-code-surface .cm-searchMatch{background:#c9ab5130;outline:1px solid #c9ab5160}.file-manager .file-code-surface .cm-searchMatch.cm-searchMatch-selected{background:#c28d3c60;outline:1px solid #ddb369}
.file-search-replacement{margin-top:6px;padding-left:34px}.file-manager .file-code-surface .file-search-replacement button{font:12px/1.4 ui-sans-serif,system-ui,sans-serif;border-color:#46505d;padding-inline:12px}.file-search-message{padding:7px 0 0 34px;color:#e1b4a2;font-size:12px}
@media(max-width:767px){.file-manager .file-code-surface .cm-panels-top{right:12px;width:calc(100% - 24px);max-width:540px}.file-manager .file-code-surface .file-code-search button{min-height:36px;height:36px;min-width:30px}.file-manager .file-code-surface .file-code-search input{height:36px;min-height:36px}.file-search-count{min-width:48px}.file-search-replacement{padding-left:34px}}
@media(max-width:480px){.file-search-row{flex-wrap:wrap}.file-search-input{flex-basis:calc(100% - 42px)}.file-search-count{margin-left:34px;margin-right:auto}.file-manager .file-code-surface .file-search-replacement>input{flex-basis:100%}}
.file-editor-status{display:flex;align-items:center;justify-content:flex-end;gap:18px;min-height:32px;padding:6px 16px;background:#23262c;border-top:1px solid #343941;color:#b7c0ce;font:11px/1.4 ui-monospace,monospace;flex:none;font-variant-numeric:tabular-nums}.file-editor-status>span{flex:none;white-space:nowrap}.file-editor-status>span:first-child{margin-right:auto}.file-editor-status output{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#dce1e8}
.file-editor-workspace .file-editor-status{box-sizing:border-box;width:100%;min-width:0;max-width:100%}
.file-editor-workspace .file-code-fallback{display:flex;flex-direction:column;flex:1;min-height:0}.file-code-fallback>div{display:flex;align-items:center;gap:16px;padding:12px 16px;background:#302b25;font-size:13px;flex:none}.file-code-fallback p{margin:0}.file-manager .file-editor-workspace .file-code-fallback textarea{flex:1;height:0;min-height:0;resize:none;background:#1c1e22;border:0;border-radius:0;font:14px/1.7 ui-monospace,monospace;padding:18px 24px}
.file-manager:has(.file-editor-workspace:not([hidden])) .file-upload-queue{visibility:hidden}
@media(max-width:767px){.file-editor-heading{min-height:56px;padding:8px 12px}.file-manager .file-editor-heading button,.file-manager .file-editor-workspace .file-code-toolbar button{min-height:44px}.file-editor-tab{max-width:65%;padding-inline:12px}.file-editor-tabs{gap:12px}.file-code-toolbar .framely-select{width:120px}.file-editor-status{gap:12px;padding-inline:12px}.file-editor-indent{display:none}}
@media(max-width:480px){.file-editor-heading strong{font-size:13px}.file-editor-heading>div{gap:6px}.file-manager .file-editor-heading button{padding:8px 10px}.file-editor-workspace .file-code-toolbar{flex-wrap:wrap;padding:4px 8px}.file-code-toolbar .framely-select{width:112px}.file-editor-status{flex-wrap:wrap;gap:6px 12px}.file-editor-tab{max-width:75%}.file-editor-tabs>code{display:none}}
.file-manager .file-property-grid code,.file-manager .file-permissions h3 code{display:inline;padding:0;background:none;border-radius:0;font:13px/1.6 ui-monospace,monospace}
+17 -7
View File
@@ -10,6 +10,9 @@ import {FileUploadQueue} from './file-upload-queue';
import {FileUploadPanel} from './file-upload-panel';
import {Select} from './localized-select';
import {SwitchRow} from './switch';
import {FileProperties} from './file-properties';
import {FileCodeEditor} from './file-code-editor';
import {IconCode,IconDeviceFloppy} from '@tabler/icons-react';
import {t} from './i18n';
import './manager-tools.css';
import './file-manager.css';
@@ -42,6 +45,7 @@ export function FileManager({active=true}:{active?:boolean}){
useEffect(()=>{if(!navigationOpen)return;const previous=document.activeElement as HTMLElement|null;const sidebar=document.querySelector<HTMLElement>('.file-navigation-open .file-sidebar');sidebar?.querySelector<HTMLButtonElement>('button')?.focus();const key=(e:KeyboardEvent)=>{if(e.key==='Escape'){e.preventDefault();setNavigationOpen(false);}if(e.key==='Tab'){const list=Array.from(sidebar?.querySelectorAll<HTMLElement>('button:not(:disabled),[tabindex="0"]')??[]).filter(el=>el.getClientRects().length);if(e.shiftKey&&document.activeElement===list[0]){e.preventDefault();list.at(-1)?.focus();}else if(!e.shiftKey&&document.activeElement===list.at(-1)){e.preventDefault();list[0]?.focus();}}};document.addEventListener('keydown',key,true);return()=>{document.removeEventListener('keydown',key,true);if(previous?.isConnected)previous.focus();};},[navigationOpen]);
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),[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 [editorReadOnly,setEditorReadOnly]=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){
@@ -81,10 +85,11 @@ export function FileManager({active=true}:{active?:boolean}){
// 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);}
try{const v=await managerApi('files',{operation:'read',path:e.path});setEditor({path:e.path,...v});setEditorReadOnly(false);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);}
}
async function editAsText(e:Entry){setMenu(null);setBusy(true);setError('');try{const v=await managerApi('files',{operation:'read',path:e.path,asText:true});setEditor({path:e.path,...v});setEditorReadOnly(!!v.readOnly);setModified(false);}catch(error){setError(String(error));}finally{setBusy(false);}}
const [,updateQueue]=useState(0),[dragTarget,setDragTarget]=useState(''),[preparing,setPreparing]=useState(false);
const prepareController=useRef<AbortController|null>(null),queueTimer=useRef<ReturnType<typeof setTimeout>|null>(null),refreshTimer=useRef<ReturnType<typeof setTimeout>|null>(null);
@@ -124,7 +129,6 @@ export function FileManager({active=true}:{active?:boolean}){
else if(d.kind==='rename'){await perform({operation:'rename',path:d.path,name:value});}
else if(d.kind==='delete'){await perform({operation:'delete',paths:selected,permanent,approve:permanent});setSelected([]);}
else if(d.kind==='paste'){await perform({operation:clipboard?.cut?'move':'copy',paths:clipboard?.paths,destination:path,conflict:policy,approve:policy==='overwrite'});if(clipboard?.cut)setClipboard(null);}
else if(d.kind==='properties'){await perform({operation:'chmod',path:d.entry!.path,mode:parseInt(value,8)});}
else if(d.kind==='compress'){const format=value.endsWith('.tar.gz')?'tar.gz':value.endsWith('.tar')?'tar':'zip';await perform({operation:'compress',paths:selected,path:path+'/'+value,format,conflict:policy,approve:policy==='overwrite'});}
else if(d.kind==='extract'){await perform({operation:'extract',path:d.path,destination:value,conflict:policy,approve:policy==='overwrite'});}
else if(d.kind==='trash.purge'){await perform({operation:'trash.purge',id:d.path,all:!d.path,approve:true});setTrash(await managerApi('files',{operation:'trash.list'}));}
@@ -134,7 +138,7 @@ export function FileManager({active=true}:{active?:boolean}){
else if(d.kind==='editor.overwrite'){const v=await perform({operation:'write',...editor,approve:true});setEditor(e=>e?{...e,...v}:null);setModified(false);}
setDialog(null);
}catch{}}
async function saveText(){if(!editor)return;try{const v=await perform({operation:'write',...editor});setEditor(e=>e?{...e,...v}:null);setModified(false);}catch(e){if((e instanceof ManagerError?e.raw:String(e)).includes('File changed outside the editor'))openDialog({title:t('保存冲突'),kind:'editor.overwrite'});}}
async function saveText(){if(!editor||editorReadOnly||busy)return;try{const v=await perform({operation:'write',...editor});setEditor(e=>e?{...e,...v}:null);setModified(false);}catch(e){if((e instanceof ManagerError?e.raw:String(e)).includes('File changed outside the editor'))openDialog({title:t('保存冲突'),kind:'editor.overwrite'});}}
const picked=shown.find(e=>e.path===selected[0]);
const selectedCount=selected.length;
const bookmarkCurrent=bookmarks.some(b=>b.path===path);
@@ -151,6 +155,7 @@ export function FileManager({active=true}:{active?:boolean}){
<button role={menu?'menuitem':undefined} disabled={!selectedCount||busy} onClick={()=>openDialog({title:t('创建压缩包'),kind:'compress',value:'archive.zip'})}><IconFileZip size={18}/>{t('压缩')}</button>
<button role={menu?'menuitem':undefined} disabled={selectedCount!==1||!picked||busy||loading} onClick={()=>openDialog({title:t('解压文件'),kind:'extract',path:selected[0],value:path})}><IconFolder size={18}/>{t('解压')}</button>
<button role={menu?'menuitem':undefined} disabled={selectedCount!==1||!picked||busy||loading} onClick={()=>void open(picked!)}><IconExternalLink size={18}/>{t('打开')}</button>
<button role={menu?'menuitem':undefined} disabled={selectedCount!==1||!picked||picked.directory||busy||loading} onClick={()=>void editAsText(picked!)}><IconFileText size={18}/>{t('以文本编辑')}</button>
</>;
const mobileCreateBar=<div className="file-mobile-action-list"><button disabled={busy} onClick={()=>openDialog({title:t('新建文件夹'),kind:'folder'})}><IconFolderPlus size={20}/>{t('新建文件夹')}</button><button disabled={busy} onClick={()=>openDialog({title:t('新建文件'),kind:'create'})}><IconPlus size={20}/>{t('新建文件')}</button><button disabled={busy} onClick={()=>fileInput.current?.click()}><IconUpload size={20}/>{t('上传文件')}</button><button disabled={busy} onClick={()=>void chooseUploadDirectory()}><IconFolderPlus size={20}/>{t('上传文件夹')}</button><button disabled={!clipboard||busy} onClick={()=>openDialog({title:t('粘贴文件'),kind:'paste'})}><IconClipboard size={20}/>{t('粘贴')}</button></div>;
const commandBar=<div className="file-command-bar"><div className="file-actions"><button disabled={busy} onClick={()=>openDialog({title:t('新建文件夹'),kind:'folder'})}><IconFolderPlus size={18}/>{t('新建文件夹')}</button><button disabled={busy} onClick={()=>openDialog({title:t('新建文件'),kind:'create'})}><IconPlus size={18}/>{t('新建文件')}</button><button disabled={!clipboard||busy} onClick={()=>openDialog({title:t('粘贴文件'),kind:'paste'})}><IconClipboard size={18}/>{t('粘贴')}</button></div><div className="file-actions"><button className="file-primary" disabled={busy} onClick={()=>fileInput.current?.click()}><IconUpload size={18}/>{t('上传文件')}</button>
@@ -165,7 +170,7 @@ export function FileManager({active=true}:{active?:boolean}){
{job&&<div className="file-notice file-progress"><span>{t(job.phase)}</span>{job.totalBytes?<progress value={job.bytes??0} max={job.totalBytes}/>:<progress/>}<span>{job.total?`${job.completed??0}/${job.total}`:job.visited?`${job.visited} · ${job.matches??0}`:job.bytes?bytes(job.bytes):''}</span>{!['done','failed','cancelled'].includes(job.phase)?<button onClick={()=>void api('job.cancel',{job:job.id}).catch(e=>setError(String(e)))}>{t('取消')}</button>:<button aria-label={t('关闭')} onClick={()=>setJob(null)}><IconX size={18}/></button>}</div>}
{outcomes.length>0&&<details className="file-outcomes"><summary>{t('逐项结果')} · {outcomes.length}</summary>{outcomes.map((item,index)=><p key={index}>{item.path} · {item.error??t(item.result?.skipped||item.skipped?'已跳过':'已完成')}</p>)}</details>}
{mobile&&navigationOpen&&<button className="file-navigation-scrim" aria-label={t('关闭文件导航')} onClick={()=>setNavigationOpen(false)}/>}
<div className="file-layout" ref={layout}>
<div className="file-layout" ref={layout} inert={!!editor||!!dialog}>
<aside className="file-sidebar" aria-label={t('文件导航')} inert={mobile&&!navigationOpen?true:undefined} role={mobile?'dialog':undefined} aria-modal={mobile&&navigationOpen?true:undefined}>
{mobile&&<div className="file-navigation-heading"><strong>{t('浏览文件夹')}</strong><button aria-label={t('关闭文件导航')} onClick={()=>setNavigationOpen(false)}><IconX size={22}/></button></div>}
<section className="file-quick-access"><h3>{t('快速访问')}</h3><div className="file-quick-access-list">
@@ -194,13 +199,14 @@ export function FileManager({active=true}:{active?:boolean}){
<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>
{mobile&&selected.length>0&&<div className="file-mobile-selection"><span>{t('已选择 {0} 项',{0:selected.length})}</span><button onClick={()=>setMobileTool('actions')}><IconDots size={20}/>{t('操作')}</button></div>}
{mobile&&selected.length>0&&<div className="file-mobile-selection" inert={!!editor||!!dialog}><span>{t('已选择 {0} 项',{0:selected.length})}</span><button onClick={()=>setMobileTool('actions')}><IconDots size={20}/>{t('操作')}</button></div>}
{active&&mobileTool&&mobile&&<MobileSheet title={t(mobileTool==='create'?'新建与上传':mobileTool==='filters'?'筛选与显示':'文件操作')} onClose={()=>setMobileTool(null)}><div onClick={event=>{if((event.target as HTMLElement).closest('button')&&mobileTool!=='filters')setMobileTool(null);}}>{mobileTool==='create'?mobileCreateBar:mobileTool==='filters'?filterBar:<div className="file-mobile-action-list">{actions}<button disabled={busy} onClick={()=>void getDownload()}><IconDownload size={20}/>{t('下载文件')}</button></div>}</div></MobileSheet>}
<FileUploadPanel active={active} queue={queue} limit={filePreferences.uploadConcurrency??3} onLimit={uploadConcurrency=>saveFilePreferences({uploadConcurrency},previous=>({...previous,uploadConcurrency}))}/>
{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);}}>
{menu.blank?<><button role="menuitem" disabled={busy} onClick={()=>openDialog({title:t('新建文件夹'),kind:'folder'})}><IconFolderPlus size={18}/>{t('新建文件夹')}</button><button role="menuitem" disabled={busy} onClick={()=>openDialog({title:t('新建文件'),kind:'create'})}><IconPlus size={18}/>{t('新建文件')}</button><button role="menuitem" disabled={!clipboard||busy} onClick={()=>openDialog({title:t('粘贴文件'),kind:'paste'})}><IconClipboard size={18}/>{t('粘贴')}</button><div role="separator"/><button role="menuitem" disabled={busy} onClick={()=>fileInput.current?.click()}><IconUpload size={18}/>{t('上传文件')}</button><button role="menuitem" disabled={busy} onClick={()=>void chooseUploadDirectory()}><IconFolderPlus size={18}/>{t('上传文件夹')}</button><button role="menuitem" onClick={refresh}><IconRefresh size={18}/>{t('刷新')}</button></>:<>{actions}<div role="separator"/><button role="menuitem" disabled={!selectedCount||busy} onClick={()=>void getDownload()}><IconDownload size={18}/>{t('下载文件')}</button>{picked?.directory&&selectedCount===1&&<button role="menuitem" disabled={busy} onClick={()=>openDialog({title:t('添加到快速访问'),kind:'bookmark',path:picked.path,value:picked.name})}><IconStar size={18}/>{t('添加到快速访问')}</button>}</>}
</div>}
{active&&dialog&&<div className={dialog.kind==='upload'?'modal file-upload-confirm':'modal'} role="dialog" aria-modal="true" aria-label={dialog.title}><h2>{dialog.title}</h2>{error&&<p className="error">{error}</p>}{dialog.kind==='properties'&&dialog.entry&&<div className="file-details"><p>{dialog.entry.path}</p><p>{bytes(dialog.entry.size)} · UID {dialog.entry.uid} / GID {dialog.entry.gid}</p>{dialog.entry.linkTarget&&<p>{dialog.entry.linkTarget}</p>}</div>}{['create','folder','rename','bookmark','bookmark.rename','compress','extract','properties'].includes(dialog.kind)&&<label>{t(dialog.kind==='properties'?'权限(八进制)':dialog.kind==='extract'?'目标目录':'名称')}<input autoFocus value={value} onChange={e=>setValue(e.target.value)}/></label>}
{active&&dialog?.kind==='properties'&&dialog.entry&&<FileProperties key={dialog.entry.path} entry={dialog.entry} onClose={()=>setDialog(null)} onSaved={refresh}/>}
{active&&dialog&&dialog.kind!=='properties'&&<div className={dialog.kind==='upload'?'modal file-upload-confirm':'modal'} role="dialog" aria-modal="true" aria-label={dialog.title}><h2>{dialog.title}</h2>{error&&<p className="error">{error}</p>}{['create','folder','rename','bookmark','bookmark.rename','compress','extract'].includes(dialog.kind)&&<label>{t(dialog.kind==='extract'?'目标目录':'名称')}<input autoFocus value={value} onChange={e=>setValue(e.target.value)}/></label>}
{dialog.kind==='upload'&&<div className="file-upload-review">
<div className="file-upload-destination"><IconFolder size={24}/><div><span>{t('上传到')}</span><code>{dialog.path}</code></div></div>
<div className="file-upload-summary"><span>{t('{0} 个文件',{0:dialog.entries?.filter(e=>!e.directory).length??0})}</span><span>{t('{0} 个文件夹',{0:dialog.entries?.filter(e=>e.directory).length??0})}</span><strong>{bytes(dialog.entries?.reduce((n,e)=>n+(e.file?.size??0),0)??0)}</strong></div>
@@ -217,6 +223,10 @@ export function FileManager({active=true}:{active?:boolean}){
{['paste','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(dialog.kind==='upload'?'确认后会替换同名文件,不删除目录中的其他文件。':'确认后会覆盖同名文件或整个同名文件夹。')}</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(dialog.kind==='upload'?'开始上传':'确认')}</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>}
{active&&editor&&<section className="file-editor-workspace file-editor-modal" hidden={!!dialog} inert={!!dialog} role="region" aria-label={t('文本编辑器')}>
<header className="file-editor-heading"><div><IconCode size={21}/><strong>{t('文本编辑器')}</strong></div><div><button disabled={busy||editorReadOnly||!modified} className="file-editor-save" title="Ctrl / Cmd + S" onClick={()=>void saveText()}><IconDeviceFloppy size={18}/>{t('保存')}</button><button disabled={busy} onClick={()=>{if(!modified)setEditor(null);else openDialog({title:t('未保存的修改'),kind:'editor.discard'});}}><IconX size={18}/>{t('关闭')}</button></div></header>
<div className="file-editor-tabs"><div className="file-editor-tab"><IconFileText size={17}/><strong>{editor.path.split('/').pop()}</strong>{modified&&<span className="file-editor-dirty" aria-label={t('未保存的修改')}/>}</div><code title={editor.path}>{editor.path.slice(0,editor.path.lastIndexOf('/'))||'/'}</code></div>
{error&&<p className="error" role="alert">{error}</p>}{editorReadOnly&&<p className="banner">{t('此文件包含二进制内容或不是 UTF-8,仅以文本预览,不允许保存以避免损坏原文件。')}</p>}<FileCodeEditor path={editor.path} value={editor.text} readOnly={editorReadOnly||busy||!!dialog} onSave={()=>void saveText()} onChange={text=>{setEditor(v=>v?{...v,text}:null);setModified(true);}}/>
</section>}
</section>;
}
+40
View File
@@ -0,0 +1,40 @@
import React,{useEffect,useState} from 'react';
import {IconFile,IconFolder,IconLock} from '@tabler/icons-react';
import {managerApi} from './manager-api';
import {t} from './i18n';
export type FilePropertiesData={name:string;path:string;directory:boolean;regular?:boolean;symlink:boolean;size:number;allocated?:number;modified?:number;accessed?:number;changed?:number;created?:number;mode:number;uid:number;gid:number;owner?:string;group?:string;inode?:string;device?:string;links?:number;linkTarget?:string;resolvedPath?:string;canChmod?:boolean};
export const validFileMode=(value:string)=>/^0?[0-7]{3}$/.test(value);
export function permissionText(mode:number){return [8,7,6,5,4,3,2,1,0].map((bit,i)=>{const present=!!(mode&(1<<bit));if(i===2&&(mode&0o4000))return present?'s':'S';if(i===5&&(mode&0o2000))return present?'s':'S';if(i===8&&(mode&0o1000))return present?'t':'T';return present?'rwx'[i%3]:'-';}).join('');}
const size=(n:number)=>`${n.toLocaleString()} B${n>=1024?` (${(n/1024**(n>=1024**3?3:n>=1024**2?2:1)).toFixed(2)} ${n>=1024**3?'GiB':n>=1024**2?'MiB':'KiB'})`:''}`;
export function FileProperties({entry,onClose,onSaved}:{entry:FilePropertiesData;onClose:()=>void;onSaved:()=>void}){
const [data,setData]=useState(entry),[mode,setMode]=useState((entry.mode&0o777).toString(8).padStart(3,'0')),[loading,setLoading]=useState(true),[busy,setBusy]=useState(false),[error,setError]=useState('');
useEffect(()=>{let alive=true;setLoading(true);void managerApi<FilePropertiesData>('files',{operation:'properties',path:entry.path}).then(value=>{if(alive){setData(value);setMode((value.mode&0o777).toString(8).padStart(3,'0'));}}).catch(e=>{if(alive)setError(String(e));}).finally(()=>{if(alive)setLoading(false);});return()=>{alive=false;};},[entry.path]);
const date=(value?:number)=>value===undefined||value===null?t('不可用'):new Date(value*1000).toLocaleString(document.documentElement.lang,{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'});
const writable=!loading&&!busy&&data.canChmod===true&&!data.symlink&&!!data.inode&&!!data.device;
const numeric=validFileMode(mode)?parseInt(mode,8):null;
const changed=numeric!==null&&numeric!==(data.mode&0o777);
const rows:[[string,React.ReactNode],...[string,React.ReactNode][]]=[
['完整路径',<code>{data.path}</code>],['文件类型',t(data.symlink?'符号链接':data.directory?'文件夹':data.regular===false?'特殊文件':'普通文件')],
[data.directory?'目录自身大小(不含内容)':'文件大小',size(data.size)],['磁盘占用',data.allocated===undefined?t('不可用'):size(data.allocated)],
['所有者',`${data.owner??t('未知')} (UID ${data.uid})`],['所属组',`${data.group??t('未知')} (GID ${data.gid})`],
['修改时间',date(data.modified)],['访问时间',date(data.accessed)],['状态变更时间',date(data.changed)],['创建时间',date(data.created)],
['Inode',data.inode??t('不可用')],['设备编号',data.device??t('不可用')],['硬链接数',data.links??t('不可用')]
];
if(data.linkTarget)rows.splice(2,0,['链接目标',<code>{data.linkTarget}</code>]);
if(data.symlink)rows.splice(3,0,['解析后的路径',<code>{data.resolvedPath??t('不可用')}</code>]);
async function save(){if(!writable||numeric===null||!changed)return;setBusy(true);setError('');try{await managerApi('files',{operation:'chmod',path:data.path,mode:numeric,identity:{device:data.device,inode:data.inode}});onSaved();onClose();}catch(e){setError(String(e));}finally{setBusy(false);}}
return <div className="modal file-properties-modal" role="dialog" aria-modal="true" aria-label={t('文件属性')} aria-busy={loading||busy}>
<header className="file-properties-heading">{data.directory?<IconFolder size={30}/>:<IconFile size={30}/>}<div><h2>{data.name}</h2><p>{t('文件属性')}</p></div></header>
{loading&&<p role="status">{t('正在读取属性…')}</p>}
{error&&<p className="error" role="alert">{error}</p>}
<dl className="file-property-grid">{rows.map(([label,value])=><div key={label}><dt>{t(label)}</dt><dd>{value}</dd></div>)}</dl>
<section className="file-permissions"><h3><IconLock size={19}/>{t('访问权限')} <code>{(data.mode&0o7000).toString(8)}{numeric===null?'???':mode.replace(/^0(?=\d{3}$)/,'')} / {permissionText((data.mode&0o7000)|(numeric??(data.mode&0o777)))}</code></h3>
<div className="file-permission-grid"><span/>{['读取','写入','执行/进入'].map(label=><span key={label}>{t(label)}</span>)}{['所有者','所属组','其他用户'].map((label,row)=><React.Fragment key={label}><strong>{t(label)}</strong>{[4,2,1].map((bit,col)=>{const mask=bit<<(6-row*3);return <button key={bit} type="button" role="checkbox" aria-label={`${t(label)}: ${t(['读取','写入','执行/进入'][col])}`} aria-checked={numeric!==null&&!!(numeric&mask)} disabled={!writable||numeric===null} onClick={()=>setMode((numeric!^mask).toString(8).padStart(3,'0'))}>{numeric!==null&&(numeric&mask)?'✓':'-'}</button>;})}</React.Fragment>)}</div>
<label>{t('权限(八进制)')}<input value={mode} maxLength={4} inputMode="numeric" disabled={!writable} aria-invalid={!validFileMode(mode)} onChange={e=>setMode(e.target.value)}/></label>
{!validFileMode(mode)&&<p className="error" role="alert">{t('请输入 000 至 777 的八进制权限。')}</p>}
{!loading&&(data.canChmod===false||data.symlink)&&<p>{t(data.symlink?'符号链接权限不可修改;此处显示链接本身的属性。':'当前用户不是文件所有者,无法修改权限。')}</p>}
{!!(data.mode&0o7000)&&<p>{t('现有特殊权限位会保留,本面板仅修改读、写、执行权限。')}</p>}
</section>
<footer><button disabled={busy} onClick={onClose}>{t('关闭')}</button><button className="primary" disabled={!writable||!validFileMode(mode)||!changed} onClick={()=>void save()}>{t('保存权限')}</button></footer>
</div>;
}
+36 -15
View File
@@ -21,22 +21,43 @@
.notification-inbox .notification-card{border-radius:16px;padding:22px;border-color:#ffffff18;background:#ffffff05}
.notification-inbox .notification-card .toast-actions button{border-radius:999px}
.notification-inbox .notification-card .toast-actions .notification-primary{background:var(--blue);color:#152331;border-color:var(--blue)}
.cast-quick{padding:4px 0 20px}
.cast-quick>header{display:flex;align-items:center;gap:12px;margin-bottom:24px;color:var(--blue)}
.cast-quick>header h1{font-size:28px;color:var(--text);flex:1}
.cast-quick>header button{width:44px;min-height:44px;padding:10px;background:transparent;border:0;border-radius:999px}
.cast-quick-stream{display:flex;flex-direction:column;align-items:center;padding:24px 16px 28px;border-radius:16px;background:#ffffff06;border:1px solid #ffffff14}
.cast-quick-stream>svg{color:var(--blue);margin-bottom:18px}
.cast-quick-stream h2{font-size:24px;margin-bottom:6px}
.cast-quick-stream p{font-size:16px;color:var(--muted);font-variant-numeric:tabular-nums;margin:0 0 22px}
.cast-quick-stream button{min-width:180px;border-radius:999px}
/* Quick casting is a spatial control surface, not a marketing page.
Preserve the app's dark tokens and fonts; 16px panel, 10px action corners.
Red recording color is semantic, not a second decorative accent. */
.cast-quick{padding:0 0 12px}
.cast-quick>header{display:flex;align-items:center;gap:10px;margin-bottom:18px;color:var(--blue)}
.cast-quick>header h1{font-size:26px;color:var(--text);flex:1;line-height:1.3}
.cast-quick>header button{min-height:48px;padding:8px 12px;background:transparent;border:1px solid var(--line);border-radius:10px;font-size:16px;color:var(--muted);white-space:nowrap}
.cast-quick-stream{padding:20px;border-radius:16px;background:#ffffff06;border:1px solid #ffffff1c}
.cast-quick-source{display:flex;align-items:center;justify-content:space-between;gap:16px}
.cast-quick-source>div{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
.cast-quick-source>svg{color:var(--muted)}
.cast-quick-stream h2{font-size:23px;margin:0;line-height:1.35}
.cast-quick-state{font-size:15px;color:var(--muted)}
.cast-quick-stream.is-live .cast-quick-state,.cast-quick-stream.is-live .cast-quick-source>svg{color:var(--blue)}
.cast-quick-stream .cast-quick-specs{display:flex;flex-wrap:wrap;gap:8px 16px;margin:12px 0 20px;color:var(--muted);font-size:16px;font-variant-numeric:tabular-nums}
.cast-quick-specs span{white-space:nowrap}
.cast-quick-specs.is-loading{visibility:hidden}
.cast-quick .cast-stream-actions{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px}
.cast-quick .cast-stream-actions>button{min-width:0;min-height:60px;border-radius:10px;padding:12px 8px;font-size:18px;white-space:nowrap}
.cast-quick-stream button.primary{background:var(--blue);color:#152331;border-color:var(--blue)}
.cast-quick-address{display:grid;gap:8px;padding:18px 0;color:var(--muted);font-size:16px}
.cast-quick-address code{font:15px/1.5 ui-monospace,monospace;color:var(--text);overflow-wrap:anywhere;user-select:all}
.cast-quick-receive{margin-top:28px}
.cast-quick-receive h2{font-size:17px;font-weight:500;color:var(--muted);margin-bottom:8px}
.cast-quick-receive label{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0;padding:14px 0}
.cast-quick-receive .ui-switch{flex:none}
.cast-quick-stream button.primary:hover:not(:disabled){background:#b5d7f2}
.cast-quick .cast-quick-framing{padding:18px 0;border-top:0;border-bottom:1px solid var(--line)}
.cast-quick .cast-quick-framing-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:20px}
.cast-quick .cast-quick-framing-row>button{min-height:52px;border-radius:10px;font-size:17px;padding:10px 16px}
.cast-quick .switch-row{min-height:52px;padding:0;gap:12px}
.cast-quick .switch-row b{font-size:17px;font-weight:500}
.cast-quick .switch-row small{font-size:14px;line-height:1.5}
.cast-quick .ui-switch{min-width:60px;width:60px;min-height:52px;border-radius:10px}
.cast-quick-recording-options{padding:16px 0}
.cast-quick .cast-record-path{font-size:12px;line-height:1.5;color:var(--muted);margin:10px 0 0;overflow-wrap:anywhere}
.cast-quick-address{display:grid;gap:6px;padding:0 0 18px;color:var(--muted);font-size:15px}
.cast-quick-address code{font:14px/1.5 ui-monospace,monospace;color:var(--text);overflow-wrap:anywhere;user-select:all}
.cast-quick-receive{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:0 20px;margin-top:0;border-top:1px solid var(--line);padding-top:16px}
.cast-quick-receive h2{grid-column:1/-1;font-size:15px;font-weight:500;color:var(--muted);margin:0 0 6px}
.cast-quick-receive label{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0;padding:0;font-size:18px;min-width:0}
.cast-quick .error{font-size:16px}
@media(max-width:420px){.cast-quick-stream{padding:16px}.cast-quick .cast-stream-actions{grid-template-columns:1fr}.cast-quick .cast-quick-framing-row{grid-template-columns:1fr;gap:12px}.cast-quick .cast-quick-framing-row>button{justify-self:start}.cast-quick-receive{grid-template-columns:1fr}.cast-quick>header button{font-size:14px}}
.quick nav button>span{font-size:16px}
@media(max-width:480px){.toasts .notification-card{padding:14px}.toasts .notification-heading{gap:8px}.toasts .notification-origin{flex-direction:row;gap:6px}.toasts .notification-countdown{font-size:11px}.toasts .notification-card .toast-actions button{flex:1;padding:10px 12px}.notification-inbox .notification-card{padding:18px}}
@media(prefers-reduced-motion:no-preference){.notification-card button,.cast-quick button{transition:background .15s,transform .15s}.notification-card button:active:not(:disabled),.cast-quick button:active:not(:disabled){transform:scale(.98)}}
+6 -6
View File
@@ -2,11 +2,11 @@ import {SwitchRow} from './switch';
import {t} from './i18n';
import React,{useEffect,useState} from 'react';
import {api} from './api';
export type ProxyConfig={http:string;github:string;httpEnabled?:boolean;githubEnabled?:boolean};
export type ProxyConfig={http:string;github:string;httpEnabled?:boolean;githubEnabled?:boolean;githubAutoFallback?:boolean};
export function ProxySettings({config,refresh}:{config:ProxyConfig;refresh:()=>Promise<void>}){
const[http,setHttp]=useState(config.http),[github,setGithub]=useState(config.github),[httpEnabled,setHttpEnabled]=useState(config.httpEnabled??false),[githubEnabled,setGithubEnabled]=useState(config.githubEnabled??false),[busy,setBusy]=useState(false),[error,setError]=useState('');
useEffect(()=>{setHttp(config.http);setGithub(config.github);setHttpEnabled(config.httpEnabled??false);setGithubEnabled(config.githubEnabled??false);},[config.http,config.github,config.httpEnabled,config.githubEnabled]);
async function save(){setBusy(true);setError('');try{await api('proxy.save',{http:http.trim(),github:github.trim(),httpEnabled,githubEnabled});await refresh();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
const unchanged=http===config.http&&github===config.github&&httpEnabled===(config.httpEnabled??false)&&githubEnabled===(config.githubEnabled??false);
return <section className="settings-card"><h2>{t("代理设置")}</h2><div className="proxy-fields"><div><SwitchRow checked={httpEnabled} label={t("启用 HTTP 代理")} disabled={busy} onChange={setHttpEnabled}/><label><input aria-label={t("HTTP 代理地址")} disabled={!httpEnabled} maxLength={2048} type="url" value={http} placeholder="http://192.168.1.2:7890" onChange={e=>setHttp(e.target.value)}/></label></div><div><SwitchRow checked={githubEnabled} label={t("启用 GitHub 代理")} disabled={busy} onChange={setGithubEnabled}/><label><input aria-label={t("GitHub 代理地址")} disabled={!githubEnabled} maxLength={2048} type="url" value={github} placeholder="https://gh-proxy.com" onChange={e=>setGithub(e.target.value)}/></label></div></div><div className="row"><button disabled={busy||unchanged||(httpEnabled&&!http.trim())||(githubEnabled&&!github.trim())} onClick={()=>void save()}>{busy?t("保存中…"):t("保存")}</button></div>{error&&<p className="error" role="alert">{error}</p>}</section>;
const[http,setHttp]=useState(config.http),[github,setGithub]=useState(config.github),[httpEnabled,setHttpEnabled]=useState(config.httpEnabled??false),[githubEnabled,setGithubEnabled]=useState(config.githubEnabled??false),[githubAutoFallback,setGithubAutoFallback]=useState(config.githubAutoFallback??true),[busy,setBusy]=useState(false),[error,setError]=useState('');
useEffect(()=>{setHttp(config.http);setGithub(config.github);setHttpEnabled(config.httpEnabled??false);setGithubEnabled(config.githubEnabled??false);setGithubAutoFallback(config.githubAutoFallback??true);},[config.http,config.github,config.httpEnabled,config.githubEnabled,config.githubAutoFallback]);
async function save(){setBusy(true);setError('');try{await api('proxy.save',{http:http.trim(),github:github.trim(),httpEnabled,githubEnabled,githubAutoFallback});await refresh();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
const unchanged=http===config.http&&github===config.github&&httpEnabled===(config.httpEnabled??false)&&githubEnabled===(config.githubEnabled??false)&&githubAutoFallback===(config.githubAutoFallback??true);
return <section className="settings-card"><h2>{t("代理设置")}</h2><div className="proxy-fields"><div><SwitchRow checked={httpEnabled} label={t("启用 HTTP 代理")} disabled={busy} onChange={setHttpEnabled}/><label><input aria-label={t("HTTP 代理地址")} disabled={!httpEnabled} maxLength={2048} type="url" value={http} placeholder="http://192.168.1.2:7890" onChange={e=>setHttp(e.target.value)}/></label></div><div><SwitchRow checked={githubEnabled} label={t("启用 GitHub 代理")} disabled={busy} onChange={setGithubEnabled}/><label><input aria-label={t("GitHub 代理地址")} disabled={!githubEnabled} maxLength={2048} type="url" value={github} placeholder="https://gh-proxy.com" onChange={e=>setGithub(e.target.value)}/></label></div></div><SwitchRow checked={githubAutoFallback} label={t("GitHub 连接失败时自动使用 gh-proxy.com")} description={t("公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。")} disabled={busy} onChange={setGithubAutoFallback}/><div className="row"><button disabled={busy||unchanged||(httpEnabled&&!http.trim())||(githubEnabled&&!github.trim())} onClick={()=>void save()}>{busy?t("保存中…"):t("保存")}</button></div>{error&&<p className="error" role="alert">{error}</p>}</section>;
}