Compare commits

...
Author SHA1 Message Date
toorux 176c4d0f8b release: 0.8.3-preview.2 2026-10-17 00:57:58 +08:00
toorux 2b43094404 test: update CEF download mocks and Steam artwork cleanup expectations 2026-10-17 00:57:47 +08:00
toorux 2afd1dff50 release: 0.8.3-preview.1 2026-10-17 00:45:43 +08:00
toorux d414fd2733 fix: handle unreachable GitHub routes and save from search fields 2026-10-17 00:45:20 +08:00
toorux ac6e3dc110 feat(files): add detailed properties and CodeMirror editor workspace 2026-10-16 23:52:29 +08:00
toorux b52b8d7e20 feat(cast): refine quick controls and add recording frame drag handle 2026-10-16 22:09:03 +08:00
toorux acf298991f fix(apk): repair Steam landscape artwork and separate shortcut icons 2026-10-16 22:08:57 +08:00
toorux 92d97d5b3b feat(apk): generate Steam artwork and keep watch access visible 2026-10-16 21:23:34 +08:00
toorux 2028c97039 fix(build): keep byte-download test helper out of production builds 2026-10-16 19:40:25 +08:00
toorux fe1c84d5e5 feat(network): retry public GitHub failures through an optional fallback proxy 2026-10-16 19:36:20 +08:00
toorux 493e6f66f9 feat(recording): allow hiding the live recording indicator 2026-10-16 19:24:47 +08:00
toorux 267c5dfff5 fix(apk): roll back fresh installs and clean damaged owned containers 2026-10-16 19:05:30 +08:00
toorux 7d9b5a23f4 release: 0.8.2 2026-10-16 00:42:32 +08:00
toorux b885b8f56a test(cast): add opt-in wearer framing visibility diagnostic 2026-10-16 00:40:27 +08:00
toorux 53ddf07f18 fix(cast): compensate stereo framing with live eye and UI depth 2026-10-16 00:40:10 +08:00
toorux b3c692e4a2 fix(ui): vibrate when hovering framing corner handles 2026-10-16 00:39:48 +08:00
toorux cbd13e7db2 fix(cast): restart streams and close framing editors reliably 2026-10-16 00:39:13 +08:00
toorux 9cf05cde09 feat(cast): add calibrated recording frame editor and region guide
Constrain frames to capture-eye visibility, restore menu sizing and add reset. Fix centered resize and trigger release handling, and prevent large native poll responses from losing framing commands.
2026-10-15 23:32:43 +08:00
toorux b23b39fc00 Record MP4 independently of casting with shared 4:3 defaults 2026-10-15 20:26:41 +08:00
toorux 2b7fd3098a Fix progressing download timeouts and release 0.8.2-preview.2 2026-10-14 11:21:15 +08:00
toorux 2f9471c3e4 Add casting recording controls and prepare 0.8.2-preview.1 2026-10-14 00:16:36 +08:00
toorux 1b847f9305 Apply Steam Frame factory panel orientation to capture 2026-10-13 23:06:03 +08:00
toorux d229ad2b09 Add revocable watch-only casting links and fix signed framing arguments 2026-10-13 21:48:39 +08:00
toorux 69831e474e Merge Quest app compatibility into main 2026-10-13 20:56:25 +08:00
toorux a078e8bfb1 Fix casting audio and pacing and show actual stream stats 2026-10-13 20:54:57 +08:00
toorux 926ee38b96 Fix casting audio and pacing and show actual stream stats 2026-10-13 20:54:47 +08:00
toorux 1e62eefb85 chore: prepare 0.8.0 preview.2 release 2026-10-12 22:45:46 +08:00
toorux d5a039dad9 feat(q2f): integrate FrameBridge into Quest conversion runtime 2026-10-12 22:35:08 +08:00
toorux fd3666f931 Defer Quest APK conversion until installation 2026-10-12 20:14:56 +08:00
toorux 76eba81eff Use supported Java version in preview release workflow 2026-10-12 08:16:04 +08:00
toorux 3427786053 Prepare 0.8.0 preview.1 release 2026-10-12 08:12:12 +08:00
toorux 2e5496a2bd Make Quest conversion an optional APK install switch 2026-10-12 08:02:35 +08:00
toorux cdb24fd31d Add optional q2f runtime and Quest APK conversion 2026-10-12 01:56:07 +08:00
toorux 3039c9ef0e Improve Quest permission UI and pending interface fixes 2026-10-12 01:40:22 +08:00
toorux 41137e1d26 Add per-app custom Steam names for sideloaded APKs 2026-10-11 23:01:32 +08:00
toorux d3628b0bcf Show storage usage details and refine storage settings for 0.7.1 2026-10-11 12:21:10 +08:00
toorux a9d61df041 Align storage settings with manager design 2026-10-11 11:17:37 +08:00
toorux c647aa28a0 Handle restricted overlay directories in storage settings and migration 2026-10-11 11:07:08 +08:00
toorux 6cb052f732 Allow same-version plugin replacement and configure storage locations 2026-10-10 23:29:38 +08:00
toorux 33941cd3f4 Preserve Android host folders when Lepton recreates storage links 2026-10-10 22:17:33 +08:00
toorux 1291925884 Protect APK data during package-manager container cleanup too 2026-10-10 20:27:03 +08:00
toorux ae35a7c487 Preserve installed APK data during Lepton 3 prefix cleanup 2026-10-10 20:12:12 +08:00
toorux 2a5e77a1c1 Support Lepton 3 APK overlay directory hooks without replacing app data 2026-10-10 19:41:03 +08:00
158 changed files with 9853 additions and 444 deletions

No files matched your search

+70 -4
View File
@@ -10,7 +10,33 @@ concurrency:
group: framely-release-${{ github.ref }}
cancel-in-progress: false
jobs:
framebridge:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Build pinned FrameBridge adapter from source
run: |
mkdir -p target/q2f-framebridge/source target/q2f-framebridge/artifacts/{arm64-v8a,armeabi-v7a}
curl -fsSL --retry 3 -o target/q2f-framebridge/source.tar.gz https://github.com/spoopyghosty0/frameport/archive/80ef9ca6a77eed1ca6b2cc6f746d9897e4178b4e.tar.gz
echo 'ea90ea8dd2f0fe49d54d2df5cc61c1876c9562e55fb40fce2651b1d5594dab7c target/q2f-framebridge/source.tar.gz' | sha256sum -c
tar -xzf target/q2f-framebridge/source.tar.gz -C target/q2f-framebridge/source --strip-components=1
FRAMEPORT_ARTIFACTS_OUT="$PWD/target/q2f-framebridge/artifacts" python3 target/q2f-framebridge/source/native/build.py --only adapter
for abi in arm64-v8a armeabi-v7a; do
test -s "target/q2f-framebridge/artifacts/$abi/libopenxr_loader_generic.so"
readelf -W --dyn-syms "target/q2f-framebridge/artifacts/$abi/libopenxr_loader_generic.so" | grep xrGetInstanceProcAddr
done
mkdir -p target/framebridge-upload/artifacts/{arm64-v8a,armeabi-v7a}
cp target/q2f-framebridge/source.tar.gz target/framebridge-upload/
for abi in arm64-v8a armeabi-v7a; do
cp "target/q2f-framebridge/artifacts/$abi/libopenxr_loader_generic.so" "target/framebridge-upload/artifacts/$abi/"
done
- uses: actions/upload-artifact@v4
with:
name: framely-framebridge
path: target/framebridge-upload/
if-no-files-found: error
frame:
needs: [framebridge]
runs-on: ubuntu-24.04-arm
env:
CARGO_HOME: ${{ github.workspace }}/target/framely-cargo
@@ -54,6 +80,38 @@ jobs:
key: cef-linux-arm64-154.0.32-682c378
- name: Fetch pinned CEF
run: test -d target/cef || bash tools/fetch-cef.sh
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
architecture: aarch64
- uses: actions/download-artifact@v4
with:
name: framely-framebridge
path: target/q2f-framebridge
- name: Prepare pinned q2f inputs
run: |
mkdir -p target/q2f-input
curl -fsSL --retry 3 -o target/q2f-input/cli-jar.zip https://github.com/ovrport/app/releases/download/1.2.3/cli-jar.zip
python3 - <<'PY'
import zipfile
with zipfile.ZipFile('target/q2f-input/cli-jar.zip') as source:
name = 'overportcli-1.2.3-all.jar'
with source.open(name) as data, open('target/q2f-input/' + name, 'xb') as output:
import shutil
shutil.copyfileobj(data, output)
PY
curl -fsSL --retry 3 -o target/q2f-input/libraries.zip https://files.crx.moe/ovrport/tracker/releases/download/3.4.3-23204ea/libraries.zip
curl -fsSL --retry 3 -o target/q2f-input/LICENSE https://raw.githubusercontent.com/ovrport/app/c790854/LICENSE
curl -fsSL --retry 3 -o target/q2f-input/source.tar.gz https://github.com/ovrport/app/archive/c790854.tar.gz
sha256sum -c <<'EOF'
307d58a69a5c9722f912d1b6566b0dd1e0ebe1440c6d1e56cab6cb883d74fef2 target/q2f-input/overportcli-1.2.3-all.jar
6cec6c176e1aeee8d9ec4f9b4e3982f71da7b44a36a293249620073a06f00e5e target/q2f-input/libraries.zip
2db2c0f597a8be01d9742180019627b4c23fd0213f04f5034fbad53f0d296eb3 target/q2f-input/source.tar.gz
EOF
jlink --strip-debug --no-man-pages --no-header-files --compress=2 \
--add-modules java.base,java.datatransfer,java.xml,java.prefs,java.desktop,java.logging,java.net.http,jdk.crypto.ec,jdk.unsupported \
--output target/q2f-input/java
- name: Validate
run: |
npm ci
@@ -66,11 +124,19 @@ jobs:
env:
RELEASE_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.ref_name }}
Q2F_JAVA_HOME: target/q2f-input/java
Q2F_CLI_JAR: target/q2f-input/overportcli-1.2.3-all.jar
Q2F_LIBRARIES_ZIP: target/q2f-input/libraries.zip
Q2F_LICENSE: target/q2f-input/LICENSE
Q2F_SOURCE_ARCHIVE: target/q2f-input/source.tar.gz
Q2F_FRAMEBRIDGE_SOURCE: target/q2f-framebridge/source.tar.gz
Q2F_FRAMEBRIDGE_ARM64: target/q2f-framebridge/artifacts/arm64-v8a/libopenxr_loader_generic.so
Q2F_FRAMEBRIDGE_ARM32: target/q2f-framebridge/artifacts/armeabi-v7a/libopenxr_loader_generic.so
run: |
bash tools/package-release.sh target/cef
mkdir -p release-assets
archive=$(find release -maxdepth 1 -name 'framely-*-linux-arm64.tar.gz' ! -name '*-offline-linux-arm64.tar.gz' ! -name 'framely-cef-*' -print -quit)
cp release/*.tar.gz release/framely-cef.json release-assets/
cp release/*.tar.gz release/framely-cef.json release/framely-q2f.json release-assets/
cp tools/bootstrap.py release-assets/bootstrap.py
cp install.sh release-assets/install.sh
"$CARGO_TARGET_DIR/release/framely" release-manifest --archive "$archive" --url "https://github.com/$RELEASE_REPO/releases/download/$RELEASE_TAG/$(basename "$archive")" --output release-assets/framely-release.json --changelog "Framely $RELEASE_TAG"
@@ -99,8 +165,8 @@ jobs:
RELEASE_TAG: ${{ github.ref_name }}
run: |
cd release-assets
sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json framely-cef.json > SHA256SUMS
printf 'Framely %s\n\nFirst install: choose the complete *-offline-linux-arm64.tar.gz package. Updates: choose the smaller Framely package without CEF. A separate framely-cef-* archive is available for runtime downloads.\n\nInstall on Frame over SSH using the repository install.sh. SHA256SUMS verifies archives; each archive also includes internal checksums.\n' "$RELEASE_TAG" > release-notes.txt
sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json framely-cef.json framely-q2f.json > SHA256SUMS
printf 'Framely %s\n\nFirst install: choose the complete *-offline-linux-arm64.tar.gz package. Updates: choose the smaller Framely package without CEF. Separate framely-cef-* and framely-q2f-* archives provide the browser and optional Quest converter.\n\nInstall on Frame over SSH using the repository install.sh. SHA256SUMS verifies archives; each archive also includes internal checksums.\n' "$RELEASE_TAG" > release-notes.txt
prerelease=false
latest=true
if [[ "$RELEASE_TAG" == *-* ]]; then
@@ -108,5 +174,5 @@ jobs:
latest=false
fi
gh release create "$RELEASE_TAG" --verify-tag --title "Framely $RELEASE_TAG" --notes-file release-notes.txt --draft --latest=false --prerelease="$prerelease"
gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json framely-cef.json
gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json framely-cef.json framely-q2f.json
gh release edit "$RELEASE_TAG" --draft=false --latest="$latest" --prerelease="$prerelease"
+3
View File
@@ -20,3 +20,6 @@
/media/bin/
/media/source/
# Local agent operating notes
/AGENTS.md
Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.7.0-preview.2"
version = "0.8.3-preview.2"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.7.0-preview.2"
version = "0.8.3-preview.2"
edition = "2021"
license = "AGPL-3.0-only"
+3 -3
View File
@@ -93,8 +93,8 @@ python3 -m unittest discover -s tests -p 'test_*.py'
See [release maintenance](docs/releases.md) for CEF builds, integration tests, and publishing commands. Plugins support `steamos` and `root`; hash verification is not a security review, so check the source and runtime user.
## License
## License and acknowledgments
Original Framely code, including the core, installer, SDK, template, and repository examples, uses **AGPL-3.0-only**; see [LICENSE](LICENSE). Third-party dependencies and vendored files retain their respective licenses.
Framely is licensed under **AGPL-3.0-only**; see [LICENSE](LICENSE). Third-party projects retain their own licenses.
AGPL allows commercial use. Distribution requires complete corresponding source under the license; modified software that interacts with users over a network must also offer source to those users. Release source corresponds to the repository's `v<VERSION>` or `installer-v<VERSION>` tags. Redistributors must provide modifications and build scripts matching their binaries.
Thanks to Lepton for the Android runtime, [OVRPort](https://github.com/ovrport/app) for Quest APK conversion, and [FramePort's FrameBridge](https://github.com/spoopyghosty0/frameport) for OpenXR graphics and controller support. The optional q2f package includes the OVRPort and FrameBridge licenses and corresponding source.
+3 -3
View File
@@ -93,8 +93,8 @@ python3 -m unittest discover -s tests -p 'test_*.py'
CEF 构建、集成测试和发布命令见[发行维护](docs/zh-CN/releases.md)。插件支持 `steamos` 和 `root`;哈希校验不代表安全审核,应核对来源和运行用户。
## 许可证
## 许可证与致谢
Framely 原创代码,包括本体、安装器、SDK、模板和仓库示例,采用 **AGPL-3.0-only**,全文见 [LICENSE](LICENSE)。第三方依赖和 vendored 文件保留各自许可证。
Framely 采用 **AGPL-3.0-only**,详见 [LICENSE](LICENSE)。第三方项目保留各自的许可证。
AGPL 允许商业使用;分发需按许可提供完整对应源码,修改后通过网络与用户交互还须向这些用户提供源码。发行源码对应本仓库的 `v<版本>` 或 `installer-v<版本>` 标签;重新分发者须提供与自己二进制匹配的修改和构建脚本。
感谢 Lepton 提供安卓运行环境、[OVRPort](https://github.com/ovrport/app) 提供 Quest APK 转换工具,以及 [FramePort 的 FrameBridge](https://github.com/spoopyghosty0/frameport) 提供 OpenXR 画面与手柄适配。可选 q2f 包附有 OVRPort 和 FrameBridge 的许可证及对应源码。
+32
View File
@@ -4,6 +4,14 @@
## 采集与网页观看
快捷面板和“网页观看”提供 **显示录制指示器** 开关,默认开启并持久保存。录制过程中也可切换;关闭仅隐藏头显右上角指示器及其点击区域,不停止或重启录制、不改变录制区域边框。隐藏后仍可通过两个面板的“停止录制”按钮停止。
“网页观看”和快捷面板的串流卡片均有 **开始/停止录制** 按钮,状态同步。录制与串流共用画面来源、取景、分辨率、帧率、码率、视频编码和声音设置;默认输出为 1920×1440(4:3),已有自定义设置保持不变。录制复用原始 H.264/H.265 视频,不进行二次视频编码,声音转为 AAC。文件保存到 Steam 用户的 `Videos/framely`(头显默认 `/home/steamos/Videos/framely`),使用唯一文件名和分片 MP4 容器,不覆盖已有视频。
录制与网页串流独立启停:点击录制直接启动本地采集和录制,不开启网页观看或 HLS 服务;之后开启串流会复用正在录制的采集编码,不重启录制。停止串流不停止录制,停止录制不停止串流,两者都停止时释放采集资源。录制期间不能修改采集参数或调整取景;接收投屏设置不受影响。
录制期间头显右上角显示红点、计时和停止图标。指向指示器并点击可停止当前录制;显示指示器不请求场景输入焦点或开启全局激光模式。录制进程异常退出时取消指示并在面板显示错误,已经写入的视频保留。正常关闭会话时尝试完成文件收尾;突然断电无法保证最后一段数据完整。
**屏幕采集**读取头显实际扫描输出,包括透视、Steam 菜单和空间窗口。核心 root 服务只启动固定的 `framely-panel-grab` 助手,通过受 Steam 用户身份检查的私人 Unix socket 导出 DRM framebuffer 的 DMA-BUF。其余采集、Vulkan 畸变校正、取景及 Iris H.264/H.265 编码均以 Steam 用户运行;不设置应用 capability,也不依赖 Framecorder 插件。
Framecorder 原插件的名称是“面板画面”和“SteamVR 备用画面”(CLI 为 `panel` / `headset`)。Framely 使用“屏幕采集”和“SteamVR”,避免把参考实现的应用名当作采集方式。
@@ -16,6 +24,8 @@ 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 硬件编码。
## 接收与发送
@@ -71,4 +81,26 @@ DISPLAY=:0 FRAMELY_MEDIA_BIN=/绝对路径/media/bin \
自动通过的协议/编解码测试不能替代真实发送端兼容性、头显内视觉比例和用户感知延迟验收。
快捷面板的录屏模块提供 **画幅调整**:透明空间窗口中的框可以移动、等比例缩放,并修改画幅比例(包括 3:4)及录制分辨率。应用前使用实际采集 LUT 检查镜片可见范围,超出范围时拒绝保存;录制中不能修改取景。**录制区域显示** 独立持久保存,只在实际录制或串流时显示无交互的淡色虚线框,停止后隐藏;空闲时开启开关不要求 SteamVR 已启动。框的内边缘对应录制边界,颜色读取 SteamVR 边界颜色设置。
画幅调整窗口和虚线框保持双眼可见,但几何范围由实际录制眼决定;跟随主力眼时同样使用采集进程实际报告的眼睛。双眼显示不是双眼录制,边界检查需分别覆盖左右眼。
调整窗口以实际录制眼的完整 render bounds 为固定参考,不能按当前取景大小缩小参考范围,否则缩小后重新打开会无法恢复放大。拖动四角以矩形中心等比例缩放;最小尺寸由中间菜单的实际布局尺寸决定,保存和改变比例时同样执行该限制。网页在松开、取消、失去指针捕获、窗口失焦时结束拖拽;原生层保留移动事件的按下标志,并使用现有物理扳机状态在丢失松开事件时补发释放,避免悬停继续缩放。采集标记对齐不能替代佩戴时的可见范围验收。
菜单的视觉角尺寸独立于参考画布,扩大画布时不能同步放大菜单。采集 LUT 输出由同一个 `shown` 判定生成的有效区域网格,前端使用该网格实时限制拖动、缩放和比例变化,不仅在应用时拒绝越界。网格检查保留一格外侧余量,使外绘边线不会跨入无效区;这个保守上限不改变取景边界与实际录制画面的投影对应关系。**重置大小** 保留画幅比例和分辨率,恢复居中的最大有效框;后端应用时仍做精确有效区域检查。元数据读取必须并发排空 stdout,不能等待进程退出后再读取,以免有效区域数据塞满较小的管道。
提交调整时携带打开窗口时的实际 `expectedEye`,后端重新读取采集几何后进行核对;主力眼在编辑期间改变时拒绝应用,要求重新打开,不能用旧眼的有效范围验证新眼。
应用时以当前串流运行状态为准:正在串流则用保存后的取景重启,未串流则不自动开启。保存成功即结束编辑会话;后续重启失败仍保持窗口关闭并明确报告“取景已保存”,不能让过期窗口恢复编辑。原生宿主通过每次轮询返回的 `framingEditor` 状态补偿可能丢失的关闭命令;后台忙时返回 `null`,宿主保留当前状态。普通轮询的空关闭/停止回执不得等待串流锁。四角悬停复用界面的振动设置。
空间框与采集共用工厂面板旋转、相机内参及 `grow_for_undistort` 投影;不能用通用 OpenVR render frustum 替代该投影。运行中的框只接受当前采集进程输出的几何信息,缺少有效校准时隐藏并报错,不显示近似框。
可见范围同时检查工厂畸变 UV 中的面板覆盖和 OpenVR render projection 中的 HiddenAreaMesh;两套坐标不可混用,也不能跟随面板扫描方向翻转隐藏网格。左右眼分别检查,边界标记测试覆盖最外侧像素。正式框和校准标记使用相同的前景层级,避免被 Steam 空间窗口遮挡。
上述检查证明的是软件渲染/采样有效范围,不是当前佩戴者的完整光学可见范围。OpenVR 的 HiddenAreaMesh 保证被遮区域不可见,但补集并不保证在所有瞳位、眼距下可见;不得把标记录制对齐测试当作鼻侧可见问题已经通过验收。鼻侧边界需要闭另一眼进行真实佩戴验证,不能通过任意翻转坐标或猜测裁剪半径来宣称修复。
双眼辅助框采用实时 `GetEyeToHeadTransform` 的实际选眼原点(含当前瞳距),而不是固定或上次启动时的眼距。保持取景角度不变,按中心射线与当前可见 Steam/Framely 窗口的真实交点计算参考深度,框宽度同步为 `2 × depth × tanHalfFov`。因此录制眼里的边界不变,另一眼的视差由实测窗口距离决定,减少原固定 2 米平面对近处窗口的错位。拖动时冻结深度以保持激光交互稳定;暂时移出窗口时保留最近实测深度。首次没有可测窗口时仍使用 2 米近似,不能宣称读取了任意游戏的场景深度;倾斜/曲面窗口在中心处匹配,整个边框仍是平面近似。改变参考深度不会扩大录制眼的光学可见范围。
仅在用户同意短暂显示校准标记后,运行 `cast::region_device_test::recording_region_device_alignment` 忽略测试。指定 `FRAMELY_REGION_TEST_MEDIA` 和 `FRAMELY_REGION_TEST_MARKER`,后者由 `tests/recording_region_device.cpp` 编译。测试覆盖左右眼、4:3、16:9、竖屏、偏移及高分辨率,以实际录制帧内四角和中心标记的位置检查空间框与输出是否一致;使用隔离临时目录,不替换正式安装文件。
`tests/cast_frame.cpp` 验证原生视频与透明控件的预乘 Alpha 合成、控件纹理保留和收起后的完整画面,需要 GLX 环境。
+4 -4
View File
@@ -38,7 +38,7 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven
Device and installer share the repository with independent versions/tags/workflows:
- Device: root Cargo version, `v<version>` (currently `v0.7.0-preview.2`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Device: root Cargo version, `v<version>` (currently `v0.7.1`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.10`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
Versions need not match or ship together. Update the relevant Cargo.lock with version changes. Installer package/macOS versions use the installer version.
@@ -47,8 +47,8 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
```bash
# After committing and pushing the intended source:
git tag v0.7.0-preview.2
git push origin v0.7.0-preview.2
git tag v0.7.1
git push origin v0.7.1
# Independent installer release:
git tag installer-v0.4.1-preview.10
git push origin installer-v0.4.1-preview.10
@@ -58,7 +58,7 @@ Prerelease suffixes create GitHub Prereleases without Latest. Installer releases
Only tags publish. After every product build succeeds, publication creates a draft, uploads assets/checksums, then makes it public. Failed drafts are not stable download targets. Manual runs leave Actions artifacts only.
Device attachments include the core archive without CEF, the complete `*-offline-linux-arm64.tar.gz` archive, a separate `framely-cef-*` runtime archive, `framely-release.json`, `framely-cef.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. The update descriptor always targets the core archive. First installation selects the complete offline archive. CEF is verified and shared under `/home/.framely/cef/<runtime-id>`; missing or newly required runtimes download separately, and previous runtimes remain available for rollback. See [package selection and runtime recovery](user-guide/installation.md#release-packages-and-cef). Installer releases have five platform packages and checksums only. Desktop installers download archive/checksums without user-managed scripts.
Device attachments include the core archive without CEF, the complete `*-offline-linux-arm64.tar.gz` archive, a separate `framely-cef-*` runtime archive, an optional `framely-q2f-*` Quest conversion archive, `framely-release.json`, `framely-cef.json`, `framely-q2f.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. The update descriptor always targets the core archive. First installation selects the complete offline archive. CEF is verified and shared under `/home/.framely/cef/<runtime-id>`; missing or newly required runtimes download separately, and previous runtimes remain available for rollback. The q2f archive is downloaded only when Quest conversion is chosen; each release pins a verified runtime identity, and already installed APKs are not automatically reconverted. See [package selection and runtime recovery](user-guide/installation.md#release-packages-and-cef). Installer releases have five platform packages and checksums only. Desktop installers download archive/checksums without user-managed scripts.
External checksums cover downloaded archives; internal checksums cover extracted payload. Neither independently proves author identity. Online installation trusts the chosen repository/HTTPS, local installation the supplied archive/checksums. Redirects never downgrade HTTPS.
+17 -1
View File
@@ -1,5 +1,7 @@
# APK Manager
In **APK → Apps → Manage**, set **Custom Steam name** for each app. Leave it blank to restore the original app name. The name survives APK updates and is applied to existing and future Steam entries; if Steam is unavailable, it syncs when Steam is ready. This does not rename the app in Framely or change its package, container or saved data.
Global controller and container lifecycle preferences are under **APK → Settings**, separate from the application list. Per-application display and launch preferences remain in each app’s management dialog.
[简体中文](../zh-CN/user-guide/apk-manager.md)
@@ -89,7 +91,7 @@ Do not invoke Lepton's global cleanup commands as an application-specific uninst
## Supported formats
The initial implementation supports individual APKs. Split APK bundles, XAPK/APKM, OBB import, Google Play installation and automatic Steam shortcut creation are outside this version. Unsupported bundles are rejected rather than treated as ordinary APKs.
The initial implementation supports individual APKs. Optional Quest conversion downloads and verifies a separate `framely-q2f-*` runtime when selected in the APK install review. It preserves the original APK and reviews the converted, newly signed APK before installation. The runtime and per-package signing keys live in the configured APK storage location and move with it. An existing original APK may reject the new signature; Framely will not silently uninstall it or erase saves. Updating q2f does not automatically reconvert installed games. Games can continue downloading their own OBB data into persistent container storage. Split APK bundles, XAPK/APKM, manual OBB import, Google Play installation and automatic Steam shortcut creation are outside this version. Unsupported bundles are rejected rather than treated as ordinary APKs.
### Flat window orientation
@@ -161,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.
@@ -193,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.
+1 -1
View File
@@ -72,7 +72,7 @@ Both services should be `active`. Open SteamVR Dashboard and find Framely in the
## Update and rollback
When an update source is configured, choose **Stable** or **Testing** in **About → Framely update**, then check, download and confirm installation. Stable checks stable releases only; Testing checks prereleases such as Preview, Beta and RC. The channel preference persists on the device. Switching channels clears the previous candidate and download, so check and download again before installation. Alternatively, use the desktop installer or run on Frame:
When an update source is configured, choose **Stable** or **Testing** in **About → Framely update**, then check, download and confirm installation. Stable checks stable releases only; Testing checks both stable releases and prereleases such as Preview, Beta and RC, then selects the newest version. The channel preference persists on the device. Switching channels clears the previous candidate and download, so check and download again before installation. Alternatively, use the desktop installer or run on Frame:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- update --version v0.4.2-preview.3
+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.
+5 -5
View File
@@ -42,13 +42,13 @@ framely call system.source.save '{"source":{"url":"https://example.org/framely-r
每次构建生成不含 CEF 的本体包 `framely-<版本及构建号>-linux-arm64.tar.gz`、完整离线包 `framely-<版本及构建号>-offline-linux-arm64.tar.gz`、独立运行库 `framely-cef-<运行库标识>-linux-arm64.tar.gz`。更新清单 `framely-release.json` 固定指向不含 CEF 的本体包;首次安装固定选择完整离线包。`framely-cef.json` 提供独立 CEF 的 HTTPS 下载地址、大小及 SHA256。
CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时复用;旧安装中相同的运行库自动迁移。所需运行库缺失或 CEF 版本更换时单独下载并校验,各发行的运行库保留用于回滚。完整包可离线提供 CEF。参见[发行包与运行库恢复](user-guide/installation.md#发行包与-cef)。
CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时复用;旧安装中相同的运行库自动迁移。所需运行库缺失或 CEF 版本更换时单独下载并校验,各发行的运行库保留用于回滚。完整包可离线提供 CEF。参见[发行包与运行库恢复](user-guide/installation.md#发行包与-cef)。可选的 Quest 转换工具另打 `framely-q2f-*` 包,发行包声明经过验证的确切版本;只有用户选择 Quest 转换时才校验缓存并按需下载,Framely 本体更新不自动重转已安装 APK。
## GitHub Actions 发布
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.7.0-preview.2`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.7.1`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.10`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
@@ -59,8 +59,8 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
```bash
# 提交并推送代码后,选择需要发布的产品
git tag v0.7.0-preview.2
git push origin v0.7.0-preview.2
git tag v0.7.1
git push origin v0.7.1
# 独立发布安装器,不触发 Framely 构建
git tag installer-v0.4.1-preview.10
@@ -71,7 +71,7 @@ git push origin installer-v0.4.1-preview.10
只有标签触发会发布。各工作流在自己的构建全部成功后创建草稿 Release,上传本产品产物及外部 `SHA256SUMS`,最后公开草稿。Framely Release 额外包含 `bootstrap.py`、入口脚本和更新清单;安装器 Release 只包含五个平台的安装器包和校验文件。失败的草稿不作为最新稳定版提供下载。手动触发用于验证,仅保留 Actions artifacts。
Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为不含 CEF 的更新包,`framely-*-offline-linux-arm64.tar.gz` 为首次安装用的完整离线包,`framely-cef-*` 为独立运行库,`framely-cef.json` 提供运行库下载信息;`framely-release.json` 为内置更新功能的版本清单;`install.sh` 是命令行入口,调用 `bootstrap.py` 完成下载、校验和维护;`SHA256SUMS` 为附件校验清单。桌面安装器只需自动下载运行包和校验清单,用户无需手动下载脚本。安装器的五个平台包发布在独立的安装器 Release 中。
Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为不含 CEF 的更新包,`framely-*-offline-linux-arm64.tar.gz` 为首次安装用的完整离线包,`framely-cef-*` 为独立运行库,`framely-cef.json` 提供运行库下载信息;`framely-q2f-*` 为可选 Quest 转换工具包,`framely-q2f.json` 提供下载信息;`framely-release.json` 为内置更新功能的版本清单;`install.sh` 是命令行入口,调用 `bootstrap.py` 完成下载、校验和维护;`SHA256SUMS` 为附件校验清单。桌面安装器只需自动下载运行包和校验清单,用户无需手动下载脚本。安装器的五个平台包发布在独立的安装器 Release 中。
外部 `SHA256SUMS` 包含压缩包哈希,校验传输内容;包内同名文件校验解压后的载荷。哈希不能独立证明作者身份,在线安装依赖指定 GitHub 仓库与 HTTPS;本地安装依赖用户提供的可信包与校验文件。下载遵循 HTTPS 重定向,不允许降级。
+19 -1
View File
@@ -1,5 +1,7 @@
# APK 管理
在 **APK → 应用 → 管理** 中,可以为每个应用设置 **自定义 Steam 名称**。留空恢复应用原名;更新 APK 后保留,并应用于已有和以后创建的 Steam 入口。Steam 暂不可用时,会在就绪后同步。此设置不改变 Framely 中的应用名称、包名、容器或存档。
全局手柄与容器生命周期选项位于 **APK → 设置**,不再占用应用列表。每个应用的显示与启动选项仍保留在对应应用的管理弹窗内。
[English](../../user-guide/apk-manager.md)
@@ -26,6 +28,8 @@ APK 管理用于安装和管理 Steam Frame 上的 Lepton 侧载应用,本地
点击“安装 APK”,选择单个 `.apk`,核对名称、包名、版本、最低 Android SDK 和目标容器。在 Frame 内选择本机 APK 会直接在设备上校验,不经过浏览器上传;确认阶段保留独立本机副本,不修改原文件。网络上传暂存于 Frame 持久化文件系统,避免占满较小的 `/tmp`。上传显示真实字节进度;后续显示处理阶段,不使用假百分比。
Quest 游戏可在安装审核中点击“转换此 APK”。Framely 首次使用时按发行包声明的标识下载独立的 `framely-q2f-*` 工具包,校验完整归档和内部文件,之后复用已验证缓存;普通 APK 安装无需下载。转换调用 OVR Port,原始 APK 不修改,转换结果重新读取并再次审核后才安装。转换会重新签名;原版 APK 已安装时可能出现签名不匹配,Framely 不会自动卸载应用或清除存档。工具包和每个包名的签名密钥保存在配置的 APK 存储目录,随该目录迁移;转换临时文件也使用该位置,不使用 `/tmp`。工具包更新只影响之后发起的转换,不自动重新转换已安装游戏。转换结果能否在 Frame 运行仍取决于游戏。
在 Frame 内,两个管理面板的文件选择入口统一使用 Framely 内置文件选择器,包括 APK、插件包和语言文件。默认打开下载目录,记住上次选择的目录;左侧为按需加载的文件夹树,覆盖主目录、临时目录和已挂载存储,支持逐级展开、收起和当前路径定位,提供目录导航、手动输入路径、搜索、隐藏文件和文件类型筛选。选中文件后确认;B/Escape 只取消选择器,不关闭管理面板。外部浏览器继续使用系统文件选择器。启动台右侧的“安装APK”按钮打开 APK 管理面板。
共享容器中的 APK 使用各自独立的 Android 安装目录。容器重启会保留 Android 外部存储中的已下载资源,包括未完成的下载片段和更新进度记录。无法识别的 Lepton 存储初始化脚本会在执行前拒绝,避免丢弃文件;游戏是否继续下载仍由自身更新器决定。旧版单 APK 挂载会在保存的安装包可用时恢复,旧 OBB 资源链接会在替换安装前迁移,并保留原目标。这只能防止后续丢失,旧版启动流程已经删除的下载资源无法仅靠 APK 还原。
@@ -95,7 +99,7 @@ Android 清除已卸载包的保留状态时可能需要原签名 APK。Framely
## 格式范围
首版支持单个 APK。不包含分包、XAPK/APKM、OBB 导入、Google Play 安装及自动创建 Steam 快捷方式;不支持的包格式会明确拒绝。
首版支持单个 APK。Quest 兼容转换同样只接受单个 APK;游戏自行下载的 OBB 资源继续使用持久化容器存储。不包含分包、XAPK/APKM、手动 OBB 导入、Google Play 安装及自动创建 Steam 快捷方式;不支持的包格式会明确拒绝。
### 平面窗口方向
@@ -129,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 启动占用其数据。跨进程保留的占用锁阻止重复启动及共享数据的并发启动。
@@ -145,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 界面会暂时关闭并重新启动。
+1 -1
View File
@@ -72,7 +72,7 @@ sudo /var/lib/framely/current/bin/framely status
## 更新与回滚
已配置更新服务时,在“关于 → Framely 更新”选择“正式版”或“测试版”,再检查、下载并确认安装。正式版只检查正式 Release,测试版只检查 Preview、Beta、RC 等预发布版本;渠道选择保存在设备上。切换渠道会清除原候选版本和已下载包,需要重新检查和下载。也可以用桌面安装器,或在 Frame 执行:
已配置更新服务时,在“关于 → Framely 更新”选择“正式版”或“测试版”,再检查、下载并确认安装。正式版只检查正式 Release;测试版同时检查正式版和 Preview、Beta、RC 等预发布版本,并选择最新版本。渠道选择保存在设备上。切换渠道会清除原候选版本和已下载包,需要重新检查和下载。也可以用桌面安装器,或在 Frame 执行:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- update --version v0.4.2-preview.3
@@ -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("保存")
+104 -11
View File
@@ -5,6 +5,7 @@
use anyhow::{bail, Result};
use framely_capture::openvr::OpenVr;
use crate::panel_geometry::PanelGeometry;
/// Output pixels between grid points. The mapping is smooth, 8 is plenty.
const STEP: u32 = 8;
@@ -26,6 +27,10 @@ const PANELS_ROTATED: bool = true;
pub struct Lut {
pub view: Option<(f64, f64, f64)>,
pub capture_to_head: Option<[[f64; 4]; 3]>,
pub region_fully_visible: bool,
pub editor_reference_tan: Option<f64>,
pub visibility: Option<serde_json::Value>,
pub width: u32,
pub height: u32,
pub step: f32,
@@ -48,6 +53,10 @@ impl Lut {
let height = out_h.div_ceil(STEP) + 1;
Self {
view: None,
capture_to_head: None,
region_fully_visible: false,
editor_reference_tan: None,
visibility: None,
width,
height,
step: STEP as f32,
@@ -113,6 +122,7 @@ struct EyeModel {
t: f64,
b: f64,
roll: f64,
panel_geometry: Option<PanelGeometry>,
/// Panel -> render target, sampled on a grid, per channel.
forward: Vec<[[f64; 2]; 3]>,
/// Render target spots the compositor never draws.
@@ -125,6 +135,20 @@ impl EyeModel {
fn new(vr: &OpenVr, eye: usize) -> Result<Self> {
let proj = vr.projection(eye);
let roll = vr.eye_roll(eye);
// Parallel render cameras report identity even though the physical
// Frame panels are clocked. Do not apply both transforms twice.
let panel_geometry = if roll.abs() < 1e-5 {
match PanelGeometry::load(eye) {
Ok(geometry) => {
log::info!("eye {eye}: experimental factory panel orientation correction enabled");
Some(geometry)
}
Err(error) => {
log::warn!("eye {eye}: factory panel orientation unavailable, retaining OpenVR mapping: {error}");
None
}
}
} else { None };
log::info!(
"eye {eye} projection: {proj:?}, render target {:?}, roll {:.2}°",
vr.render_target_size(),
@@ -139,12 +163,17 @@ impl EyeModel {
for j in 0..n {
for i in 0..n {
let (u, v) = (i as f32 / (n - 1) as f32, j as f32 / (n - 1) as f32);
if let Some(d) = vr.distortion(eye, u, v) {
forward[j * n + i] = d.map(|c| [c[0] as f64, c[1] as f64]);
let (du, dv) = if panel_geometry.is_some() { (1. - u, 1. - v) } else { (u, v) };
if let Some(d) = vr.distortion(eye, du, dv) {
forward[j * n + i] = d.map(|c| if panel_geometry.is_some() {
[1. - c[0] as f64, 1. - c[1] as f64]
} else { [c[0] as f64, c[1] as f64] });
}
}
}
// HiddenAreaMesh belongs to the OpenVR render projection, not the
// factory distortion UV space. Never rotate it with panel scanout.
let triangles = vr.hidden_area(eye);
let hidden = rasterize(&triangles);
if log::log_enabled!(log::Level::Debug) {
@@ -168,27 +197,36 @@ impl EyeModel {
t: proj.top as f64,
b: proj.bottom as f64,
roll,
panel_geometry,
forward,
hidden,
covered,
})
}
fn render_target(&self, tx:f64, ty:f64) -> [f64;2] {
let (s,c)=(-self.roll).sin_cos();
[(c*tx-s*ty-self.l)/(self.r-self.l),(s*tx+c*ty-self.t)/(self.b-self.t)]
}
/// Render target UV for a view direction given as tangents, with the
/// eye's roll undone so the video stays level with the head.
fn target(&self, tx: f64, ty: f64) -> [f64; 2] {
let (s, c) = (-self.roll).sin_cos();
let (rx, ry) = (tx * c - ty * s, tx * s + ty * c);
[(rx - self.l) / (self.r - self.l), (ry - self.t) / (self.b - self.t)]
if let Some(geometry)=self.panel_geometry {return geometry.render_uv(tx,ty);}
self.render_target(tx,ty)
}
/// Whether a render target spot is on the panel and actually drawn.
fn shown(&self, uv: [f64; 2]) -> bool {
fn shown(&self, tx:f64, ty:f64) -> bool {
let uv=self.target(tx,ty);
if !(0.0..1.0).contains(&uv[0]) || !(0.0..1.0).contains(&uv[1]) {
return false;
}
let i = (uv[1] * MASK_SIZE as f64) as usize * MASK_SIZE + (uv[0] * MASK_SIZE as f64) as usize;
self.covered[i] && !self.hidden[i]
if !self.covered[i] {return false;}
let render=self.render_target(tx,ty);
if !(0.0..1.0).contains(&render[0]) || !(0.0..1.0).contains(&render[1]) {return false;}
let i=(render[1]*MASK_SIZE as f64) as usize*MASK_SIZE+(render[0]*MASK_SIZE as f64) as usize;
!self.hidden[i]
}
/// Panel position (eye local) that shows this render target spot in channel `c`.
@@ -204,8 +242,7 @@ impl EyeModel {
/// horizontal half-angle tangent plus a center offset (also tangents).
/// The center may drift a few degrees off straight ahead when that buys
/// a noticeably wider view, since the lens isn't symmetric.
fn widest(&self, aspect: f64) -> (f64, f64, f64) {
let fits = |tan_h: f64, cx: f64, cy: f64| {
fn region_fits(&self, tan_h: f64, cx: f64, cy: f64, aspect: f64) -> bool {
let tan_v = tan_h * aspect;
(0..=EDGE_SAMPLES).all(|k| {
let f = k as f64 / EDGE_SAMPLES as f64 * 2.0 - 1.0;
@@ -216,9 +253,27 @@ impl EyeModel {
(cx + tan_h, cy + f * tan_v),
]
.iter()
.all(|&(x, y)| self.shown(self.target(x, y)))
.all(|&(x, y)| self.shown(x, y))
})
};
}
fn visibility_grid(&self, extent:f64) -> serde_json::Value {
const N:usize=256;
let mut cells=String::with_capacity(N*N/4);
let mut nibble=0u8;
for i in 0..N*N {
let x=i%N;let y=i/N;
let visible=[(0.,0.),(1.,0.),(0.,1.),(1.,1.),(0.5,0.5)].iter().all(|&(dx,dy)| {
self.shown(((x as f64+dx)/N as f64*2.-1.)*extent,((y as f64+dy)/N as f64*2.-1.)*extent)
});
if visible {nibble|=1<<(i%4);}
if i%4==3 {cells.push(char::from_digit(nibble as u32,16).unwrap());nibble=0;}
}
serde_json::json!({"size":N,"extent":extent,"cells":cells})
}
fn widest(&self, aspect: f64) -> (f64, f64, f64) {
let fits = |tan_h, cx, cy| self.region_fits(tan_h, cx, cy, aspect);
let limit = (-self.l).max(self.r).max((-self.t).max(self.b) / aspect);
let search = |cx: f64, cy: f64| {
let (mut lo, mut hi) = (0.0, limit);
@@ -351,6 +406,23 @@ pub fn undistorted(vr: &OpenVr, eye: usize, fov_deg: Option<f64>, out_w: u32, ou
let mut lut = Lut::new(out_w, out_h);
let eye_offset = model.eye as f64 * 0.5;
lut.view = Some((tan_h, cx, cy));
// The editor canvas is the eye's full render bounds, never a multiple of
// the current crop: reopening a smaller crop must not lower its zoom limit.
lut.editor_reference_tan = Some((-model.l).max(model.r).max((-model.t).max(model.b)));
lut.visibility = Some(model.visibility_grid(lut.editor_reference_tan.unwrap()));
lut.region_fully_visible = model.region_fits(tan_h, cx, cy, aspect);
// The verified factory mapping consumes head-relative output rays.
// Without calibration, keep capture working but do not claim an accurate
// spatial guide: the generic OpenVR projection is not equivalent.
let eye = vr.eye_to_head(eye);
// With factory projection, output rays are already head-relative. The
// compositor applies panel orientation itself; applying it again to the
// guide would double the correction.
lut.capture_to_head = if model.panel_geometry.is_some() {
eye.map(|eye|std::array::from_fn(|i|std::array::from_fn(|j| {
if j == 3 {eye[i][3]} else {if i==j {1.} else {0.}}
})))
} else {None};
let mut guess = [[0.5f64, 0.5]; 3];
// Widest color fringe, measured in output pixels.
let mut max_split = 0.0f64;
@@ -465,3 +537,24 @@ fn nearest(forward: &[[[f64; 2]; 3]], n: usize, c: usize, target: [f64; 2]) -> O
.min_by(|a, b| a.1.total_cmp(&b.1))?;
Some([(best % n) as f64 / (n - 1) as f64, (best / n) as f64 / (n - 1) as f64])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recording_region_checks_hidden_mesh_in_render_not_distortion_space() {
let geometry=PanelGeometry::parse(&serde_json::json!({"model_number":"Steam Frame","tracking_to_eye_transform":[{"eye_to_head":[[-1,0,0],[0,-1,0],[0,0,1]],"intrinsics":[[1,0,0],[0,1,0],[0,0,-1]],"grow_for_undistort":0}]}).to_string(),0).unwrap();
let mut model=EyeModel {eye:0,l:-2.,r:1.,t:-2.,b:1.,roll:0.,panel_geometry:Some(geometry),forward:vec![],hidden:vec![false;MASK_SIZE*MASK_SIZE],covered:vec![true;MASK_SIZE*MASK_SIZE]};
let index=|uv:[f64;2]|(uv[1]*MASK_SIZE as f64) as usize*MASK_SIZE+(uv[0]*MASK_SIZE as f64) as usize;
let render=index(model.render_target(0.,0.));
let distortion=index(model.target(0.,0.));
assert_ne!(render,distortion);
model.hidden[render]=true;
assert!(!model.shown(0.,0.));
model.hidden[render]=false;
model.hidden[distortion]=true;
assert!(model.shown(0.,0.));
model.covered[distortion]=false;
assert!(!model.shown(0.,0.));
}
}
+11 -5
View File
@@ -4,6 +4,7 @@ mod encoder;
mod gpu;
mod kms;
mod lut;
mod panel_geometry;
use anyhow::{Context, Result};
use clap::Parser;
@@ -13,11 +14,12 @@ use std::{io::{self, Write}, os::fd::AsRawFd, path::Path, time::{Duration, Insta
#[derive(Parser)]
struct Args {
#[arg(long, default_value_t = 1920)] width: u32,
#[arg(long, default_value_t = 1080)] height: u32,
#[arg(long, default_value_t = 1440)] height: u32,
#[arg(long, default_value_t = 30)] fps: u32,
#[arg(long, default_value_t = 8)] bitrate: u32,
#[arg(long, default_value_t = 0)] eye: usize,
#[arg(long)] raw: bool,
#[arg(long)] describe_view: bool,
#[arg(long, value_enum, default_value_t = encoder::Codec::H264)] codec: encoder::Codec,
#[arg(long)] fov: Option<f64>,
#[arg(long, default_value_t = 0.)] center_x: f64,
@@ -31,14 +33,18 @@ fn run(a: Args) -> Result<()> {
"Invalid output dimensions, rate or eye");
anyhow::ensure!(a.fov.is_none_or(|v| v.is_finite() && (10.0..=150.0).contains(&v))
&& [a.center_x, a.center_y].into_iter().all(|v| v.is_finite() && (-60.0..=60.0).contains(&v)), "Invalid field of view");
let kms = kms::Kms::open(Path::new("/dev/dri/card0"))?;
let height = if a.raw { ((a.width as f64 * kms.mode_size.1 as f64 / kms.mode_size.0 as f64).round() as u32 + 1) & !1 } else { a.height };
anyhow::ensure!(!a.describe_view || !a.raw, "Raw view has no single-eye region");
let kms = if a.describe_view {None} else {Some(kms::Kms::open(Path::new("/dev/dri/card0"))?)};
let height = if a.raw { let kms=kms.as_ref().unwrap(); ((a.width as f64 * kms.mode_size.1 as f64 / kms.mode_size.0 as f64).round() as u32 + 1) & !1 } else { a.height };
anyhow::ensure!(height>=160&&height<=4096&&u64::from(a.width)*u64::from(height)<=8_847_360,"Raw output exceeds supported dimensions");
let map = if a.raw { lut::raw(a.width, height, kms.mode_size.0, kms.mode_size.1) }
let map = if a.raw { let kms=kms.as_ref().unwrap(); lut::raw(a.width, height, kms.mode_size.0, kms.mode_size.1) }
else { lut::undistorted(&OpenVr::connect()?, a.eye, a.fov, a.width, height, a.center_x, a.center_y)? };
if let Some((tan_h, cx, cy)) = map.view {
eprintln!("{}", serde_json::json!({"event":"view","tanHalfHorizontal":tan_h,"centerTanX":cx,"centerTanY":cy,"aspect":a.width as f64/height as f64}));
let description=serde_json::json!({"event":"view","tanHalfHorizontal":tan_h,"centerTanX":cx,"centerTanY":cy,"aspect":a.width as f64/height as f64,"captureToHead":map.capture_to_head,"eye":a.eye,"projection":"factory-panel-v1","regionFullyVisible":map.region_fully_visible,"editorReferenceTan":map.editor_reference_tan,"visibility":map.visibility});
if a.describe_view { anyhow::ensure!(map.capture_to_head.is_some(),"SteamVR cannot provide an accurate eye transform");println!("{description}"); return Ok(()); }
eprintln!("{description}");
}
let kms=kms.unwrap();
let mut enc = encoder::Encoder::open(&encoder::find_device()?, encoder::Config {
codec: a.codec, width: a.width, height, fps: a.fps,
bitrate: a.bitrate * 1_000_000, qp: None,
+8
View File
@@ -201,6 +201,14 @@ impl OpenVr {
ok.then_some([out.red, out.green, out.blue])
}
pub fn eye_to_head(&self, eye: usize) -> Option<[[f64; 4]; 3]> {
if !self.full_layout { return None; }
let t = unsafe {
let f: unsafe extern "C" fn(i32) -> HmdMatrix34 = self.slot(SLOT_EYE_TO_HEAD);
f(eye as i32)
};
Some(t.m.map(|row|row.map(f64::from)))
}
/// How far the eye's view is rolled relative to the head, in radians.
/// Canted displays show up here.
pub fn eye_roll(&self, eye: usize) -> f64 {
+137
View File
@@ -0,0 +1,137 @@
//! Physical panel orientation is distinct from OpenVR's parallel render cameras.
//! Steam Frame exposes the former in its per-device factory calibration.
use anyhow::{ensure, Context, Result};
#[derive(Clone, Copy)]
pub struct PanelGeometry {
eye_to_head: [[f64; 3]; 3],
intrinsics: [f64; 4],
render_scale: f64,
}
impl PanelGeometry {
/// ComputeDistortion's render UVs use the factory camera, expanded by
/// grow_for_undistort, not the parallel GetProjectionRaw bounding frustum.
pub fn render_uv(&self, tx: f64, ty: f64) -> [f64; 2] {
let [x,y]=self.tangents(tx,ty);
let [fx,fy,cx,cy]=self.intrinsics;
[0.5+(fx*x+cx)/(2.*self.render_scale),0.5+(fy*y-cy)/(2.*self.render_scale)]
}
pub fn load(eye: usize) -> Result<Self> {
let path = "/persist/device_config.json";
ensure!(std::fs::metadata(path)?.len() <= 1024 * 1024, "oversized panel calibration");
Self::parse(&std::fs::read_to_string(path)?, eye)
}
pub(crate) fn parse(json: &str, eye: usize) -> Result<Self> {
let config: serde_json::Value = serde_json::from_str(json)?;
// Factory JSON retains the internal model name on retail devices.
ensure!(matches!(config["model_number"].as_str(), Some("Steam Frame" | "Deckard MP")), "not Steam Frame calibration");
let matrix = &config["tracking_to_eye_transform"][eye]["eye_to_head"];
let mut rotation = [[0.; 3]; 3];
for (i, row) in rotation.iter_mut().enumerate() {
for (j, value) in row.iter_mut().enumerate() {
let raw = matrix[i][j].as_f64().context("missing eye rotation")?;
ensure!(raw.is_finite(), "non-finite eye rotation");
// The shader already rotates each physical panel by 180°.
*value = raw * if j < 2 { -1. } else { 1. };
}
}
for i in 0..3 {
for j in 0..3 {
let dot: f64 = (0..3).map(|k| rotation[i][k] * rotation[j][k]).sum();
ensure!((dot - if i == j { 1. } else { 0. }).abs() < 0.01, "invalid eye rotation");
}
}
let determinant = rotation[0][0] * (rotation[1][1] * rotation[2][2] - rotation[1][2] * rotation[2][1])
- rotation[0][1] * (rotation[1][0] * rotation[2][2] - rotation[1][2] * rotation[2][0])
+ rotation[0][2] * (rotation[1][0] * rotation[2][1] - rotation[1][1] * rotation[2][0]);
ensure!((determinant - 1.).abs() < 0.01 && rotation[2][2] > 0.5, "invalid panel basis");
let calibration=&config["tracking_to_eye_transform"][eye];
let k=&calibration["intrinsics"];
let intrinsics=[k[0][0].as_f64(),k[1][1].as_f64(),k[0][2].as_f64(),k[1][2].as_f64()];
ensure!(intrinsics.iter().all(|v|v.is_some_and(f64::is_finite)),"missing or invalid panel intrinsics");
let intrinsics=intrinsics.map(Option::unwrap);
let render_scale=1.+calibration["grow_for_undistort"].as_f64().context("missing overscan calibration")?;
ensure!(intrinsics[0]>0. && intrinsics[1]>0. && render_scale.is_finite() && (1.0..=4.0).contains(&render_scale),"invalid panel projection");
Ok(Self { eye_to_head: rotation,intrinsics,render_scale })
}
pub fn tangents(&self, tx: f64, ty: f64) -> [f64; 2] {
// Head ray -> physical eye ray, using the transpose of its rotation.
// OpenVR looks along -Z, while image Y increases downwards.
let ray = [tx, -ty, -1.];
let q: [f64; 3] = std::array::from_fn(|j| {
(0..3).map(|i| self.eye_to_head[i][j] * ray[i]).sum()
});
if q[2] >= -1e-6 {
return [f64::NAN; 2];
}
[q[0] / -q[2], q[1] / q[2]]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(matrix: serde_json::Value) -> String {
serde_json::json!({"model_number":"Steam Frame", "tracking_to_eye_transform":[{"eye_to_head":matrix,"intrinsics":[[1,0,0],[0,1,0],[0,0,-1]],"grow_for_undistort":0}]}).to_string()
}
#[test]
fn panel_geometry_removes_scanout_rotation() {
let p = PanelGeometry::parse(&config(serde_json::json!([[-1,0,0],[0,-1,0],[0,0,1]])), 0).unwrap();
assert_eq!(p.tangents(0.2, -0.3), [0.2, -0.3]);
assert_eq!(p.render_uv(0.2,-0.3),[0.6,0.35]);
}
#[test]
fn panel_geometry_preserves_factory_roll_direction() {
let angle: f64 = -10_f64.to_radians();
let (s,c) = angle.sin_cos();
let p = PanelGeometry::parse(&config(serde_json::json!([[-c,s,0],[-s,-c,0],[0,0,1]])), 0).unwrap();
let t = p.tangents(0.5, 0.);
assert!((t[0] - 0.5*c).abs() < 1e-12);
assert!((t[1] - 0.5*s).abs() < 1e-12);
}
#[test]
fn panel_geometry_projects_with_intrinsics_and_overscan() {
let mut json:serde_json::Value=serde_json::from_str(&config(serde_json::json!([[-1,0,0],[0,-1,0],[0,0,1]]))).unwrap();
json["tracking_to_eye_transform"][0]["intrinsics"]=serde_json::json!([[1.2,0,0.04],[0,1.1,-0.02],[0,0,-1]]);
json["tracking_to_eye_transform"][0]["grow_for_undistort"]=serde_json::json!(0.6);
let geometry=PanelGeometry::parse(&json.to_string(),0).unwrap();
let uv=geometry.render_uv(0.2,-0.3);
assert!((uv[0]-(0.5+(1.2*0.2+0.04)/3.2)).abs()<1e-12);
assert!((uv[1]-(0.5+(-1.1*0.3+0.02)/3.2)).abs()<1e-12);
json["tracking_to_eye_transform"][0]["grow_for_undistort"]=serde_json::json!(-1.);
assert!(PanelGeometry::parse(&json.to_string(),0).is_err());
}
#[test]
fn panel_geometry_rejects_missing_and_invalid_calibration() {
for matrix in [serde_json::json!(null), serde_json::json!([[0,0,0],[0,0,0],[0,0,0]]), serde_json::json!([[1,0,0],[0,-1,0],[0,0,1]])] {
assert!(PanelGeometry::parse(&config(matrix), 0).is_err());
}
assert!(PanelGeometry::parse("{}", 0).is_err());
}
#[test]
fn panel_geometry_accepts_retail_internal_model_name() {
let json = config(serde_json::json!([[-1,0,0],[0,-1,0],[0,0,1]]));
assert!(PanelGeometry::parse(&json.replace("Steam Frame", "Deckard MP"), 0).is_ok());
assert!(PanelGeometry::parse(&json.replace("Steam Frame", "Other HMD"), 0).is_err());
}
#[test]
fn panel_geometry_device_fixture() {
let Ok(path) = std::env::var("FRAMELY_PANEL_TEST_FIXTURE") else { return };
let json = std::fs::read_to_string(path).unwrap();
for eye in 0..2 {
let geometry = PanelGeometry::parse(&json, eye).unwrap();
let ray = geometry.tangents(0.25, 0.25);
assert!(ray.into_iter().all(f64::is_finite));
}
}
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "vendor/json.hpp"
#include <string>
// Native close commands are an optimization; this durable state also repairs
// a missed command without letting an old close affect a newly opened editor.
inline bool framing_editor_should_close(const nlohmann::json& state,const std::string& editor_id){
if(!state.is_object()||!state.contains("open")||!state["open"].is_boolean()||!state.contains("id")||!state["id"].is_string())return false;
auto id=state["id"].get<std::string>();
return state["open"].get<bool>()?id!=editor_id:id==editor_id;
}
+191 -18
View File
@@ -31,6 +31,10 @@
#include "brand_mask.h"
#include "ui_visibility.h"
#include "ui_feedback.h"
#include "recording_indicator.h"
#include "recording_region.h"
#include "recording_region_depth.h"
#include "framing_lifecycle.h"
#include "plugin_navigation.h"
#include <GL/gl.h>
#include <GL/glx.h>
@@ -103,11 +107,13 @@ static int gaze_feedback_hand=1;
static std::chrono::steady_clock::time_point gaze_sent{},gaze_feedback_at{};
static EntryTrigger entry_buttons[2][2];
static EntryAnalogTrigger entry_analog[2];
static bool framing_trigger_known[2]{},framing_trigger_down[2]{};
static EntryPointer entry_pointer;
static bool menu_open=false,keyboard_open=false,keyboard_close_pending=false;
static float notification_curve_radius=1.3f;
static bool keyboard_toolbar=false,keyboard_chars=false,keyboard_done=false;
static MenuButtonInput menu_button;
static std::map<unsigned,uint64_t> menu_previous_buttons;
static cef_frame_t* keyboard_frame=nullptr;
static std::string keyboard_view;
static KeyboardMenuGuard keyboard_menu;
@@ -125,7 +131,8 @@ template<class T> struct Handler {
#include "cast_frame.h"
struct View {
CastFrame cast_frame;
std::string key,plugin,title;int width=600,height=840;float render_scale=1.f;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false;
json region_geometry=json::object();
std::string key,plugin,title,region_editor_id;int width=600,height=840;float render_scale=1.f;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false,framing_left_down=false;
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_at{},activation_at{};
cef_mouse_event_t pointer{};bool pointer_valid=false,stick_scrolling=false;vr::TrackedDeviceIndex_t pointer_device=vr::k_unTrackedDeviceIndexInvalid;std::chrono::steady_clock::time_point pointer_at{};
cef_file_dialog_callback_t* file_dialog=nullptr;std::string file_dialog_id;bool file_multiple=false,file_directory=false;
@@ -133,6 +140,7 @@ struct View {
vr::VROverlayHandle_t overlay=0,thumbnail=0;GLuint textures[2]{};int texture_slot=0;bool texture_ready[2]{};PaintDamage texture_damage[2];std::vector<uint8_t> pixels;
};
static std::map<std::string,std::unique_ptr<View>> views;
static bool permission_trigger_held[2]{};
static View* find_browser(cef_browser_t* b){int id=b->get_identifier(b);for(auto& [key,v]:views)if(v->browser_id==id)return v.get();return nullptr;}
static Handler<cef_client_t> client;
static Handler<cef_render_handler_t> render_handler;
@@ -165,7 +173,15 @@ static std::vector<uint8_t> icon(bool hover,bool active){
}
static std::vector<std::string> closed_casts;
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();payload=json{{"views",visible_views},{"closedCasts",closed_casts}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
// Protected by visibility_mutex (outgoing request) / queue_mutex (status).
static std::string pending_recording_stop;
static json recording_state=json::object();
static json recording_region_state=json::object();
static json framing_editor_state=nullptr;
static std::string pending_region_close;
static RecordingRegionOverlay recording_region_overlay;
static RecordingIndicator recording_indicator;
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;std::string stop_id,region_closed;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();stop_id=pending_recording_stop;region_closed=pending_region_close;payload=json{{"views",visible_views},{"closedCasts",closed_casts},{"stopRecording",stop_id},{"framingClosed",region_closed}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard<std::mutex> lock(visibility_mutex);if(pending_region_close==region_closed)pending_region_close.clear();if(pending_recording_stop==stop_id)pending_recording_stop.clear();closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
// Frame renders the native laser cursor in the default overlay sort group.
// Raising a panel above that group can obscure the runtime cursor.
// OpenVR owns and renders the laser cursor. Coordinates must remain in the
@@ -212,6 +228,71 @@ static vr::VROverlayHandle_t active_main_window(){
overlays->FindOverlay("valve.steam.gamepadui.main",&selected);
return selected;
}
struct RecordingRegionPresentation {json view;double distance=2.;};
static RecordingRegionPresentation recording_region_presentation(const json& original,bool freeze_depth=false){
RecordingRegionPresentation result{original,2.};
// This changes stereo presentation only, never the recorded angular crop.
// A center-ray hit measures real UI depth, including tilted/curved windows.
static auto sampled=std::chrono::steady_clock::time_point{};
static vr::HmdMatrix34_t left{},right{};static bool eyes_valid=false,measured=false;
static int previous_eye=-1;static double distance=2.;
try{
int eye=original.at("eye");if(eye<0||eye>1)return result;
vr::HmdMatrix34_t capture{};
for(int i=0;i<3;i++)for(int j=0;j<4;j++)capture.m[i][j]=original.at("captureToHead").at(i).at(j).get<float>();
auto now=std::chrono::steady_clock::now();bool probe=now-sampled>=std::chrono::milliseconds(60);
if(probe){left=vr_system->GetEyeToHeadTransform(vr::Eye_Left);right=vr_system->GetEyeToHeadTransform(vr::Eye_Right);eyes_valid=true;sampled=now;}
if(eyes_valid&&recording_region_refresh_eye_origin(static_cast<vr::EVREye>(eye),left,right,capture)){
for(int i=0;i<3;i++)result.view["captureToHead"][i][3]=capture.m[i][3];
}
if(eye!=previous_eye){previous_eye=eye;distance=2.;measured=false;probe=true;}
if(probe&&!freeze_depth){
vr::TrackedDevicePose_t poses[1]{};
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,1);
if(poses[0].bPoseIsValid&&poses[0].bDeviceIsConnected){
using namespace recording_region_depth_detail;
const auto& head=poses[0].mDeviceToAbsoluteTracking;
const double cx=original.at("centerTanX"),cy=original.at("centerTanY");
auto origin=add(translation(head),rotate(head,translation(capture)));
auto direction=rotate(head,rotate(capture,{cx,-cy,-1.}));
double length=std::sqrt(dot(direction,direction));
if(std::isfinite(length)&&length>1e-6){
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;
for(int i=0;i<3;i++){ray.vSource.v[i]=origin[i];ray.vDirection.v[i]=direction[i]/length;}
std::set<vr::VROverlayHandle_t> candidates;
for(const char* key:{"valve.steam.gamepadui.main","valve.steam.gamepadui.fallback"}){vr::VROverlayHandle_t handle=0;if(!overlays->FindOverlay(key,&handle))candidates.insert(handle);}
for(const auto& key:dashboard_keys)if(key.rfind("valve.steam.",0)==0&&key!="valve.steam.gamepadui.bar"){vr::VROverlayHandle_t handle=0;if(!overlays->FindOverlay(key.c_str(),&handle))candidates.insert(handle);}
for(const auto& [key,v]:views)if(!v->closing&&v->shown&&(v->dashboard||key.rfind("framely.window.",0)==0||key.rfind("framely.cast.",0)==0)&&key!="launcher")candidates.insert(v->overlay);
std::optional<double> nearest;
for(auto handle:candidates){
if(!overlays->IsOverlayVisible(handle))continue;
vr::VROverlayIntersectionResults_t hit{};
if(!overlays->ComputeOverlayIntersection(handle,&ray,&hit)||!std::isfinite(hit.vUVs.v[0])||!std::isfinite(hit.vUVs.v[1])||hit.vUVs.v[0]<0||hit.vUVs.v[0]>1||hit.vUVs.v[1]<0||hit.vUVs.v[1]>1)continue;
auto depth=recording_region_hit_depth(head,capture,cx,cy,hit.vPoint);
if(depth&&(!nearest||*depth<*nearest))nearest=depth;
}
if(nearest){
if(!measured){std::cout<<"Recording frame measured UI depth "<<*nearest<<" m, IPD "<<std::sqrt(dot(subtract(translation(right),translation(left)),subtract(translation(right),translation(left))))*1000<<" mm\n";distance=*nearest;measured=true;}
else distance+=(*nearest-distance)*.4;
}
// If a center ray temporarily leaves a window, retain its last measured
// depth rather than snapping back to 2 m. Without any UI measurement,
// 2 m remains an explicit approximation for arbitrary game scenes.
}
}
}
result.distance=distance;
}catch(...){return {original,2.};}
return result;
}
static void update_framing_placement(View& v){
if(v.closing||!v.region_geometry.is_object()||!v.region_geometry.contains("view"))return;
auto presentation=recording_region_presentation(v.region_geometry["view"],v.framing_left_down);
RecordingRegionPlane plane;
if(!recording_region_plane(presentation.view,v.region_geometry.at("referenceTan"),0,0,1,plane,presentation.distance))return;
overlays->SetOverlayWidthInMeters(v.overlay,plane.width);
overlays->SetOverlayTransformTrackedDeviceRelative(v.overlay,vr::k_unTrackedDeviceIndex_Hmd,&plane.transform);
}
static void hover_haptic(vr::VROverlayHandle_t overlay,std::chrono::steady_clock::time_point& last,bool activation=false){
auto now=std::chrono::steady_clock::now();if(now-last<std::chrono::milliseconds(80)||!overlays->IsOverlayVisible(overlay)||!overlays->IsHoverTargetOverlay(overlay))return;
last=now;if(ui_sounds)ui_audio.play(activation);
@@ -268,8 +349,25 @@ static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_o
}else {keyboard_done=keyboard_done||e.eventType==vr::VREvent_KeyboardDone;keyboard_close_pending=true;}
return true;
}
static void close_view(View& v){if(v.closing)return;if(v.key.rfind("framely.cast.",0)==0){std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
static void close_view(View& v){if(v.closing)return;if(v.key.rfind("framely.cast.",0)==0){std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}if(v.key=="framely.framing"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_region_close=v.region_editor_id;}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu"||v.key=="framely.apk.permission")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
static void anchor_to_head(View& v,float width,float x=0,float y=0,float z=-1.35f){vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);if(!poses[0].bPoseIsValid)return;auto m=poses[0].mDeviceToAbsoluteTracking;for(int r=0;r<3;r++)m.m[r][3]+=m.m[r][0]*x+m.m[r][1]*y+m.m[r][2]*z;overlays->SetOverlayWidthInMeters(v.overlay,width);overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&m);}
static void anchor_permission(View& v){
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);
if(!poses[0].bPoseIsValid)return;
const auto& head=poses[0].mDeviceToAbsoluteTracking;
float fx=head.m[0][2],fz=head.m[2][2],length=std::hypot(fx,fz);
if(length<.01f)return;
fx/=length;fz/=length;
vr::HmdMatrix34_t upright{};
upright.m[0][0]=fz;upright.m[1][1]=1;upright.m[2][0]=-fx;
upright.m[0][2]=fx;upright.m[2][2]=fz;
upright.m[0][3]=head.m[0][3]-.65f*fx;
upright.m[1][3]=head.m[1][3];
upright.m[2][3]=head.m[2][3]-.65f*fz;
overlays->SetOverlayWidthInMeters(v.overlay,.85f);
overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&upright);
}
static void anchor_notification(View& v){
// Let the compositor follow the HMD's render pose; a captured standing-space
// transform leaves the toast behind when the user turns or moves their head.
@@ -288,9 +386,15 @@ static View& create_view(const std::string& key,const std::string& title,const s
if(views.count(key)){auto& v=*views.at(key);if(v.dashboard&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="notifications"&&key!="launcher")show(v,true);return v;}
// Use 1.5x for launcher artwork and 2x for scrolling panels. CEF readback,
// texture upload and launcher mipmap generation scale with the pixel count.
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dashboard;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications")?2:1;
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dashboard;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications"||key=="framely.framing")?2:1;
auto error=dashboard?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(v->overlay,0);
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,key=="framely.apk.permission");overlays->SetOverlaySortOrder(v->overlay,key=="framely.apk.permission"?100:0);if(key=="framely.framing")recording_region_foreground(overlays,v->overlay);if(key=="framely.apk.permission")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true);
if(key=="framely.apk.permission"){
vr::VROverlayIntersectionMaskPrimitive_t mask{};
mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;
mask.m_Primitive.m_Rectangle={0,0,float(width),float(height)};
if(auto e=overlays->SetOverlayIntersectionMask(v->overlay,&mask,1))std::cerr<<"Permission input mask failed "<<e<<"\n";
}
// Keep the runtime's full dashboard controls, including its keyboard and close buttons.
if(dashboard){
@@ -476,11 +580,13 @@ static void launcher_gaze(vr::IVRInput* input,bool allowed,const bool (&connecte
gaze_active=true;
}
static void entry_actions(vr::VROverlayHandle_t button,bool available){
if(!entry_actions_ready)return;auto* input=vr::VRInput();vr::VRActiveActionSet_t sets[2]{};sets[0].ulActionSet=entry_set;sets[1].ulActionSet=gaze_set;
for(int hand=0;hand<2;hand++){framing_trigger_known[hand]=false;framing_trigger_down[hand]=false;}
const bool pointer_available=available||recording_indicator.active();
if(!entry_actions_ready){recording_indicator.cancel_input();return;}auto* input=vr::VRInput();vr::VRActiveActionSet_t sets[2]{};sets[0].ulActionSet=entry_set;sets[1].ulActionSet=gaze_set;
// Only the eye action participates in overlay priority. Controller bindings
// keep their existing priority so Steam laser and Dock controls are preserved.
if(launcher_gaze_allowed()&&!entry_hold.pressed&&!(available&&(entry_pointer.inside||overlays->IsHoverTargetOverlay(button))))sets[1].nPriority=vr::k_nActionSetOverlayGlobalPriorityMin;
if(input->UpdateActionState(sets,sizeof(sets[0]),gaze_set?2:1)!=vr::VRInputError_None)return;
if(input->UpdateActionState(sets,sizeof(sets[0]),gaze_set?2:1)!=vr::VRInputError_None){recording_indicator.cancel_input();return;}
auto now=std::chrono::steady_clock::now();
// Dock ownership must be checked before reading confirm edges. Gaze focus
// alone does not own a trigger aimed at the entry, even before mouse-down
@@ -489,8 +595,8 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
float stick_x=0,stick_y=0;bool stick_active=false;vr::TrackedDeviceIndex_t stick_device=vr::k_unTrackedDeviceIndexInvalid;
for(int hand=0;hand<2;hand++){
vr::InputPoseActionData_t pose{};auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);bool hit=available&&entry_pointer.hit(device);
bool pose_ok=available&&input->GetPoseActionDataRelativeToNow(aim_action,vr::TrackingUniverseStanding,0,&pose,sizeof(pose),hands[hand])==vr::VRInputError_None&&pose.bActive&&pose.pose.bPoseIsValid;
if(available&&!pose_ok&&device!=vr::k_unTrackedDeviceIndexInvalid){
bool pose_ok=pointer_available&&input->GetPoseActionDataRelativeToNow(aim_action,vr::TrackingUniverseStanding,0,&pose,sizeof(pose),hands[hand])==vr::VRInputError_None&&pose.bActive&&pose.pose.bPoseIsValid;
if(pointer_available&&!pose_ok&&device!=vr::k_unTrackedDeviceIndexInvalid){
vr::TrackedDevicePose_t tracked[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,tracked,vr::k_unMaxTrackedDeviceCount);
char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));
vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t tip{};
@@ -498,7 +604,7 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
pose.pose.mDeviceToAbsoluteTracking=entry_tip_pose(tracked[device].mDeviceToAbsoluteTracking,tip.mTrackingToComponentLocal);pose_ok=true;
}
}
if(pose_ok){auto& m=pose.pose.mDeviceToAbsoluteTracking;vr::VROverlayIntersectionParams_t params{};params.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){params.vSource.v[r]=m.m[r][3];params.vDirection.v[r]=-m.m[r][2];}vr::VROverlayIntersectionResults_t result{};hit=hit||(overlays->IsHoverTargetOverlay(button)&&overlays->ComputeOverlayIntersection(button,&params,&result)&&result.vUVs.v[0]>=float(dock_icon_left)/dock_canvas_width&&result.vUVs.v[0]<=float(dock_icon_left+128)/dock_canvas_width);}
if(available&&pose_ok){auto& m=pose.pose.mDeviceToAbsoluteTracking;vr::VROverlayIntersectionParams_t params{};params.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){params.vSource.v[r]=m.m[r][3];params.vDirection.v[r]=-m.m[r][2];}vr::VROverlayIntersectionResults_t result{};hit=hit||(overlays->IsHoverTargetOverlay(button)&&overlays->ComputeOverlayIntersection(button,&params,&result)&&result.vUVs.v[0]>=float(dock_icon_left)/dock_canvas_width&&result.vUVs.v[0]<=float(dock_icon_left+128)/dock_canvas_width);}
if(hit)gaze_allowed=false;
gaze_connected[hand]=device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device);
@@ -532,9 +638,40 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
}
if(kind==0&&!active)entry_analog[hand].held=false;
if(kind==0&&active)gaze_buttons[hand]|=down;
if(kind==0){
framing_trigger_known[hand]=active;framing_trigger_down[hand]=down||gaze_buttons[hand];
// Reuse the same physical tip / analog-trigger fallback as the Dock.
// No second action poll or input-focus request for the recording HUD.
auto stop_recording=recording_indicator.pointer(hand,pose.pose.mDeviceToAbsoluteTracking,active&&pose_ok,down);
if(!stop_recording.empty()){std::lock_guard<std::mutex> lock(visibility_mutex);pending_recording_stop=stop_recording;}
auto permission=views.find("framely.apk.permission");
if(permission!=views.end()&&!permission->second->closing&&permission->second->shown&&pose_ok){
auto& view=*permission->second;
const auto& m=pose.pose.mDeviceToAbsoluteTracking;
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;
for(int row=0;row<3;row++){ray.vSource.v[row]=m.m[row][3];ray.vDirection.v[row]=-m.m[row][2];}
vr::VROverlayIntersectionResults_t point{};
if(overlays->ComputeOverlayIntersection(view.overlay,&ray,&point)&&point.vUVs.v[0]>=0&&point.vUVs.v[0]<=1&&point.vUVs.v[1]>=0&&point.vUVs.v[1]<=1){
cef_mouse_event_t mouse{};
mouse.x=std::clamp(int(point.vUVs.v[0]*view.width),0,view.width-1);
mouse.y=std::clamp(int((1-point.vUVs.v[1])*view.height),0,view.height-1);
if(!overlays->IsHoverTargetOverlay(view.overlay)){
view.host->send_mouse_move_event(view.host,&mouse,0);
if(active&&down&&!permission_trigger_held[hand]){
view.host->set_focus(view.host,1);
mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;
view.host->send_mouse_click_event(view.host,&mouse,MBT_LEFT,0,1);
mouse.modifiers=0;
view.host->send_mouse_click_event(view.host,&mouse,MBT_LEFT,1,1);
}
}
}
}
permission_trigger_held[hand]=active&&down;
}
bool was_held=entry_buttons[hand][kind].held;
entry_buttons[hand][kind].update(active,down,hit,now);
if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!recording_indicator.hovered(hand)&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
}
if(launcher_open&&hand==1){vr::InputDigitalActionData_t close{};bool active=input->GetDigitalActionData(close_action,&close,sizeof(close),hands[hand])==vr::VRInputError_None&&close.bActive;bool down=close.bState;
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};active=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_b",hands[hand],&mode,&state);down=active&&(state.uProperties&vr::VRComponentProperty_IsPressed);}
@@ -552,9 +689,28 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
if(gaze_allowed&&gaze_active)launcher_outside_at={};
if(launcher_open&&launcher_outside_at.time_since_epoch().count()&&now>=launcher_outside_at){launcher_outside_at={};if(!keyboard_open&&!launcher_dock_interaction())launcher_back();}
}
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="cast.window"){auto id=c.at("id").get<std::string>();auto key="framely.cast."+id;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}int width=c.value("width",640),height=c.value("height",240);if(width<160||width>1920||height<112||height>8192)continue;auto& v=create_view(key,"Framely casting",origin+"/cast/window/"+id,false,width,height,"",2.f);v.cast_frame.path=c.at("frame").get<std::string>();if(v.width!=width||v.height!=height){v.width=width;v.height=height;v.pixels.clear();v.texture_ready[0]=v.texture_ready[1]=false;v.host->was_resized(v.host);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v.overlay,&scale);}menu_open=false;launcher_open=false;}else if(kind=="cast.close"){auto key="framely.cast."+c.at("id").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="window.open"){hide_launcher(false);std::string plugLine truncated
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="cast.framing.open"){auto key="framely.framing";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto geometry=c.at("geometry");RecordingRegionPlane plane;auto view=geometry.at("view");if(!recording_region_plane(view,geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Recording frame",origin+"/cast/framing",false,1600,1600);v.region_editor_id=geometry.at("id");v.region_geometry=geometry;update_framing_placement(v);menu_open=false;hide_launcher(false);}else if(kind=="cast.framing.reference"){auto found=views.find("framely.framing");if(found!=views.end()&&!found->second->closing&&found->second->region_editor_id==c.value("id",std::string{})){double cx=c.at("centerTanX"),cy=c.at("centerTanY");if(std::isfinite(cx)&&std::isfinite(cy)&&std::abs(cx)<=1.733&&std::abs(cy)<=1.733){found->second->region_geometry["view"]["centerTanX"]=cx;found->second->region_geometry["view"]["centerTanY"]=cy;}}}else if(kind=="cast.framing.close"){if(views.count("framely.framing")&&views.at("framely.framing")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.framing"));}else if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu"Line truncated
}
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key.rfind("framely.cast.",0)!=0)close_view(v);break;default:break;}}
static void release_framing_pointer(View& v){
if(!v.framing_left_down)return;v.framing_left_down=false;
auto mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_click_event(v.host,&mouse,MBT_LEFT,1,1);
auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.pointerRelease'))");frame->base.release(&frame->base);
}
static void reconcile_framing_pointer(View& v){
if(v.key!="framely.framing"||!v.framing_left_down)return;
bool known=true,held=false,found=false,owner=false;
for(int hand=0;hand<2;hand++){
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
if(device==vr::k_unTrackedDeviceIndexInvalid)continue;
if(device==v.pointer_device){known=framing_trigger_known[hand];held=framing_trigger_down[hand];owner=true;break;}
}
if(!owner)for(int hand=0;hand<2;hand++){
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
if(device==vr::k_unTrackedDeviceIndexInvalid)continue;found=true;known&=framing_trigger_known[hand];held|=framing_trigger_down[hand];
}
if((owner||found)&&known&&!held)release_framing_pointer(v);
}
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;if(menu_open&&v.key!="menu"&&e.eventType==vr::VREvent_MouseButtonDown&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now()))menu_open=false;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="framely.framing")release_framing_pointer(v);if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="framely.framing")mouse.modifiers=v.framing_left_down?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if(v.key=="framely.framing"&&button==MBT_LEFT)v.framing_left_down=e.eventType==vr::VREvent_MouseButtonDown;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(v.key=="framely.framing")release_framing_pointer(v);if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key.rfind("framely.cast.",0)!=0&&v.key!="framely.apk.permission"&&v.key!="framely.framing")close_view(v);break;default:break;}}
int main(int argc,char** argv){
std::cout.setf(std::ios::unitbuf);std::string initial;int port=0;for(int i=1;i+1<argc;i++){std::string s=argv[i];if(s=="--url")initial=argv[++i];else if(s=="--port")port=std::stoi(argv[++i]);else if(s=="--runtime")runtime=argv[++i];}
@@ -593,10 +749,10 @@ int main(int argc,char** argv){
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);DockIconTexture icon_texture;icon_texture.init();if(!icon_texture.submit(button,pixels)){icon_texture.destroy();overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayInputMethod(button,vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{dock_canvas_width,dock_canvas_height}};overlays->SetOverlayMouseScale(button,&scale);vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;mask.m_Primitive.m_Rectangle={dock_icon_left,0,128,128};if(overlays->SetOverlayIntersectionMask(button,&mask,1)){std::cerr<<"Dock input mask unavailable\n";overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayFlag(button,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(button,0);overlays->SetOverlayFlag(button,vr::VROverlayFlags_HideLaserIntersection,false);
try{create_view("menu","Framely",initial,false,600,840);}catch(const std::exception& e){std::cerr<<e.what()<<"\n";running=0;}
const char* steam_root=std::getenv("FRAMELY_STEAM_ROOT");if(!ui_audio.prepare(steam_root?steam_root:""))std::cerr<<"Steam UI sounds unavailable\n";
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",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 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();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;
@@ -612,7 +768,7 @@ int main(int argc,char** argv){
if(!main_overlay)main_overlay=active_main_window();
if(main_overlay&&!overlays->GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose;
if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);}
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher"&&key.rfind("framely.cast.",0)!=0)close_view(*v);}was_visible=visible;
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher"&&key.rfind("framely.cast.",0)!=0&&key!="framely.apk.permission")close_view(*v);}was_visible=visible;
if(launcher_close_at.time_since_epoch().count()&&now>=launcher_close_at){launcher_close_at={};if(!launcher_open)finish_launcher_close();}
// The runtime owns dashboard placement, curvature, visibility and switching.
// Never move the launcher into standing space or hide another app's overlay.
@@ -660,8 +816,23 @@ int main(int argc,char** argv){
activate_entry(entry_hold.tick(std::chrono::steady_clock::now(),anchor_ok&&entry_connected,entry_inside));
for(auto&[key,v]:views)v->stick_scrolling=false;
entry_actions(button,anchor_ok);
// Some dashboard runtimes do not emit Modal_Cancel for clicks on their own
// overlays. Observe the same controller click edge as a fallback, without
// consuming the click destined for the other surface.
for(int hand=0;hand<2;hand++){
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
if(device==vr::k_unTrackedDeviceIndexInvalid)continue;
vr::VRControllerState_t state{};
if(!vr_system->GetControllerState(device,&state,sizeof(state))){menu_previous_buttons.erase(device);continue;}
auto previous=menu_previous_buttons.find(device);
uint64_t pressed=previous==menu_previous_buttons.end()?0:state.ulButtonPressed&~previous->second;
menu_previous_buttons[device]=state.ulButtonPressed;
const uint64_t click=vr::ButtonMaskFromId(vr::k_EButton_SteamVR_Trigger)|vr::ButtonMaskFromId(vr::k_EButton_A)|vr::ButtonMaskFromId(vr::k_EButton_DPad_Down);
if(menu_open&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now())&&(pressed&click)&&views.count("menu")&&!overlays->IsHoverTargetOverlay(views.at("menu")->overlay)&&!overlays->IsHoverTargetOverlay(button))menu_open=false;
}
if(launcher_open){bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held;launcher_dismiss.observe(held);}
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){if(!active&&key=="launcher"){if(v->launcher_pointer.active){cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;v->host->send_mouse_move_event(v->host,&outside,0);outside.modifiers=0;v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);}v->launcher_pointer.clear();auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->base.release(&frame->base);}v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;if(key!="launcher")for(auto&[other,t]:views)if(!t->dashboard&&other!="menu"&&other!="notifications"&&other!="launcher"&&other.rfind("framely.cast.",0)!=0&&!keyboard_open)close_view(*t);}}
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){if(!active&&key=="launcher"){if(v->launcher_pointer.active){cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;v->host->send_mouse_move_event(v->host,&outside,0);outside.modifiers=0;v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);}v->launcher_pointer.clear();auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->base.release(&frame->base);}v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;if(key!="launcher")for(auto&[other,t]:views)if(!t->dashboard&&other!="menu"&&other!="notifications"&&other!="launcher"&&other.rfind("framely.cast.",0)!=0&&other!="framely.apk.permission"&&!keyboard_open)close_view(*t);}}
reconcile_framing_pointer(*v);
while(overlays->PollNextOverlayEvent(v->overlay,&event,sizeof(event)))overlay_input(*v,event);
if(!v->cast_frame.path.empty()){glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);if(v->cast_frame.update())v->dirty=true;}
if(v->dirty&&!v->closing&&v->pixels.size()==size_t(v->width*v->render_scale)*size_t(v->height*v->render_scale)*4){
@@ -677,7 +848,7 @@ int main(int argc,char** argv){
if(!v->texture_ready[slot]){
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,mipmapped?GL_LINEAR_MIPMAP_LINEAR:GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,v->width*v->render_scale,v->height*v->render_scale,0,GL_BGRA,GL_UNSIGNED_BYTE,nullptr);
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,pixel_width,pixel_height,0,GL_BGRA,GL_UNSIGNED_BYTE,nullptr);
v->texture_ready[slot]=true;
damage.include(0,0,pixel_width,pixel_height);
}
@@ -737,5 +908,7 @@ int main(int argc,char** argv){
// only for immediate API failures, so a broken sync call cannot busy-spin.
if(sync!=vr::VROverlayError_None&&sync!=vr::VROverlayError_TimedOut)std::this_thread::sleep_until(frame_start+std::chrono::milliseconds(8));
}
recording_indicator.destroy();
recording_region_overlay.destroy();
close_keyboard();for(auto&[key,v]:views)close_view(*v);auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(5);while(std::chrono::steady_clock::now()<deadline){cef_do_message_loop_work();bool all=true;for(auto&[key,v]:views)all&=v->closed;if(all)break;std::this_thread::sleep_for(std::chrono::milliseconds(5));}poller.join();priority_worker.join();for(auto&[key,v]:views)if(!v->closed){std::cerr<<"Browser shutdown timed out\n";_Exit(7);}for(auto&[key,v]:views){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);glDeleteTextures(2,v->textures);}views.clear();overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();glXMakeContextCurrent(xdisplay,None,None,nullptr);glXDestroyContext(xdisplay,glcontext);glXDestroyPbuffer(xdisplay,glsurface);XCloseDisplay(xdisplay);return 0;
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <array>
#include <vector>
#include <string>
#include <cstdio>
#include <cmath>
#include <algorithm>
struct RecordingIndicatorClick {
bool held=true,armed=false;
bool update(bool down,bool inside,bool valid=true){
if(!valid){held=true;armed=false;return false;}
if(down&&!held)armed=inside;
bool clicked=!down&&held&&armed&&inside;
if(!down)armed=false;
held=down;return clicked;
}
};
inline std::vector<unsigned char> recording_indicator_pixels(uint64_t seconds,bool hover){
constexpr int w=320,h=80;std::vector<unsigned char> pixels(w*h*4);
auto point=[&](int x,int y,int r,int g,int b,int a=255){if(x<0||x>=w||y<0||y>=h)return;auto i=(y*w+x)*4;pixels[i]=r;pixels[i+1]=g;pixels[i+2]=b;pixels[i+3]=a;};
for(int y=0;y<h;y++)for(int x=0;x<w;x++){
int dx=std::max(12-x,std::max(x-(w-13),0)),dy=std::max(12-y,std::max(y-(h-13),0));
if(dx*dx+dy*dy<=144)point(x,y,hover?65:27,hover?45:27,hover?45:29,235);
if((x-32)*(x-32)+(y-40)*(y-40)<=11*11)point(x,y,242,61,75);
}
static constexpr unsigned digits[]={0x7b6f,0x2492,0x73e7,0x73cf,0x5bc9,0x79cf,0x79ef,0x7249,0x7bef,0x7bcf};
char text[32];seconds=std::min<uint64_t>(seconds,359999);std::snprintf(text,sizeof text,"%02llu:%02llu:%02llu",(unsigned long long)(seconds/3600),(unsigned long long)(seconds/60%60),(unsigned long long)(seconds%60));
int x=70;for(char c:std::string(text)){
unsigned glyph=c==':'?0x0410:digits[c-'0'];
for(int row=0;row<5;row++)for(int col=0;col<3;col++)if(glyph&(1u<<(14-row*3-col)))for(int dy=0;dy<4;dy++)for(int dx=0;dx<4;dx++)point(x+col*4+dx,30+row*4+dy,245,245,245);
x+=20;
}
for(int y=29;y<51;y++)for(int x=272;x<294;x++)point(x,y,245,245,245);
return pixels;
}
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();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];}
std::string pointer(int hand,const vr::HmdMatrix34_t& pose,bool valid,bool down){
if(!active()||hand<0||hand>=2)return {};
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;
for(int k=0;k<3;k++){ray.vSource.v[k]=pose.m[k][3];ray.vDirection.v[k]=-pose.m[k][2];}
vr::VROverlayIntersectionResults_t hit{};
hover[hand]=valid&&overlay->ComputeOverlayIntersection(handle,&ray,&hit)&&hit.vUVs.v[0]>=0&&hit.vUVs.v[0]<=1&&hit.vUVs.v[1]>=0&&hit.vUVs.v[1]<=1;
return clicks[hand].update(down,hover[hand],valid)?id:std::string{};
}
void tick(vr::IVROverlay* overlays,const std::string& next,uint64_t seconds){
overlay=overlays;if(next.empty()){destroy();return;}
if(!handle){
if(overlay->CreateOverlay("framely.recording","Framely recording — click to stop",&handle))return;
overlay->SetOverlayWidthInMeters(handle,.32f);
vr::HmdMatrix34_t transform={{{1,0,0,.50f},{0,1,0,.35f},{0,0,1,-1.f}}};
overlay->SetOverlayTransformTrackedDeviceRelative(handle,vr::k_unTrackedDeviceIndex_Hmd,&transform);
// Stay passive: never take input focus from a game or activate global lasers.
overlay->SetOverlayInputMethod(handle,vr::VROverlayInputMethod_None);
overlay->SetOverlayFlag(handle,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,false);
overlay->SetOverlayFlag(handle,vr::VROverlayFlags_HideLaserIntersection,true);
overlay->SetOverlayFlag(handle,vr::VROverlayFlags_VisibleInDashboard,true);
rendered=~uint64_t(0);
}
if(id!=next){id=next;clicks={};hover={};}
bool highlighted=hover[0]||hover[1];
if(seconds!=rendered||highlighted!=last_hover){auto pixels=recording_indicator_pixels(seconds,highlighted);if(!overlay->SetOverlayRaw(handle,pixels.data(),320,80,4)){overlay->ShowOverlay(handle);rendered=seconds;last_hover=highlighted;}}
}
};
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include <cmath>
#include <algorithm>
#include <vector>
#include <array>
#include <chrono>
#include "vendor/json.hpp"
using RegionJson=nlohmann::json;
struct RecordingRegionPlane {vr::HmdMatrix34_t transform{};float width=0;};
inline void recording_region_foreground(vr::IVROverlay* overlays,vr::VROverlayHandle_t handle){
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_SortWithNonSceneOverlays,true);
overlays->SetOverlaySortOrder(handle,1000);
}
inline bool recording_region_plane(const RegionJson& view,double tan,double cx,double cy,double aspect,RecordingRegionPlane& plane,double distance=2.){
try{
if(!std::isfinite(tan)||tan<=0||!std::isfinite(cx)||!std::isfinite(cy)||!std::isfinite(aspect)||aspect<=0||!std::isfinite(distance)||distance<=0)return false;
const auto& basis=view.at("captureToHead");
for(int i=0;i<3;i++){
double translation=basis.at(i).at(3).get<double>();
for(int j=0;j<3;j++){
double value=basis.at(i).at(j).get<double>();if(!std::isfinite(value))return false;
plane.transform.m[i][j]=value;
translation+=distance*value*(j==0?cx:j==1?-cy:-1.);
}
if(!std::isfinite(translation))return false;plane.transform.m[i][3]=translation;
}
plane.width=2*tan*distance;return std::isfinite(plane.width)&&plane.width>0;
}catch(...){return false;}
}
inline std::array<unsigned char,3> recording_region_color(){
static std::array<unsigned char,3> cached{0,255,255};static auto checked=std::chrono::steady_clock::time_point{};
auto now=std::chrono::steady_clock::now();if(now-checked<std::chrono::seconds(1))return cached;checked=now;
std::array<unsigned char,3> color{0,255,255};auto* settings=vr::VRSettings();
if(!settings)return cached;
const char* keys[]={vr::k_pch_CollisionBounds_ColorGammaR_Int32,vr::k_pch_CollisionBounds_ColorGammaG_Int32,vr::k_pch_CollisionBounds_ColorGammaB_Int32};
for(int i=0;i<3;i++){vr::EVRSettingsError error{};auto value=settings->GetInt32(vr::k_pch_CollisionBounds_Section,keys[i],&error);if(error!=vr::VRSettingsError_None||value<0||value>255)return cached;color[i]=static_cast<unsigned char>(value);}
cached=color;return cached;
}
inline std::vector<unsigned char> recording_region_pixels(int w,int h,const std::array<unsigned char,3>& color={0,255,255}){
std::vector<unsigned char> pixels(w*h*4);
for(int y=0;y<h;y++)for(int x=0;x<w;x++){
bool edge=x<2||x>=w-2||y<2||y>=h-2;
if(edge&&((x<2||x>=w-2?y:x)/14)%2==0){auto i=(y*w+x)*4;for(int c=0;c<3;c++)pixels[i+c]=color[c];pixels[i+3]=72;}
}
return pixels;
}
class RecordingRegionOverlay {
vr::IVROverlay* overlays=nullptr;vr::VROverlayHandle_t handle=0;std::string last,last_pixels;
public:
void destroy(){if(handle&&overlays)overlays->DestroyOverlay(handle);handle=0;last.clear();last_pixels.clear();}
void tick(vr::IVROverlay* api,const RegionJson& state,double distance=2.,const RegionJson* live_view=nullptr){
overlays=api;
if(!state.value("visible",false)){destroy();return;}
auto color=recording_region_color();auto serialized=state.dump()+std::string(color.begin(),color.end())+std::to_string(distance)+(live_view?live_view->dump():"");if(serialized==last)return;
try{
const auto& view=live_view?*live_view:state.at("view");double aspect=view.at("aspect");
RecordingRegionPlane plane;if(!recording_region_plane(view,view.at("tanHalfHorizontal"),view.at("centerTanX"),view.at("centerTanY"),aspect,plane,distance)){destroy();return;}
if(!handle){
if(overlays->CreateOverlay("framely.recording.region","Framely recording region",&handle))return;
overlays->SetOverlayInputMethod(handle,vr::VROverlayInputMethod_None);
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,false);
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_HideLaserIntersection,true);
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_VisibleInDashboard,true);
recording_region_foreground(overlays,handle);
}
int width=state.at("settings").at("width"),height=state.at("settings").at("height");
if(width<160||height<160||width>4096||height>4096||std::abs(double(width)/height-aspect)>1e-6){destroy();return;}
// The INNER edge is the capture boundary. Keep the guide outside it so
// it does not cover pixels inside the intended recorded image.
// Bound raw-overlay traffic independently of recording resolution.
// Square texels are explicitly stretched to the exact capture aspect.
width=height=512;plane.width*=512.f/508.f;
auto error=overlays->SetOverlayWidthInMeters(handle,plane.width);
if(!error)error=overlays->SetOverlayTexelAspect(handle,aspect);
if(!error)error=overlays->SetOverlayTransformTrackedDeviceRelative(handle,vr::k_unTrackedDeviceIndex_Hmd,&plane.transform);
const auto pixel_key=std::string(color.begin(),color.end());
if(!error&&(last_pixels.empty()||pixel_key!=last_pixels)){
auto pixels=recording_region_pixels(width,height,color);
error=overlays->SetOverlayRaw(handle,pixels.data(),width,height,4);
if(!error)last_pixels=pixel_key;
}
if(error){destroy();return;}overlays->ShowOverlay(handle);last=serialized;
}catch(...){destroy();}
}
};
+90
View File
@@ -0,0 +1,90 @@
#pragma once
#include <array>
#include <cmath>
#include <optional>
#include "openvr.h"
// Pure geometry only. Pose validity/freshness is owned by the caller: a
// numerically rigid matrix cannot reveal whether its tracking sample is old.
namespace recording_region_depth_detail {
using Point=std::array<double,3>;
inline double dot(const Point& a,const Point& b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];}
inline Point translation(const vr::HmdMatrix34_t& m){return {m.m[0][3],m.m[1][3],m.m[2][3]};}
inline Point rotate(const vr::HmdMatrix34_t& m,const Point& p){
Point result{};for(int i=0;i<3;i++)for(int j=0;j<3;j++)result[i]+=m.m[i][j]*p[j];return result;
}
inline Point add(const Point& a,const Point& b){return {a[0]+b[0],a[1]+b[1],a[2]+b[2]};}
inline Point subtract(const Point& a,const Point& b){return {a[0]-b[0],a[1]-b[1],a[2]-b[2]};}
inline bool rigid(const vr::HmdMatrix34_t& m){
for(const auto& row:m.m)for(float v:row)if(!std::isfinite(v))return false;
for(int i=0;i<3;i++)for(int j=0;j<3;j++){
double product=0;for(int k=0;k<3;k++)product+=double(m.m[k][i])*m.m[k][j];
if(std::abs(product-(i==j?1.:0.))>0.01)return false;
}
const double determinant=m.m[0][0]*(double(m.m[1][1])*m.m[2][2]-double(m.m[1][2])*m.m[2][1])
-m.m[0][1]*(double(m.m[1][0])*m.m[2][2]-double(m.m[1][2])*m.m[2][0])
+m.m[0][2]*(double(m.m[1][0])*m.m[2][1]-double(m.m[1][1])*m.m[2][0]);
return std::abs(determinant-1.)<=0.01;
}
}
// Refresh just the eye origin. Capture's basis already encodes the LUT's ray
// convention and must not receive the physical-panel rotation a second time.
inline bool recording_region_refresh_eye_origin(vr::EVREye eye,
const vr::HmdMatrix34_t& left_to_head,const vr::HmdMatrix34_t& right_to_head,
vr::HmdMatrix34_t& capture_to_head){
using namespace recording_region_depth_detail;
if((eye!=vr::Eye_Left&&eye!=vr::Eye_Right)||!rigid(left_to_head)||!rigid(right_to_head)||!rigid(capture_to_head))return false;
const auto left=translation(left_to_head),right=translation(right_to_head),baseline=subtract(right,left);
const double ipd=std::sqrt(dot(baseline,baseline));
// Broad plausibility guards, not a replacement for live tracking validity.
if(ipd<0.04||ipd>0.09||baseline[0]<0.8*ipd||std::abs(baseline[1])>0.02||std::abs(baseline[2])>0.02
||dot(left,left)>0.25*0.25||dot(right,right)>0.25*0.25)return false;
const auto& selected=eye==vr::Eye_Left?left_to_head:right_to_head;
for(int i=0;i<3;i++)capture_to_head.m[i][3]=selected.m[i][3];
return true;
}
// Intersect the selected capture-eye center ray with a known UI window plane.
// The returned parameter D is measured along capture -Z, not Euclidean ray
// length: X = eye + D * basis * (centerTanX,-centerTanY,-1).
// It therefore plugs directly into a guide whose width is 2*tanHalfFov*D.
inline std::optional<double> recording_region_window_depth(
const vr::HmdMatrix34_t& hmd_to_world,const vr::HmdMatrix34_t& window_to_world,
const vr::HmdMatrix34_t& capture_to_head,double center_tan_x,double center_tan_y){
using namespace recording_region_depth_detail;
if(!rigid(hmd_to_world)||!rigid(window_to_world)||!rigid(capture_to_head)
||!std::isfinite(center_tan_x)||!std::isfinite(center_tan_y)
||std::abs(center_tan_x)>10.||std::abs(center_tan_y)>10.
||dot(translation(capture_to_head),translation(capture_to_head))>0.25*0.25)return std::nullopt;
const Point origin=add(translation(hmd_to_world),rotate(hmd_to_world,translation(capture_to_head)));
const Point ray=rotate(hmd_to_world,rotate(capture_to_head,{center_tan_x,-center_tan_y,-1.}));
const Point normal={window_to_world.m[0][2],window_to_world.m[1][2],window_to_world.m[2][2]};
const double denominator=dot(normal,ray);
if(std::abs(denominator)<1e-5*std::sqrt(dot(ray,ray)))return std::nullopt;
const double distance=dot(normal,subtract(translation(window_to_world),origin))/denominator;
if(!std::isfinite(distance)||distance<0.25||distance>6.)return std::nullopt;
return distance;
}
// ComputeOverlayIntersection can supply an exact curved/tilted UI hit. Recover
// the same capture -Z parameter, rejecting unrelated/off-ray intersection data.
inline std::optional<double> recording_region_hit_depth(
const vr::HmdMatrix34_t& hmd_to_world,const vr::HmdMatrix34_t& capture_to_head,
double center_tan_x,double center_tan_y,const vr::HmdVector3_t& world_hit){
using namespace recording_region_depth_detail;
if(!rigid(hmd_to_world)||!rigid(capture_to_head)
||!std::isfinite(center_tan_x)||!std::isfinite(center_tan_y)
||std::abs(center_tan_x)>10.||std::abs(center_tan_y)>10.
||dot(translation(capture_to_head),translation(capture_to_head))>0.25*0.25)return std::nullopt;
Point hit{};for(int i=0;i<3;i++){hit[i]=world_hit.v[i];if(!std::isfinite(hit[i]))return std::nullopt;}
const Point origin=add(translation(hmd_to_world),rotate(hmd_to_world,translation(capture_to_head)));
const Point ray=rotate(hmd_to_world,rotate(capture_to_head,{center_tan_x,-center_tan_y,-1.}));
const Point delta=subtract(hit,origin);
const double squared_length=dot(ray,ray),distance=dot(delta,ray)/squared_length;
if(!std::isfinite(distance)||distance<0.25||distance>6.)return std::nullopt;
const Point residual={delta[0]-distance*ray[0],delta[1]-distance*ray[1],delta[2]-distance*ray[2]};
const double tolerance=1e-4+1e-4*distance*std::sqrt(squared_length);
if(dot(residual,residual)>tolerance*tolerance)return std::nullopt;
return distance;
}
+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[@]}"
+15 -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,6 +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}[];
+648 -36
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,8 +23,11 @@ use std::{
};
mod cleanup;
mod graphics;
mod install_rollback;
mod lifecycle;
mod native;
pub(crate) mod permissions;
mod q2f;
mod steam_bridge;
mod steam_shortcuts;
pub(crate) mod steam_ui;
@@ -49,6 +53,8 @@ pub(crate) static TEST_SERIAL: Mutex<()> = Mutex::new(());
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Record {
#[serde(default)]
steam_name: Option<String>,
#[serde(default)]
steam_launch: bool,
#[serde(default)]
@@ -81,6 +87,8 @@ struct Record {
orientation: Option<String>,
#[serde(default)]
framebuffer_compatibility: bool,
#[serde(default)]
q2f_version: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct Database {
@@ -107,6 +115,45 @@ struct Database {
#[serde(default)]
records: BTreeMap<String, Record>,
}
#[derive(Default, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct StorageLocations {
#[serde(default)]
apk: Option<PathBuf>,
#[serde(default)]
contexts: Option<PathBuf>,
}
fn storage_config_path(home: &Path) -> PathBuf {
home.join(".local/share/framely/storage.json")
}
fn storage_journal_path(home: &Path) -> PathBuf {
home.join(".local/share/framely/storage-migration.json")
}
fn storage_config(home: &Path) -> Result<StorageLocations> {
let path = storage_config_path(home);
if !path.exists() { return Ok(StorageLocations::default()); }
let meta = fs::symlink_metadata(&path)?;
ensure!(meta.is_file() && !meta.file_type().is_symlink() && meta.uid() == unsafe { libc::geteuid() }, "无效 APK 存储设置文件");
let config: StorageLocations = serde_json::from_slice(&fs::read(path)?)?;
for location in [&config.apk, &config.contexts].into_iter().flatten() {
ensure!(location.is_absolute() && location.is_dir() && !fs::symlink_metadata(location)?.file_type().is_symlink(), "配置的存储目录不可用:{}", location.display());
}
Ok(config)
}
fn save_storage_config(home: &Path, config: &StorageLocations) -> Result<()> {
let path = storage_config_path(home);
fs::create_dir_all(path.parent().unwrap())?;
let tmp = path.with_extension("tmp");
fs::write(&tmp, serde_json::to_vec(config)?)?;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?;
fs::File::open(&tmp)?.sync_all()?;
fs::rename(tmp, &path)?;
fs::File::open(path.parent().unwrap())?.sync_all()?;
Ok(())
}
fn contexts_root(home: &Path) -> PathBuf {
storage_config(home).ok().and_then(|c| c.contexts).unwrap_or_else(|| home.join(".local/share/lepton/contexts"))
}
impl Default for Database {
fn default() -> Self {
Self {
@@ -152,6 +199,7 @@ struct App {
orientation: Option<String>,
#[serde(default)]
framebuffer_compatibility: bool,
q2f_version: Option<String>,
}
fn gamepad_trigger_threshold_default() -> u8 {
80
@@ -180,9 +228,10 @@ fn safe(s: &str) -> bool {
&& s != ".."
}
fn root(home: &Path) -> PathBuf {
home.join(".local/share/framely/apk-manager")
storage_config(home).ok().and_then(|c| c.apk).unwrap_or_else(|| home.join(".local/share/framely/apk-manager"))
}
fn init(home: &Path) -> Result<PathBuf> {
storage_config(home)?;
let r = root(home);
for p in [
home.join(".local"),
@@ -208,6 +257,7 @@ fn init(home: &Path) -> Result<PathBuf> {
Ok(r)
}
fn load(home: &Path) -> Result<Database> {
storage_config(home)?;
let p = root(home).join("state.json");
if !p.exists() {
return Ok(Database::default());
@@ -363,13 +413,17 @@ fn validate_baked(p: &Path) -> Result<PathBuf> {
}
fn containers(home: &Path, db: &Database) -> Vec<Container> {
let mut out = Vec::new();
let base = home.join(".local/share/lepton/contexts");
if let Ok(entries) = fs::read_dir(&base) {
let managed_base = contexts_root(home);
let legacy_base = home.join(".local/share/lepton/contexts");
for base in [&managed_base, &legacy_base] {
if let Ok(entries) = fs::read_dir(base) {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !safe(&name) {
continue;
}
if base == &legacy_base && managed_base != legacy_base && db.owned_contexts.contains(&name) && managed_base.join(&name).exists() { continue; }
if out.iter().any(|c: &Container| c.name == name) { continue; }
let baked = e.path().join("baked");
if let Ok(baked) = validate_baked(&baked) {
out.push(Container {
@@ -383,20 +437,20 @@ 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 = 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 baked = managed_base.join(id).join("baked");
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);
}
}
}
@@ -592,7 +646,7 @@ fn automatic_install_context(
return Ok(id);
}
if !records.is_empty()
|| fs::symlink_metadata(home.join(".local/share/lepton/contexts").join(&id)).is_ok()
|| fs::symlink_metadata(contexts_root(home).join(&id)).is_ok()
{
continue; // Preserve retained records and unreadable/dangling paths.
}
@@ -603,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"
@@ -794,6 +849,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
show_window: rec.and_then(|r| r.show_window),
orientation: rec.and_then(|r| r.orientation.clone()),
framebuffer_compatibility: rec.is_some_and(|r| r.framebuffer_compatibility),
q2f_version: rec.and_then(|r| r.q2f_version.clone()),
});
}
}
@@ -848,6 +904,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
show_window: r.show_window,
orientation: r.orientation.clone(),
framebuffer_compatibility: r.framebuffer_compatibility,
q2f_version: r.q2f_version.clone(),
});
}
}
@@ -893,6 +950,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
show_window: None,
orientation: None,
framebuffer_compatibility: false,
q2f_version: None,
});
}
}
@@ -947,9 +1005,241 @@ pub fn list(home: &Path) -> Result<Value> {
}
}
Ok(
json!({"apps":apps.iter().map(|a| { let mut v=serde_json::to_value(a).unwrap(); v["steamLaunch"]=json!(db.records.get(&a.id).is_none_or(steam_shortcuts::wanted)); v["steamRegistered"]=json!(db.records.get(&a.id).is_some_and(|r|r.steam_launch));v["steamRegistrationError"]=json!(db.records.get(&a.id).and_then(|r|r.steam_registration_error.as_ref())); v }).collect::<Vec<_>>(),"containers":cs.iter().map(|c| { let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(false); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok(),"retainSteamEntries":db.retain_steam_entries,"gamepadEnabled":db.gamepad_enabled,"gamepadSource":db.gamepad_source,"gamepadRumble":db.gamepad_rumble,"gamepadTriggerThreshold":db.gamepad_trigger_threshold,"autoStopContainer":db.auto_stop_container,"stopContainerOnClose":db.stop_container_on_close}),
json!({"apps":apps.iter().map(|a| { let mut v=serde_json::to_value(a).unwrap(); v["steamName"]=json!(db.records.get(&a.id).and_then(|r|r.steam_name.as_ref())); v["steamLaunch"]=json!(db.records.get(&a.id).is_none_or(steam_shortcuts::wanted)); v["steamRegistered"]=json!(db.records.get(&a.id).is_some_and(|r|r.steam_launch));v["steamRegistrationError"]=json!(db.records.get(&a.id).and_then(|r|r.steam_registration_error.as_ref())); v }).collect::<Vec<_>>(),"containers":cs.iter().map(|c| { let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(false); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok(),"retainSteamEntries":db.retain_steam_entries,"gamepadEnabled":db.gamepad_enabled,"gamepadSource":db.gamepad_source,"gamepadRumble":db.gamepad_rumble,"gamepadTriggerThreshold":db.gamepad_trigger_threshold,"autoStopContainer":db.auto_stop_container,"stopContainerOnClose":db.stop_container_on_close}),
)
}
pub fn storage_status(home: &Path) -> Result<Value> {
let config = storage_config(home)?;
let apk = config.apk.unwrap_or_else(|| home.join(".local/share/framely/apk-manager"));
let contexts = config.contexts.unwrap_or_else(|| home.join(".local/share/lepton/contexts"));
let db = load(home)?;
let mut context_bytes = 0u64;
let mut contexts_incomplete = false;
let mut context_details = Vec::new();
for id in &db.owned_contexts {
if safe(id) {
let (size, incomplete) = crate::storage_location::disk_bytes(&contexts.join(id))?;
context_bytes = context_bytes.saturating_add(size);
contexts_incomplete |= incomplete;
context_details.push(json!({"id":id,"name":id,"bytes":size,"sizeIncomplete":incomplete}));
}
}
let (apk_bytes, apk_incomplete) = crate::storage_location::disk_bytes(&apk)?;
let mut apk_details = Vec::new();
if let Ok(entries) = fs::read_dir(apk.join("apks")) {
for entry in entries {
let entry = match entry { Ok(entry) => entry, Err(_) => continue };
let path = entry.path();
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { continue; }
let key = entry.file_name();
let record = db.records.values().find(|record| hash(&record.id) == key.to_string_lossy());
let (size, incomplete) = crate::storage_location::disk_bytes(&path)?;
let name = record.map_or_else(|| key.to_string_lossy().into_owned(), |record| record.metadata.name.clone());
let id = record.map_or_else(|| key.to_string_lossy().into_owned(), |record| record.id.clone());
apk_details.push(json!({"id":id,"name":name,"bytes":size,"sizeIncomplete":incomplete}));
}
}
apk_details.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
let apk_device = fs::metadata(&apk).ok().map(|m| m.dev());
let context_device = fs::metadata(&contexts).ok().map(|m| m.dev());
let volume = crate::storage_location::volume(home)?;
let journal = fs::read(storage_journal_path(home)).ok().and_then(|b|serde_json::from_slice::<Value>(&b).ok());
let committed = journal.as_ref().is_some_and(|v| v["target"] == json!(if v["kind"] == "apk" { &apk } else { &contexts }));
if committed {
let _ = fs::remove_file(storage_journal_path(home));
}
Ok(json!({"apk":{"path":apk,"bytes":apk_bytes,"sizeIncomplete":apk_incomplete,"device":apk_device,"details":apk_details},"contexts":{"path":contexts,"bytes":context_bytes,"sizeIncomplete":contexts_incomplete,"device":context_device,"details":context_details},"volume":volume,"interrupted":if committed {None} else {journal}}))
}
pub fn migrate_storage(home: &Path, kind: &str, target: &Path, cancel: Cancellation, progress: Arc<dyn Fn(Value) + Send + Sync>) -> Result<Value> {
ensure!(matches!(kind, "apk" | "contexts"), "无效存储类型");
let _guard = MUTATION.try_lock().map_err(|_| anyhow::anyhow!("已有 APK 操作正在运行"))?;
cancel.check()?;
let config = storage_config(home)?;
let old_apk = init(home)?;
let lock_file = fs::OpenOptions::new().write(true).create(true).mode(0o600).open(old_apk.join("operation.lock"))?;
ensure!(unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0, "已有 APK 操作正在运行");
let db = load(home)?;
let source = if kind == "apk" { old_apk } else { contexts_root(home) };
let journal_path = storage_journal_path(home);
if let Ok(bytes) = fs::read(&journal_path) {
let record: Value = serde_json::from_slice(&bytes)?;
if record["target"] == json!(source) { fs::remove_file(&journal_path)?; }
}
let (target, resume) = if journal_path.exists() {
let record: Value = serde_json::from_slice(&fs::read(&journal_path)?)?;
ensure!(record["kind"] == kind && record["source"] == json!(source), "存储迁移来源已改变,请检查原目录");
if record["target"] == json!(target) {
let verified = if target.exists() { if kind == "apk" { crate::storage_location::verified_equal(&source, target)? } else { crate::storage_location::verified_selected(&source, target, &db.owned_contexts, false)? } } else { false };
ensure!(!target.exists() || verified || (target.is_dir() && fs::read_dir(target)?.next().is_none()), "上次迁移副本已与原目录不同,请选择新的空目录");
(target.to_path_buf(), verified)
} else { (crate::storage_location::validate_target(&source, target, false)?, false) }
} else {
(crate::storage_location::validate_target(&source, target, false)?, false)
};
let log = root(home).join("logs/storage-migration.log");
let owned: Vec<Container> = containers(home, &db).into_iter()
.filter(|c| db.owned_contexts.contains(&c.id)).collect();
let active: Vec<Container> = owned.iter().filter(|c| c.running).cloned().collect();
let result = (|| -> Result<Value> {
crate::storage_location::write_journal(&journal_path, &json!({"kind":kind,"source":source,"target":target}))?;
progress(json!({"step":"stopping"}));
for c in &owned { stop(c, &log)?; }
cancel.check()?;
if resume { progress(json!({"step":"verifying"})); }
else if kind == "apk" {
crate::storage_location::copy_verified(&source, &target, |step| progress(json!({"step":step})))?;
} else {
crate::storage_location::copy_selected(&source, &target, &db.owned_contexts, false, |step| progress(json!({"step":step})))?;
}
cancel.check()?;
let mut next = config.clone();
if kind == "apk" { next.apk = Some(target.clone()); } else { next.contexts = Some(target.clone()); }
save_storage_config(home, &next)?;
progress(json!({"step":"restarting"}));
let new_log = root(home).join("logs/storage-migration.log");
let mut started = Vec::new();
for c in &active {
let mut relocated = c.clone();
if kind == "contexts" { relocated.baked = target.join(&c.id).join("baked"); }
if let Err(error) = start_container(home, &relocated, None, false, &new_log) {
for opened in &started { let _ = stop(opened, &new_log); }
let _ = save_storage_config(home, &config);
for prior in &active { let _ = start_container(home, prior, None, false, &log); }
return Err(error).context("迁移后容器启动失败,已恢复原位置");
}
started.push(relocated);
}
fs::remove_file(&journal_path)?;
progress(json!({"step":"cleaning"}));
let mut warnings = Vec::new();
if kind == "apk" {
if let Err(error) = crate::storage_location::remove_tree(&source) { warnings.push(error.to_string()); }
} else {
for id in &db.owned_contexts {
if safe(id) && source.join(id).exists() {
if let Err(error) = crate::storage_location::remove_tree(&source.join(id)) { warnings.push(error.to_string()); }
}
}
}
Ok(json!({"path":target,"cleanupWarnings":warnings}))
})();
if result.is_err() {
for c in &active { let _ = start_container(home, c, None, false, &log); }
}
result
}
#[cfg(test)]
mod storage_migration_tests {
use super::*;
#[test]
fn storage_status_lists_apk_and_managed_container_sizes() {
let _serial = TEST_SERIAL.lock().unwrap();
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
fs::create_dir(&home).unwrap();
let apk_root = init(&home).unwrap();
fs::create_dir_all(apk_root.join("apks/example")).unwrap();
fs::write(apk_root.join("apks/example/base.apk"), vec![1; 8192]).unwrap();
let container = contexts_root(&home).join("managed");
fs::create_dir_all(&container).unwrap();
fs::write(container.join("data"), vec![2; 8192]).unwrap();
let mut db = load(&home).unwrap();
db.owned_contexts.push("managed".into());
save(&home, &db).unwrap();
let status = storage_status(&home).unwrap();
assert_eq!(status["apk"]["details"][0]["id"], "example");
assert!(status["apk"]["details"][0]["bytes"].as_u64().unwrap() > 0);
assert_eq!(status["contexts"]["details"][0]["id"], "managed");
assert!(status["contexts"]["details"][0]["bytes"].as_u64().unwrap() > 0);
assert!(status["volume"]["total"].as_u64().unwrap() > 0);
}
#[test]
fn apk_and_owned_contexts_move_independently() {
let _serial = TEST_SERIAL.lock().unwrap();
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
let home = temp.path().join("home");
fs::create_dir(&home).unwrap();
let original_apk = init(&home).unwrap();
fs::write(original_apk.join("apks/example.apk"), b"apk").unwrap();
let apk_work = original_apk.join("recovery/old/baked/app_workdir/work");
fs::create_dir_all(&apk_work).unwrap();
fs::write(apk_work.join("overlay-state"), b"private").unwrap();
fs::set_permissions(&apk_work, fs::Permissions::from_mode(0o000)).unwrap();
let original_contexts = contexts_root(&home);
let baked = original_contexts.join("managed/baked/data_overlay/system");
fs::create_dir_all(&baked).unwrap();
fs::write(baked.join("packages.xml"), b"<packages/>").unwrap();
let context_work = original_contexts.join("managed/baked/data_workdir/work");
fs::create_dir_all(&context_work).unwrap();
fs::write(context_work.join("overlay-state"), b"private").unwrap();
fs::set_permissions(&context_work, fs::Permissions::from_mode(0o000)).unwrap();
let external = original_contexts.join("external/baked/data_overlay/system");
fs::create_dir_all(&external).unwrap();
fs::write(external.join("packages.xml"), b"<packages/>").unwrap();
let mut db = load(&home).unwrap();
db.owned_contexts.push("managed".into());
save(&home, &db).unwrap();
let apk_target = temp.path().join("apk-new");
migrate_storage(&home,"apk",&apk_target,Cancellation::default(),Arc::new(|_|{})).unwrap();
assert_eq!(root(&home), apk_target);
assert_eq!(fs::read(root(&home).join("apks/example.apk")).unwrap(), b"apk");
assert!(!original_apk.exists());
assert_eq!(fs::metadata(apk_target.join("recovery/old/baked/app_workdir/work")).unwrap().permissions().mode() & 0o777, 0);
assert!(original_contexts.join("managed/baked").is_dir());
let context_target = temp.path().join("android-new");
migrate_storage(&home,"contexts",&context_target,Cancellation::default(),Arc::new(|_|{})).unwrap();
assert_eq!(contexts_root(&home), context_target);
assert!(context_target.join("managed/baked/data_overlay/system/packages.xml").is_file());
assert!(!context_target.join("external").exists());
assert!(original_contexts.join("external/baked").is_dir());
assert!(!original_contexts.join("managed").exists());
let found = containers(&home,&load(&home).unwrap());
assert!(found.iter().any(|c|c.id=="managed" && c.baked.starts_with(&context_target)));
assert!(found.iter().any(|c|c.id=="external" && c.baked.starts_with(&original_contexts)));
fs::set_permissions(apk_target.join("recovery/old/baked/app_workdir/work"), fs::Permissions::from_mode(0o700)).unwrap();
fs::set_permissions(context_target.join("managed/baked/data_workdir/work"), fs::Permissions::from_mode(0o700)).unwrap();
}
#[test]
fn interrupted_apk_copy_resumes_without_touching_original() {
let _serial = TEST_SERIAL.lock().unwrap();
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
let home = temp.path().join("home");
fs::create_dir(&home).unwrap();
let source = init(&home).unwrap();
fs::write(source.join("apks/keep.apk"), b"saved apk").unwrap();
fs::write(source.join("operation.lock"), b"").unwrap();
let target = temp.path().join("apk-resume");
crate::storage_location::copy_verified(&source,&target,|_|{}).unwrap();
crate::storage_location::write_journal(&storage_journal_path(&home),&json!({"kind":"apk","source":source,"target":target})).unwrap();
assert_eq!(storage_status(&home).unwrap()["interrupted"]["target"],json!(target));
migrate_storage(&home,"apk",&target,Cancellation::default(),Arc::new(|_|{})).unwrap();
assert_eq!(root(&home),target);
assert_eq!(fs::read(target.join("apks/keep.apk")).unwrap(),b"saved apk");
assert!(!storage_journal_path(&home).exists());
}
#[test]
fn storage_status_survives_unreadable_overlay_workdirs() {
if unsafe { libc::geteuid() } == 0 { return; }
let _serial = TEST_SERIAL.lock().unwrap();
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
fs::create_dir(&home).unwrap();
let apk_root = init(&home).unwrap();
let apk_work = apk_root.join("recovery/old/baked/app_workdir/work");
fs::create_dir_all(&apk_work).unwrap();
let context_work = contexts_root(&home).join("managed/baked/data_workdir/work");
fs::create_dir_all(&context_work).unwrap();
let mut db = load(&home).unwrap();
db.owned_contexts.push("managed".into());
save(&home, &db).unwrap();
for path in [&apk_work, &context_work] { fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap(); }
let status = storage_status(&home);
for path in [&apk_work, &context_work] { fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); }
let status = status.unwrap();
assert_eq!(status["apk"]["sizeIncomplete"], true);
assert_eq!(status["contexts"]["sizeIncomplete"], true);
}
}
pub fn launcher(home: &Path) -> Vec<Value> {
let Ok(db) = load(home) else { return vec![] };
let cs = containers(home, &db);
@@ -1194,6 +1484,10 @@ fn direct_launch_script(source: &str) -> Result<String> {
"Unsupported Lepton entry script; cannot safely configure the APK display"
);
let hooks = r#"
# Lepton 3 calls this helper from remove_prefix, independently of
# LEPTON_NO_CLEANUP. Preserve installed data for both the package-manager
# container and native Steam launches; temporary prefix removal still runs.
function prepare_baked_data_for_removal() { :; }
# Preserve downloaded Android resources in development contexts. Lepton's
# single-app launcher deletes media/0 before creating its external-storage link.
function framely_prepare_media() {
@@ -1228,6 +1522,24 @@ function framely_prepare_media() {
mkdir -p -- "$media"
fi
}
function framely_link_host_directory() {
local target="$1" source="$2"
if [[ -L "$target" ]]; then
[[ "$(readlink "$target")" == "$source" ]] || { echo "Unexpected Android storage link: $target" >&2; return 64; }
rm -f -- "$target" || return
elif [[ -d "$target" ]]; then
# An app may replace the link with a real directory. Keep its files
# visible inside Android instead of removing or hiding them.
if [[ -n "$(find "$target" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
return 0
fi
rmdir -- "$target" || return
elif [[ -e "$target" ]]; then
echo "Unexpected Android storage entry: $target" >&2
return 64
fi
ln -s -- "$source" "$target"
}
for framely_mount_hook in setup_mounts setup_podman_mounts; do
declare -F "$framely_mount_hook" >/dev/null || continue
framely_mount_body="$(declare -f "$framely_mount_hook")"
@@ -1240,6 +1552,21 @@ for framely_mount_hook in setup_mounts setup_podman_mounts; do
fi
if [[ "$framely_mount_body" == *"$framely_media_remove"* ]]; then
framely_mount_body="${framely_mount_body//"$framely_media_remove"/framely_prepare_media || return}"
if [[ "$framely_mount_hook" == setup_mounts ]]; then
for framely_host_pair in 'Documents:Documents' 'Download:Downloads' 'Movies:Videos'; do
framely_host_target="${framely_host_pair%%:*}"
framely_host_source="${framely_host_pair#*:}"
framely_host_remove="rm -rf \"\${TARGET_PATH}/$framely_host_target\""
framely_host_link="ln -s \"\${HOME}/$framely_host_source\" \"\${TARGET_PATH}/$framely_host_target\""
if [[ "$framely_mount_body" == *"$framely_host_remove"* || "$framely_mount_body" == *"$framely_host_link"* ]]; then
[[ "$framely_mount_body" == *"$framely_host_remove"* && "$framely_mount_body" == *"$framely_host_link"* ]] || { echo "Unsupported Lepton host storage links" >&2; exit 64; }
framely_host_replacement="framely_link_host_directory \"\${TARGET_PATH}/$framely_host_target\" \"\${HOME}/$framely_host_source\" || return"
framely_mount_body="${framely_mount_body//"$framely_host_remove"/$framely_host_replacement}"
framely_mount_body="${framely_mount_body//"$framely_host_link"/:}"
fi
done
unset framely_host_pair framely_host_target framely_host_source framely_host_remove framely_host_link framely_host_replacement
fi
if [[ "${FRAMELY_NATIVE_LAUNCH:-false}" == true ]]; then
framely_native_link='ln -s "${STEAM_COMPAT_DATA_PATH}/external"'
framely_preserved_link='ln -s "${FRAMELY_EXTERNAL_MEDIA_DIR:?}"'
@@ -1763,6 +2090,7 @@ fn record_app(db: &mut Database, a: &App) {
show_window: a.show_window,
orientation: a.orientation.clone(),
framebuffer_compatibility: a.framebuffer_compatibility,
q2f_version: a.q2f_version.clone(),
..Default::default()
});
}
@@ -1846,17 +2174,21 @@ fn snapshot_inventory(
Ok(())
}
fn recover_restores(home: &Path, db: &mut Database) -> Result<()> {
let base = home.join(".local/share/lepton/contexts");
let base = contexts_root(home);
let legacy = home.join(".local/share/lepton/contexts");
let mut locations = BTreeMap::new();
for id in &db.owned_contexts {
ensure!(safe(id), "Invalid managed context");
locations.insert(id.clone(), base.join(id));
}
if let Ok(entries) = fs::read_dir(&base) {
for e in entries.flatten() {
let id = e.file_name().to_string_lossy().into_owned();
if safe(&id) && !id.starts_with("steamlaunch-") && e.file_type()?.is_dir() {
locations.insert(id, e.path());
for directory in [&base, &legacy] {
if let Ok(entries) = fs::read_dir(directory) {
for e in entries.flatten() {
let id = e.file_name().to_string_lossy().into_owned();
if safe(&id) && !id.starts_with("steamlaunch-") && e.file_type()?.is_dir()
&& (directory == &base || !db.owned_contexts.contains(&id)) {
locations.insert(id, e.path());
}
}
}
}
@@ -2453,6 +2785,18 @@ pub fn reviews_drop(home: &Path, ticket: &str) -> Result<Value> {
}
Ok(json!(true))
}
pub fn q2f_convert(
home: &Path,
ticket: &str,
cancel: &Cancellation,
progress: impl Fn(Value) -> Result<()>,
proxy: &ProxySettings,
) -> Result<Value> {
q2f::convert(home, ticket, cancel, progress, proxy)
}
pub fn q2f_status(home: &Path) -> Result<Value> {
q2f::status(home)
}
pub fn operate(
home: &Path,
kind: &str,
@@ -2468,7 +2812,7 @@ pub fn operate(
}
}
let result = operate_internal(home, kind, p, cancel, progress, None);
if matches!(kind, "install" | "steam.retention.settings") && result.is_ok() {
if matches!(kind, "install" | "steam.retention.settings" | "settings") && result.is_ok() {
steam_shortcuts::wake_registration();
}
result
@@ -2690,6 +3034,15 @@ fn operate_internal(
.transpose()?;
let ticket = p["ticket"].as_str().context("Missing reviewed APK")?;
let (review, metadata) = review(home, ticket)?;
let q2f_version = match fs::read_to_string(review.join("q2f-version")) {
Ok(value) => {
let id = value.trim();
ensure!(safe(id) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b".+-".contains(&b)), "Invalid q2f review version");
Some(id.to_owned())
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
let requested = p["context"].as_str().unwrap_or("");
let cs = containers(home, &db);
let context = if requested.is_empty() {
@@ -2702,8 +3055,7 @@ fn operate_internal(
let c = existing.unwrap_or_else(|| Container {
id: context.clone(),
name: context.clone(),
baked: home
.join(".local/share/lepton/contexts")
baked: contexts_root(home)
.join(&context)
.join("baked"),
running: false,
@@ -2734,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
@@ -2768,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(),
@@ -2786,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;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);
@@ -2845,6 +3225,11 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
);
record_app(&mut db, &a);
let rec = db.records.get_mut(id).unwrap();
let steam_name = p.get("steamName").map(steam_shortcuts::parse_name).transpose()?;
if let Some(name) = &steam_name {
ensure!(!a.steam, "This app is already managed by Steam");
rec.steam_name = name.clone();
}
if let Some(v) = p.get("activity") {
let activity = v.as_str().filter(|s| !s.is_empty());
ensure!(
@@ -2875,6 +3260,14 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
rec.orientation = v.as_str().filter(|s| *s != "auto").map(str::to_owned);
}
cancel.commit(|| save(home, &db))?;
if steam_name.is_some() {
let rec = db.records.get_mut(id).unwrap();
if rec.steam_launch && steam_shortcuts::linked(home, rec) {
let result = steam_shortcuts::update_name(home, &a, rec);
rec.steam_registration_error = result.err().map(|e| format!("{e:#}"));
save(home, &db)?;
}
}
Ok(json!(true))
}
"launch" | "close" | "uninstall" | "purge" | "clear" => {
@@ -3165,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 {
@@ -3865,6 +4259,73 @@ setup_podman_mounts
assert_eq!(fs::read(&checkpoint).unwrap(), b"offset=19");
}
#[test]
fn direct_boot_keeps_real_android_host_folders() {
let dir = tempfile::tempdir().unwrap();
let library = dir.path().join("lepton/liblepton");
fs::create_dir_all(&library).unwrap();
fs::write(library.join("liblepton.sh"), r#"
function data_mount_path() { echo "$TEST_DATA"; }
function setup_mounts() {
rm -rf "$(data_mount_path)/media/0"
mkdir -p "$(data_mount_path)/media"
local TARGET_PATH="$TEST_EXTERNAL"
mkdir -p "$TARGET_PATH"
if [[ -d "${HOME}/Documents" ]]; then
rm -rf "${TARGET_PATH}/Documents"
ln -s "${HOME}/Documents" "${TARGET_PATH}/Documents"
fi
if [[ -d "${HOME}/Downloads" ]]; then
rm -rf "${TARGET_PATH}/Download"
ln -s "${HOME}/Downloads" "${TARGET_PATH}/Download"
fi
if [[ -d "${HOME}/Videos" ]]; then
rm -rf "${TARGET_PATH}/Movies"
ln -s "${HOME}/Videos" "${TARGET_PATH}/Movies"
fi
}
function setup_podman_mounts() { setup_mounts; }
"#).unwrap();
let source = r#"#!/bin/bash
set -euo pipefail
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
source "${SCRIPT_DIR}/liblepton/liblepton.sh"
setup_podman_mounts
"#;
let entry = dir.path().join("entry.sh");
fs::write(&entry, direct_launch_script(source).unwrap()).unwrap();
let home = dir.path().join("home");
let external = dir.path().join("external");
let data = dir.path().join("data");
for folder in ["Documents", "Downloads", "Videos"] {
fs::create_dir_all(home.join(folder)).unwrap();
}
let run = || Command::new("bash")
.arg(&entry)
.env("FRAMELY_LEPTON_DIR", library.parent().unwrap())
.env("HOME", &home)
.env("TEST_DATA", &data)
.env("TEST_EXTERNAL", &external)
.output().unwrap();
let first = run();
assert!(first.status.success(), "{}", String::from_utf8_lossy(&first.stderr));
for (target, source) in [("Documents", "Documents"), ("Download", "Downloads"), ("Movies", "Videos")] {
assert_eq!(fs::read_link(external.join(target)).unwrap(), home.join(source));
}
fs::remove_file(external.join("Documents")).unwrap();
fs::create_dir(external.join("Documents")).unwrap();
fs::write(external.join("Documents/keep.txt"), b"android file").unwrap();
let second = run();
assert!(second.status.success(), "{}", String::from_utf8_lossy(&second.stderr));
assert_eq!(fs::read(external.join("Documents/keep.txt")).unwrap(), b"android file");
assert!(external.join("Documents").is_dir());
fs::remove_file(external.join("Download")).unwrap();
std::os::unix::fs::symlink(dir.path().join("unexpected"), external.join("Download")).unwrap();
let third = run();
assert!(!third.status.success());
assert!(external.join("Download").is_symlink());
assert_eq!(fs::read(external.join("Documents/keep.txt")).unwrap(), b"android file");
}
#[test]
fn direct_boot_binds_resources_and_reuses_shader_cache() {
let dir = tempfile::tempdir().unwrap();
let library = dir.path().join("lepton/liblepton");
@@ -4176,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();
@@ -4318,6 +4893,38 @@ setup_props
})
}
#[test]
fn custom_steam_names_persist_reset_and_preserve_app_identity() {
let f = Fixture::new();
let id = "test/com.example.app";
let before = list(&f.home).unwrap();
let original = before["apps"][0]["metadata"]["name"].clone();
f.operation("settings", json!({"app":id,"steamName":" 我的游戏 🎮 "})).unwrap();
assert_eq!(load(&f.home).unwrap().records[id].steam_name.as_deref(), Some("我的游戏 🎮"));
let after = list(&f.home).unwrap();
assert_eq!(after["apps"][0]["steamName"], "我的游戏 🎮");
assert_eq!(after["apps"][0]["metadata"]["name"], original);
for value in [json!(42), json!("bad\nname"), json!("x".repeat(129))] {
assert!(f.operation("settings", json!({"app":id,"steamName":value})).is_err());
assert_eq!(load(&f.home).unwrap().records[id].steam_name.as_deref(), Some("我的游戏 🎮"));
}
let mut db = load(&f.home).unwrap();
let (a, _) = app(&f.home, &db, id).unwrap();
let record = db.records.get_mut(id).unwrap();
record.steam_binding = Some(steam_shortcuts::Binding { game_id: steam_shortcuts::game_id(id), native: true, revision: "stale".into() });
steam_ui::TEST_RESULT.with(|v| *v.borrow_mut() = Some(json!(true)));
steam_shortcuts::update_name(&f.home, &a, record).unwrap();
assert_ne!(record.steam_binding.as_ref().unwrap().revision, "stale");
let current_revision = record.steam_binding.as_ref().unwrap().revision.clone();
record.steam_name = Some("New name".into());
steam_ui::TEST_RESULT.with(|v| *v.borrow_mut() = Some(json!(false)));
assert!(steam_shortcuts::update_name(&f.home, &a, record).is_err());
assert_eq!(record.steam_binding.as_ref().unwrap().revision, current_revision);
f.operation("settings", json!({"app":id,"steamName":" "})).unwrap();
assert!(load(&f.home).unwrap().records[id].steam_name.is_none());
assert!(list(&f.home).unwrap()["apps"][0]["steamName"].is_null());
assert_eq!(fs::read_to_string(f.home.join(".local/share/lepton/contexts/test/baked/data_overlay/data/com.example.app/files/save")).unwrap(), "saved progress");
}
#[test]
fn transient_steam_entry_is_launch_only_protects_newer_launch_and_preserves_data() {
let f = Fixture::new();
list(&f.home).unwrap();
@@ -4780,12 +5387,17 @@ setup_props
assert!(load(&f.home).unwrap().records.is_empty());
for (version, show) in [(43, false), (44, true)] {
let ticket = f.review(version);
if version == 43 {
fs::write(root(&f.home).join("reviews").join(&ticket).join("q2f-version"), "test-q2f-1").unwrap();
}
f.operation(
"install",
json!({"ticket":ticket,"context":"test","app":id,"approve":true,"showWindow":show}),
)
.unwrap();
assert_eq!(load(&f.home).unwrap().records[id].show_window, Some(show));
assert_eq!(load(&f.home).unwrap().records[id].q2f_version.as_deref(),
if version == 43 { Some("test-q2f-1") } else { None });
assert!(
!running("test"),
"installation must finish its headless runtime"
+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 = 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)
}
}
+3 -2
View File
@@ -214,14 +214,15 @@ fn candidates(home: &Path, db: &Database) -> Vec<Container> {
.filter(|r| !r.removed)
.map(|r| r.context.clone())
.collect();
let base = home.join(".local/share/lepton/contexts");
let base = super::contexts_root(home);
let default_base = home.join(".local/share/lepton/contexts");
let mut out = Vec::new();
for id in contexts {
if !safe(&id) || id.starts_with("steamlaunch-") {
continue;
}
let mut name = id.clone();
let mut baked = base.join(&id).join("baked");
let mut baked = (if db.owned_contexts.contains(&id) { &base } else { &default_base }).join(&id).join("baked");
if id.starts_with("external-") {
let Some(path) = db
.roots
+26 -3
View File
@@ -30,9 +30,17 @@ if [[ -n "${FRAMELY_NATIVE_APK_DIR:-}" ]]; then
framely_native_mounts="$(declare -f setup_mounts)"
framely_native_lower='APP_APK_DIR="$(dirname "$(get_apk_path)")"'
framely_native_upper='$(get_baked_app_data_dir)/app_overlay'
[[ "$framely_native_mounts" == *"$framely_native_lower"* && "$framely_native_mounts" == *"$framely_native_upper"* ]] || { echo 'Unsupported native APK mounts; keeping application data unchanged' >&2; exit 64; }
[[ "$framely_native_mounts" == *"$framely_native_lower"* ]] || { echo 'Unsupported native APK mounts; keeping application data unchanged' >&2; exit 64; }
if [[ "$framely_native_mounts" == *"$framely_native_upper"* ]]; then
framely_native_mounts="${framely_native_mounts//"$framely_native_upper"/'${FRAMELY_NATIVE_OVERLAY:?}/upper'}"
elif [[ "$framely_native_mounts" == *'APP_OVERLAY_UPPERDIR="$(app_datadir)"'* ]] && declare -F app_datadir >/dev/null && [[ "$(declare -f app_datadir)" == *'$(get_baked_app_data_dir $@)/app_overlay'* ]]; then
# Lepton 3 routes the APK upper directory through app_datadir.
function app_datadir() { print "${FRAMELY_NATIVE_OVERLAY:?}/upper"; }
else
echo 'Unsupported native APK mounts; keeping application data unchanged' >&2
exit 64
fi
framely_native_mounts="${framely_native_mounts//"$framely_native_lower"/'APP_APK_DIR="${FRAMELY_NATIVE_APK_DIR:?}"'}"
framely_native_mounts="${framely_native_mounts//"$framely_native_upper"/'${FRAMELY_NATIVE_OVERLAY:?}/upper'}"
function app_workdir() { print "${FRAMELY_NATIVE_OVERLAY:?}/work"; }
eval "$framely_native_mounts"
unset framely_native_mounts framely_native_lower framely_native_upper
@@ -344,6 +352,10 @@ function setup_mounts() {{
setup_mounts
"#
);
for source in [source.clone(), source.replace(
" local APP_OVERLAY_UPPERDIR=\"$(get_baked_app_data_dir)/app_overlay\"",
" local APP_OVERLAY_UPPERDIR=\"$(app_datadir)\"",
).replacen("function setup_mounts()", "function app_datadir() { print \"$(get_baked_app_data_dir $@)/app_overlay\"; }\nfunction setup_mounts()", 1)] {
for package in ["one", "two"] {
let out = Command::new("bash")
.args(["-c", &source])
@@ -362,6 +374,7 @@ setup_mounts
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), format!("/baked/data_overlay/app/{package}|/baked/native-{package}/upper|/baked/native-{package}/work"));
}
}
}
#[test]
fn native_hooks_keep_storage_and_selected_activity_after_early_exit() {
let t = tempfile::tempdir().unwrap();
@@ -382,6 +395,11 @@ function setup_podman_mounts() { setup_mounts; }
function podman_mount_entry() { :; }
function print() { printf '%s\n' "$*"; }
function println() { print "$@"; }
function prepare_baked_data_for_removal() { rm -rf "$TEST_DATA"; }
function remove_prefix() {
prepare_baked_data_for_removal
rm -rf "$LEPTON_PREFIX"
}
"#,
)
.unwrap();
@@ -390,7 +408,7 @@ set -euo pipefail
COMMAND=waitforexitandrun
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
source "${SCRIPT_DIR}/liblepton/liblepton.sh"
function teardown() { clear_baked_app_data 'early exit'; }
function teardown() { clear_baked_app_data 'early exit'; remove_prefix; }
case "${COMMAND}" in
waitforexitandrun)
is_app_baked
@@ -413,6 +431,9 @@ esac
fs::create_dir_all(save.parent().unwrap()).unwrap();
fs::write(&save, b"saved game").unwrap();
for flat in [true, false] {
let prefix = t.path().join("lepton-prefix");
fs::create_dir_all(&prefix).unwrap();
fs::write(prefix.join("temporary"), b"runtime").unwrap();
let out = Command::new("bash")
.arg(&entry)
.env("FRAMELY_LEPTON_DIR", lib.parent().unwrap())
@@ -424,6 +445,7 @@ esac
.env("FRAMELY_NATIVE_ACTIVITY", "test.Selected")
.env("APP_WANTS_FLATSCREEN", if flat { "true" } else { "false" })
.env("TEST_DATA", &data)
.env("LEPTON_PREFIX", &prefix)
.env("TEST_PROPS", t.path().join("props"))
.output()
.unwrap();
@@ -441,6 +463,7 @@ esac
assert!(String::from_utf8_lossy(&out.stdout).contains(expected));
assert_eq!(fs::read(&save).unwrap(), b"saved game");
assert_eq!(fs::read(&download).unwrap(), b"resume-me");
assert!(!prefix.exists(), "Temporary prefix must still be cleaned up");
}
assert!(script("unknown future native entry").is_err());
}
+138
View File
@@ -0,0 +1,138 @@
//! Mirrors only a pending Android runtime-permission dialog into a VR window.
//! The user's click is delivered to Android's own PermissionController, so
//! Android retains its one-time/while-in-use/denial semantics and callbacks.
use super::*;
use base64::{engine::general_purpose::STANDARD, Engine};
use image::{DynamicImage, ImageFormat};
use std::collections::BTreeSet;
use std::io::{Cursor, Seek, SeekFrom};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Pending {
pub id: String,
pub app_name: String,
pub package: String,
runtime: String,
instance: String,
}
fn active_permission(runtime: &str) -> Result<bool> {
let mut command = crate::process::tool("podman");
command.args([
"exec", &format!("lepton-{runtime}"), "sh", "-c",
"dumpsys activity activities | grep -E 'topResumedActivity=|mResumedActivity:'; printf '\\nFRAMELY_PERMISSION_DONE\\n'",
]);
let text = output(command, Duration::from_secs(3), None)?;
ensure!(text.ends_with("FRAMELY_PERMISSION_DONE\n"), "Incomplete permission probe");
Ok(text.lines().any(|line| {
(line.contains("topResumedActivity=") || line.contains("mResumedActivity:"))
&& line.contains("com.android.permissioncontroller/")
&& line.contains("GrantPermissionsActivity")
}))
}
pub(crate) fn pending(home: &Path) -> Result<Option<Pending>> {
let db = load(home)?;
let mut command = crate::process::tool("podman");
command.args(["ps", "--format", "{{.Names}}"]);
let running = output(command, Duration::from_secs(3), None)?;
let running: BTreeSet<_> = running.lines().collect();
let contexts = containers(home, &db);
for record in db.records.values().filter(|r| !r.removed && r.metadata.vr && !r.show_window.unwrap_or(false)) {
let Some(base) = contexts.iter().find(|c| c.id == record.context) else { continue };
let runtime = native::runtime_name(record).filter(|name| running.contains(format!("lepton-{name}").as_str()))
.unwrap_or_else(|| base.name.clone());
if !running.contains(format!("lepton-{runtime}").as_str()) { continue; }
let mut probe = crate::process::tool("podman");
probe.args(["exec", &format!("lepton-{runtime}"), "getprop", "lepton.active_app_id"]);
let Ok(package) = output(probe, Duration::from_secs(3), None) else { continue };
if package.trim() != record.metadata.package { continue; }
if !active_permission(&runtime)? { continue; }
let mut inspect = crate::process::tool("podman");
inspect.args(["inspect", "--format", "{{.Id}}|{{.State.StartedAt}}|{{.State.Running}}", &format!("lepton-{runtime}")]);
let Ok(instance) = output(inspect, Duration::from_secs(3), None) else { continue };
let instance = instance.trim().to_owned();
if !instance.ends_with("|true") || instance.split('|').count() != 3 { continue; }
let id = hash(&format!("{}|{}|{}", runtime, instance, package.trim()));
return Ok(Some(Pending {
id,
app_name: record.metadata.name.clone(),
package: package.trim().to_owned(),
runtime,
instance,
}));
}
Ok(None)
}
fn current(home: &Path, id: &str) -> Result<Pending> {
let request = pending(home)?.context("Android permission request has ended")?;
ensure!(request.id == id, "Android permission request has changed");
Ok(request)
}
fn screenshot(runtime: &str) -> Result<Vec<u8>> {
let mut file = tempfile::tempfile()?;
let mut child = crate::process::tool("podman")
.args(["exec", &format!("lepton-{runtime}"), "screencap", "-p"])
.stdin(Stdio::null()).stdout(Stdio::from(file.try_clone()?)).stderr(Stdio::null())
.spawn()?;
let start = Instant::now();
let status = loop {
if let Some(status) = child.try_wait()? { break status; }
if start.elapsed() >= Duration::from_secs(3) {
let _ = child.kill(); let _ = child.wait();
bail!("Android permission capture timed out");
}
std::thread::sleep(Duration::from_millis(20));
};
ensure!(status.success(), "Android permission capture failed");
ensure!(file.metadata()?.len() <= 8 * 1024 * 1024, "Android permission capture too large");
file.seek(SeekFrom::Start(0))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
ensure!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "Android permission capture is not PNG");
Ok(bytes)
}
pub(crate) fn frame(home: &Path, id: &str) -> Result<Value> {
let request = current(home, id)?;
let image = image::load_from_memory_with_format(&screenshot(&request.runtime)?, ImageFormat::Png)?;
let (width, height) = (image.width(), image.height());
ensure!((320..=4096).contains(&width) && (240..=4096).contains(&height), "Unexpected Android display size");
let crop_width = width * 11 / 20;
let crop_height = height * 11 / 20;
let left = (width - crop_width) / 2;
let top = (height - crop_height) / 2;
let cropped = image.crop_imm(left, top, crop_width, crop_height);
let mut png = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(cropped.into_rgba8()).write_to(&mut png, ImageFormat::Png)?;
ensure!(current(home, id)?.instance == request.instance, "Android permission request has changed");
Ok(json!({"id":id,"appName":request.app_name,"package":request.package,
"image":format!("data:image/png;base64,{}",STANDARD.encode(png.into_inner())),
"left":left,"top":top,"width":crop_width,"height":crop_height}))
}
const INPUT: &str = r#"
set -eu
for entry in $(cat /proc/$(pidof system_server)/environ | tr '\0' '\n' | grep -E '^(ANDROID_(ROOT|DATA|ART_ROOT|I18N_ROOT|TZDATA_ROOT)|BOOTCLASSPATH)='); do
export "$entry"
done
exec input touchscreen tap "$1" "$2"
"#;
pub(crate) fn click(home: &Path, id: &str, x: u32, y: u32) -> Result<Value> {
let request = current(home, id)?;
ensure!(x < 4096 && y < 4096, "Invalid Android input position");
let image = image::load_from_memory_with_format(&screenshot(&request.runtime)?, ImageFormat::Png)?;
ensure!(x < image.width() && y < image.height(), "Android input position outside display");
ensure!(current(home, id)?.instance == request.instance, "Android permission request has changed");
let mut command = crate::process::tool("podman");
command.args([
"exec", &format!("lepton-{}", request.runtime), "sh", "-c", INPUT,
"framely-permission", &x.to_string(), &y.to_string(),
]);
output(command, Duration::from_secs(3), None)?;
Ok(json!(true))
}
+367
View File
@@ -0,0 +1,367 @@
//! 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::{
fs,
io::Read,
os::unix::fs::{symlink, PermissionsExt},
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
time::Duration,
};
fn verify_framebridge(
apk: &Path,
runtime: &Path,
libraries_version: &str,
abis: &[String],
) -> Result<()> {
let mut archive = zip::ZipArchive::new(fs::File::open(apk)?)?;
let mut found = false;
for abi in abis
.iter()
.filter(|abi| abi.as_str() == "arm64-v8a" || abi.as_str() == "armeabi-v7a")
{
found = true;
let library = runtime
.join("libraries")
.join(libraries_version)
.join("lib")
.join(abi);
for (apk_name, source_name) in [
("libopenxr_loader_generic.so", "libopenxr_loader_generic.so"),
(
"libopenxr_loader_original.so",
"libopenxr_loader_original.so",
),
("libframe_settings.so", "libframe_settings.so"),
] {
let expected = fs::read(library.join(source_name))?;
let mut entry = archive.by_name(&format!("lib/{abi}/{apk_name}"))?;
ensure!(
entry.size() == expected.len() as u64,
"Quest adapter file size mismatch: {abi}/{apk_name}"
);
let mut actual = Vec::with_capacity(expected.len());
entry.read_to_end(&mut actual)?;
ensure!(
actual == expected,
"Quest adapter file mismatch: {abi}/{apk_name}"
);
}
}
ensure!(found, "Quest APK has no supported FrameBridge architecture");
Ok(())
}
fn release_root() -> Result<PathBuf> {
Ok(std::env::current_exe()?
.parent()
.context("Missing Framely binary directory")?
.parent()
.context("Missing Framely release directory")?
.to_path_buf())
}
fn requirement_path() -> Result<PathBuf> {
if let Some(path) = std::env::var_os("FRAMELY_Q2F_DESCRIPTOR") {
return Ok(PathBuf::from(path));
}
Ok(release_root()?.join("Q2F_RUNTIME.json"))
}
fn tool_path() -> Result<PathBuf> {
if let Some(path) = std::env::var_os("FRAMELY_Q2F_TOOL") {
return Ok(PathBuf::from(path));
}
Ok(release_root()?.join("tools/q2f-runtime.py"))
}
pub(super) fn status(home: &Path) -> Result<Value> {
let requirement: Value = serde_json::from_slice(&fs::read(requirement_path()?)?)?;
let identity = requirement["id"]
.as_str()
.context("Invalid q2f runtime identity")?;
ensure!(
!identity.is_empty()
&& identity
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b".+-".contains(&b))
&& !identity.contains(".."),
"Invalid q2f runtime identity"
);
let cached = super::root(home).join("q2f/runtimes").join(identity);
Ok(json!({"id":identity,"bytes":requirement["size"],
"cached":fs::read_to_string(cached.join("VERSION")).is_ok_and(|v|v.trim()==identity)}))
}
fn run(mut command: Command, log: &Path, cancel: &Cancellation) -> Result<()> {
let output = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(log)?;
let errors = output.try_clone()?;
let mut child = command
.stdout(Stdio::from(output))
.stderr(Stdio::from(errors))
.spawn()?;
loop {
if let Err(error) = cancel.check() {
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
if let Some(status) = child.try_wait()? {
if status.success() {
return Ok(());
}
let detail = fs::read_to_string(log).unwrap_or_default();
let tail = detail
.chars()
.rev()
.take(3000)
.collect::<String>()
.chars()
.rev()
.collect::<String>();
anyhow::bail!("Quest conversion failed ({}): {}", status, tail.trim());
}
thread::sleep(Duration::from_millis(200));
}
}
fn workspace(storage: &Path, runtime: &Path) -> Result<PathBuf> {
let workspace = storage.join("q2f/workspace");
fs::create_dir_all(workspace.join("signatures"))?;
fs::set_permissions(
workspace.join("signatures"),
fs::Permissions::from_mode(0o700),
)?;
let libraries = workspace.join("libraries");
if libraries.is_symlink() || libraries.exists() {
if !libraries.is_symlink() {
anyhow::bail!("Unexpected q2f libraries path");
}
fs::remove_file(&libraries)?;
}
let identity = runtime
.file_name()
.context("Missing q2f runtime identity")?;
// Keep this relative so APK storage migration preserves the link.
symlink(
Path::new("../runtimes").join(identity).join("libraries"),
&libraries,
)?;
fs::copy(
runtime.join("overport-installed.json"),
workspace.join("installed.json"),
)?;
Ok(workspace)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn converted_apk_requires_the_exact_wrapped_loader_and_settings() {
let temp = tempfile::tempdir().unwrap();
let runtime = temp.path().join("runtime");
let lib = runtime.join("libraries/test/lib/arm64-v8a");
fs::create_dir_all(&lib).unwrap();
let apk = temp.path().join("converted.apk");
let mut writer = zip::ZipWriter::new(fs::File::create(&apk).unwrap());
for (name, data) in [
("libopenxr_loader_generic.so", b"framebridge".as_slice()),
("libopenxr_loader_original.so", b"overport".as_slice()),
("libframe_settings.so", b"controller_fix=1\n".as_slice()),
] {
fs::write(lib.join(name), data).unwrap();
writer
.start_file(
format!("lib/arm64-v8a/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
writer.write_all(data).unwrap();
}
writer.finish().unwrap();
let abis = vec!["arm64-v8a".to_string()];
verify_framebridge(&apk, &runtime, "test", &abis).unwrap();
fs::write(lib.join("libframe_settings.so"), b"controller_fix=0\n").unwrap();
assert!(verify_framebridge(&apk, &runtime, "test", &abis).is_err());
}
#[test]
fn workspace_library_link_survives_apk_storage_move() {
let temp = tempfile::tempdir().unwrap();
let old = temp.path().join("old");
let runtime = old.join("q2f/runtimes/test-id");
fs::create_dir_all(runtime.join("libraries")).unwrap();
fs::write(runtime.join("overport-installed.json"), "{}").unwrap();
fs::write(runtime.join("libraries/probe"), "ok").unwrap();
let work = workspace(&old, &runtime).unwrap();
assert_eq!(
fs::read_to_string(work.join("libraries/probe")).unwrap(),
"ok"
);
let new = temp.path().join("new");
fs::rename(old, &new).unwrap();
assert_eq!(
fs::read_to_string(new.join("q2f/workspace/libraries/probe")).unwrap(),
"ok"
);
}
#[test]
fn runtime_upgrade_keeps_per_app_signing_keys() {
let temp = tempfile::tempdir().unwrap();
let storage = temp.path();
for id in ["old", "framebridge"] {
let runtime = storage.join("q2f/runtimes").join(id);
fs::create_dir_all(runtime.join("libraries")).unwrap();
fs::write(runtime.join("overport-installed.json"), "{}").unwrap();
fs::write(runtime.join("libraries/probe"), id).unwrap();
workspace(storage, &runtime).unwrap();
if id == "old" {
fs::write(
storage.join("q2f/workspace/signatures/game.keystore"),
"original key",
)
.unwrap();
}
}
assert_eq!(
fs::read_to_string(storage.join("q2f/workspace/signatures/game.keystore")).unwrap(),
"original key"
);
assert_eq!(
fs::read_to_string(storage.join("q2f/workspace/libraries/probe")).unwrap(),
"framebridge"
);
}
}
pub(super) fn convert(
home: &Path,
ticket: &str,
cancel: &Cancellation,
progress: impl Fn(Value) -> Result<()>,
proxy: &ProxySettings,
) -> Result<Value> {
let _guard = MUTATION.lock().unwrap();
cancel.check()?;
let storage = init(home)?;
let (source, before) = review(home, ticket)?;
let source_apk = source.join("base.apk");
let original_bytes = fs::metadata(&source_apk)?.len();
// The observed OVR Port peak was roughly four source APK sizes, plus the
// original review. Leave a fixed reserve for unrelated Android writes.
space(
&storage,
original_bytes
.saturating_mul(5)
.saturating_add(1024 * 1024 * 1024),
)?;
let descriptor = requirement_path()?;
ensure!(
descriptor.is_file(),
"This Framely build has no q2f runtime requirement"
);
let requirement: Value = serde_json::from_slice(&fs::read(&descriptor)?)?;
let version = requirement["librariesVersion"]
.as_str()
.context("Invalid q2f runtime version")?;
ensure!(
version
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b)),
"Invalid q2f runtime version"
);
progress(json!({"phase":"q2f-preparing","operation":"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)?
.lines()
.last()
.context("q2f runtime path missing")?,
);
ensure!(
runtime.is_dir() && runtime.starts_with(storage.join("q2f/runtimes")),
"Invalid q2f runtime path"
);
let work = workspace(&storage, &runtime)?;
let output = source.join("converted");
fs::create_dir_all(&output)?;
progress(json!({"phase":"q2f-converting","operation":"convert","cancellable":true}))?;
let mut java = Command::new(runtime.join("java/bin/java"));
java.arg("-Xmx3g")
.arg("-jar")
.arg(runtime.join("overportcli.jar"))
.arg("patch")
.arg(format!("--input={}", source_apk.display()))
.arg(format!("--output={}", output.display()))
.arg("--output-name=base.apk")
.arg(format!("--workspace={}", work.display()))
.arg(format!("--version={version}"));
let conversion = run(java, &log, cancel);
let _ = fs::remove_dir_all(work.join("dec"));
if let Err(error) = conversion {
let _ = fs::remove_dir_all(&output);
return Err(error);
}
let converted = output.join("base.apk");
let result = (|| -> Result<Value> {
ensure!(converted.is_file(), "Quest converter produced no APK");
progress(json!({"phase":"q2f-verifying","operation":"convert"}))?;
let mut verifier = Command::new(runtime.join("java/bin/java"));
verifier
.arg("-cp")
.arg(format!(
"{}:{}",
runtime.join("overportcli.jar").display(),
runtime.display()
))
.arg("FramelyQ2FVerify")
.arg(&converted);
run(verifier, &log, cancel)?;
let after: Metadata = apk_metadata::read(&converted)?;
ensure!(
after.package == before.package && after.version_code == before.version_code,
"Quest conversion changed APK package or version"
);
verify_framebridge(&converted, &runtime, version, &after.abis)?;
cancel.check()?;
// Create a fresh review after conversion so the user has the full
// review window even when patching takes several minutes.
drop(_guard);
let inspected = super::inspect(home, &converted, cancel)?;
let reviewed = storage.join("reviews").join(
inspected["ticket"]
.as_str()
.context("Converted review has no ticket")?,
);
fs::write(
reviewed.join("q2f-version"),
requirement["id"].as_str().context("Invalid q2f identity")?,
)?;
Ok(inspected)
})();
let _ = fs::remove_dir_all(&output);
let next = result?;
Ok(
json!({"ticket":next["ticket"],"metadata":next["metadata"],"bytes":next["bytes"],
"q2fVersion":requirement["id"],"originalBytes":original_bytes}),
)
}
+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;
}
+40 -7
View File
@@ -210,7 +210,7 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
r.steam_binding = Some(Binding {
game_id: game.clone(),
native: true,
revision: revision(a),
revision: revision(a, r),
});
rpc(home, "create-shortcut", Some(&game))?;
let v: Value = serde_json::from_str(&rpc(home, "list-shortcuts", None)?)?;
@@ -230,15 +230,16 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
r.steam_app_id = Some(super::steam_ui::configure(
&game,
r.steam_app_id,
&a.metadata.name,
r.steam_name.as_deref().unwrap_or(&a.metadata.name),
icon.as_deref(),
&launch,
&native_apk(home, &a.id),
&a.metadata.package,
)?);
r.steam_binding = Some(Binding {
game_id: game,
native: true,
revision: revision(a),
revision: revision(a, r),
});
Ok(())
}
@@ -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() {
@@ -285,8 +288,38 @@ pub(super) fn unregister(home: &Path, r: &Record) -> Result<()> {
}
Ok(())
}
fn revision(a: &App) -> String {
hash(&json!({"metadata":a.metadata,"showWindow":a.show_window}).to_string())
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"
);
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,"artworkVersion":2});
if let Some(name) = &r.steam_name {
value["steamName"] = json!(name);
}
hash(&value.to_string())
}
pub(super) fn update_name(home: &Path, a: &App, r: &mut Record) -> Result<()> {
let parameters = json!({"game":game_id(&a.id),"previous":r.steam_app_id,"exe":native_apk(home,&a.id),"name":r.steam_name.as_deref().unwrap_or(&a.metadata.name)});
let result = steam_ui::evaluate(&format!(
"(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"
);
// 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;
}
Ok(())
}
fn icon_file(dir: &Path, metadata: &Metadata) -> Result<Option<PathBuf>> {
use base64::Engine;
@@ -369,7 +402,7 @@ pub(super) fn ensure_registered(home: &Path, id: &str, force: bool) -> Result<bo
&& rec
.steam_binding
.as_ref()
.is_some_and(|b| b.native && b.revision == revision(&a))
.is_some_and(|b| b.native && b.revision == revision(&a, rec))
{
save(home, &db)?;
return Ok(true);
@@ -421,7 +454,7 @@ pub(super) fn synchronize(home: &Path) -> Result<()> {
&& linked(home, r)
&& r.steam_binding
.as_ref()
.is_some_and(|b| b.native && b.revision == revision(&a))
.is_some_and(|b| b.native && b.revision == revision(&a, r))
})
{
continue;
+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());
}
}
+5
View File
@@ -2,5 +2,10 @@
//! run as the Steam session user, apart from the framebuffer export helper.
pub mod settings;
pub mod runtime;
mod recording;
pub mod region;
#[cfg(test)]
mod region_device_test;
pub mod receiver;
pub mod watch_access;
+551
View File
@@ -0,0 +1,551 @@
//! MP4 recording shares the capture encoder, with AAC audio for compatibility.
use anyhow::{ensure, Context, Result};
use serde_json::{json, Value};
use std::{
fs,
io::Write,
os::unix::{fs::OpenOptionsExt, process::CommandExt},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
pub struct Recording {
child: Child,
path: PathBuf,
started: Instant,
progress: PathBuf,
log: PathBuf,
pub id: String,
}
fn has_media(progress: &str) -> bool {
// Stream-copy progress has no `frame=` field on the headset's FFmpeg.
let positive = |name: &str| {
progress
.lines()
.rev()
.find_map(|line| line.strip_prefix(name))
.is_some_and(|value| value.trim().parse::<i64>().is_ok_and(|n| n > 0))
};
positive("out_time_us=") && positive("total_size=")
}
impl Recording {
pub fn start(
rtsp_port: u16,
directory: &Path,
output: &Path,
codec: super::settings::Codec,
) -> Result<Self> {
Self::start_command(Command::new("ffmpeg"), rtsp_port, directory, output, codec)
}
fn start_command(
mut cmd: Command,
rtsp_port: u16,
directory: &Path,
output: &Path,
codec: super::settings::Codec,
) -> Result<Self> {
fs::create_dir_all(output).context("无法创建录制目录")?;
let id = hex::encode(rand::random::<[u8; 16]>());
let path = output.join(format!(
"Framely-{}-{}.mp4",
crate::service::now_ms(),
&id[..8]
));
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)?;
let progress = directory.join(format!("recording-{id}.progress"));
let log = directory.join(format!("recording-{id}.log"));
super::runtime::bind_to_session(&mut cmd);
let spawned = cmd
.process_group(0)
.args([
"-hide_banner",
"-loglevel",
"warning",
"-y",
"-fflags",
"+genpts",
"-analyzeduration",
"1000000",
"-probesize",
"1048576",
"-rtsp_transport",
"tcp",
"-i",
&format!("rtsp://127.0.0.1:{rtsp_port}/headset"),
"-map",
"0:v:0",
"-map",
"0:a?",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"128k",
"-avoid_negative_ts",
"make_zero",
"-stats_period",
"0.2",
"-progress",
])
.arg(&progress)
// Fragmented MP4 remains usable without a final global index, and
// avoids buffering an entire recording until it is stopped.
.args([
"-flush_packets",
"1",
"-movflags",
"+frag_keyframe+empty_moov+default_base_moof",
"-f",
"mp4",
])
.args(if codec == super::settings::Codec::H265 {
vec!["-tag:v", "hvc1"]
} else {
vec![]
})
.arg(&path)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&log)?,
)
.spawn()
.context("启动录制程序失败");
let child = match spawned {
Ok(child) => child,
Err(error) => {
let _ = fs::remove_file(&path);
let _ = fs::remove_file(&log);
return Err(error);
}
};
let mut recording = Self {
child,
path,
started: Instant::now(),
progress,
log,
id,
};
let ready = (|| -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(12);
loop {
ensure!(
recording.child.try_wait()?.is_none(),
"录制程序启动失败:{}",
recording.detail()
);
if fs::read_to_string(&recording.progress)
.ok()
.is_some_and(|s| has_media(&s))
{
return Ok(());
}
ensure!(
Instant::now() < deadline,
"录制启动超时:{}",
recording.detail()
);
std::thread::sleep(Duration::from_millis(50));
}
})();
if let Err(error) = ready {
let _ = recording.finish();
return Err(error);
}
Ok(recording)
}
pub fn status(&self) -> Value {
json!({"active":true,"id":self.id,"path":self.path,"elapsedSeconds":self.started.elapsed().as_secs()})
}
pub fn exited(&mut self) -> Result<bool> {
Ok(self.child.try_wait()?.is_some())
}
pub fn detail(&self) -> String {
fs::read_to_string(&self.log)
.map(|s| {
s.chars()
.rev()
.take(2048)
.collect::<String>()
.chars()
.rev()
.collect()
})
.unwrap_or_default()
}
pub fn finish(&mut self) -> Result<PathBuf> {
if self.child.try_wait()?.is_none() {
if let Some(mut stdin) = self.child.stdin.take() {
let _ = stdin.write_all(b"q\n");
}
let deadline = Instant::now() + Duration::from_secs(8);
while self.child.try_wait()?.is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(25));
}
if self.child.try_wait()?.is_none() {
unsafe {
libc::kill(-(self.child.id() as i32), libc::SIGINT);
}
}
let deadline = Instant::now() + Duration::from_secs(2);
while self.child.try_wait()?.is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(25));
}
if self.child.try_wait()?.is_none() {
unsafe {
libc::kill(-(self.child.id() as i32), libc::SIGKILL);
}
let _ = self.child.wait();
anyhow::bail!("录制结束超时,已保留可恢复文件:{}", self.path.display());
}
}
let status = self.child.wait()?;
ensure!(
status.success(),
"录制异常结束,文件已保留:{};{}",
self.path.display(),
self.detail()
);
Ok(self.path.clone())
}
}
impl Drop for Recording {
fn drop(&mut self) {
let _ = self.finish();
let _ = fs::remove_file(&self.progress);
let _ = fs::remove_file(&self.log);
}
}
#[cfg(test)]
pub(super) fn test_recording(directory: &Path, output: &Path, fail: bool) -> Recording {
let script = directory.join("fake-ffmpeg.sh");
fs::write(
&script,
r#"previous=''
for argument in "$@"; do
if [ "$previous" = '-progress' ]; then progress="$argument"; fi
previous="$argument"
done
printf 'header\n' > "$previous"
printf 'out_time_us=1000\ntotal_size=100\n' > "$progress"
if [ "$FAIL_LATER" = 1 ]; then sleep 0.4; exit 1; fi
read command
[ "$command" = q ] || exit 2
printf 'trailer\n' >> "$previous"
"#,
)
.unwrap();
let mut command = Command::new("sh");
command
.arg(script)
.env("FAIL_LATER", if fail { "1" } else { "0" });
Recording::start_command(command, 1, directory, output, super::settings::Codec::H264).unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn cast_recording_copy_progress_does_not_require_frame_count() {
assert!(has_media(
"total_size=38485\nout_time_us=1191811\nprogress=continue\n"
));
assert!(!has_media("total_size=38485\nout_time_us=N/A\n"));
assert!(!has_media("frame=1\ntotal_size=0\nout_time_us=0\n"));
}
#[test]
fn cast_recording_finalizes_and_uses_unique_private_files() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("videos");
let mut first = test_recording(dir.path(), &output, false);
let path = first.finish().unwrap();
assert_eq!(path.extension().unwrap(), "mp4");
assert_eq!(fs::read_to_string(&path).unwrap(), "header\ntrailer\n");
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
let mut second = test_recording(dir.path(), &output, false);
assert_ne!(first.id, second.id);
assert_ne!(path, second.finish().unwrap());
}
#[test]
fn cast_recording_drop_finalizes_and_cleans_progress() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("videos");
let first = test_recording(dir.path(), &output, false);
let path = first.path.clone();
let progress = first.progress.clone();
drop(first);
assert_eq!(fs::read_to_string(path).unwrap(), "header\ntrailer\n");
assert!(!progress.exists());
}
#[test]
fn cast_recording_spawn_failure_cleans_empty_output() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("videos");
assert!(Recording::start_command(
Command::new("/framely-no-such-ffmpeg"),
1,
dir.path(),
&output,
super::super::settings::Codec::H264
)
.is_err());
assert_eq!(fs::read_dir(output).unwrap().count(), 0);
}
#[test]
#[ignore = "requires FFmpeg, ffprobe and MediaMTX; uses isolated test streams"]
fn cast_recording_real_mux_preserves_video_audio_and_finalizes() {
struct Worker(Child);
impl Drop for Worker {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let port = || {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
};
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("videos");
let rtsp = port();
let api = port();
let whep = port();
let hls = port();
let config = dir.path().join("mediamtx.yml");
fs::write(&config,format!("logLevel: warn\nrtspAddress: 127.0.0.1:{rtsp}\nrtspTransports: [tcp]\napi: yes\napiAddress: 127.0.0.1:{api}\nrtmp: no\nhls: no\nhlsAddress: 127.0.0.1:{hls}\nwebrtc: no\nwebrtcAddress: 127.0.0.1:{whep}\nwebrtcLocalUDPAddress: :0\nwebrtcLocalTCPAddress: :0\nsrt: no\nmoq: no\npaths:\n headset:\n source: publisher\n")).unwrap();
let binary = std::env::var("FRAMELY_MEDIA_MTX").unwrap();
let _server = Worker(
Command::new(binary)
.arg(config)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let end = Instant::now() + Duration::from_secs(5);
while std::net::TcpStream::connect(("127.0.0.1", rtsp)).is_err() {
assert!(Instant::now() < end);
std::thread::sleep(Duration::from_millis(50));
}
for (codec, audio) in [("h264", true), ("hevc", true), ("h264", false)] {
let mut command = Command::new("ffmpeg");
command.args([
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-re",
"-f",
"lavfi",
"-i",
"testsrc2=size=160x96:rate=15",
]);
if audio {
command.args([
"-re",
"-f",
"lavfi",
"-i",
"sine=frequency=440:sample_rate=48000",
]);
}
command.args([
"-c:v",
if codec == "hevc" {
"libx265"
} else {
"libx264"
},
"-preset",
"ultrafast",
"-tune",
"zerolatency",
"-threads",
"1",
"-g",
"15",
]);
if codec == "hevc" {
command.args([
"-x265-params",
"log-level=error:pools=1:frame-threads=1:bframes=0",
]);
}
if audio {
command.args(["-c:a", "libopus"]);
} else {
command.arg("-an");
}
let address = format!("rtsp://127.0.0.1:{rtsp}/headset");
let _publisher = Worker(
command
.args(["-f", "rtsp", "-rtsp_transport", "tcp", &address])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let end = Instant::now() + Duration::from_secs(8);
loop {
if ureq::get(&format!("http://127.0.0.1:{api}/v3/paths/get/headset"))
.timeout(Duration::from_millis(500))
.call()
.ok()
.and_then(|r| r.into_json::<Value>().ok())
.is_some_and(|v| v["ready"] == true)
{
break;
}
assert!(Instant::now() < end, "publisher not ready");
std::thread::sleep(Duration::from_millis(50));
}
let mut recording = Recording::start(
rtsp,
dir.path(),
&output,
if codec == "hevc" {
super::super::settings::Codec::H265
} else {
super::super::settings::Codec::H264
},
)
.unwrap();
// Toggling viewing services must leave the RTSP publisher/recorder
// intact. Record-only initially has no WebRTC or HLS listeners.
assert!(std::net::TcpStream::connect(("127.0.0.1", whep)).is_err());
for enabled in [true, false] {
ureq::patch(&format!("http://127.0.0.1:{api}/v3/config/global/patch"))
.timeout(Duration::from_secs(3))
.send_json(json!({"webrtc":enabled,"hls":enabled}))
.unwrap();
let end = Instant::now() + Duration::from_secs(3);
while [whep, hls].iter().any(|port| {
std::net::TcpStream::connect(("127.0.0.1", *port)).is_ok() != enabled
}) {
assert!(Instant::now() < end, "viewing listeners did not switch");
std::thread::sleep(Duration::from_millis(20));
}
let path: Value =
ureq::get(&format!("http://127.0.0.1:{api}/v3/paths/get/headset"))
.timeout(Duration::from_secs(2))
.call()
.unwrap()
.into_json()
.unwrap();
assert_eq!(path["ready"], true, "viewing toggle interrupted capture");
assert!(!recording.exited().unwrap());
}
std::thread::sleep(Duration::from_millis(800));
let path = recording.finish().unwrap();
assert_eq!(path.extension().unwrap(), "mp4");
let probe = Command::new("ffprobe")
.args([
"-v",
"error",
"-show_streams",
"-show_format",
"-of",
"json",
])
.arg(&path)
.output()
.unwrap();
assert!(
probe.status.success(),
"{}",
String::from_utf8_lossy(&probe.stderr)
);
let data: Value = serde_json::from_slice(&probe.stdout).unwrap();
let streams = data["streams"].as_array().unwrap();
assert!(
data["format"]["format_name"]
.as_str()
.unwrap()
.contains("mp4"),
"{data}"
);
if codec == "hevc" {
assert!(
streams
.iter()
.any(|s| s["codec_name"] == "hevc" && s["codec_tag_string"] == "hvc1"),
"{data}"
);
}
assert!(
streams
.iter()
.any(|s| s["codec_name"] == codec && s["width"] == 160 && s["height"] == 96),
"{data}"
);
assert_eq!(
streams.iter().any(|s| s["codec_name"] == "aac"),
audio,
"{data}"
);
assert!(
data["format"]["duration"]
.as_str()
.unwrap()
.parse::<f64>()
.unwrap()
> 0.5,
"{data}"
);
let decoded = Command::new("ffmpeg")
.args(["-v", "error", "-xerror", "-i"])
.arg(&path)
.args(["-f", "null", "-"])
.output()
.unwrap();
assert!(
decoded.status.success(),
"saved recording is not decodable: {}",
String::from_utf8_lossy(&decoded.stderr)
);
if audio {
let pcm = Command::new("ffmpeg")
.args(["-v", "error", "-i"])
.arg(&path)
.args(["-map", "0:a:0", "-t", "0.2", "-f", "s16le", "pipe:1"])
.output()
.unwrap();
assert!(pcm.status.success());
assert!(
pcm.stdout
.chunks_exact(2)
.any(|s| i16::from_le_bytes([s[0], s[1]]).unsigned_abs() > 1000),
"recorded audio is silent"
);
}
println!(
"PASS: {codec}, audio={audio}, finalized {} bytes",
fs::metadata(path).unwrap().len()
);
drop(recording);
drop(_publisher);
std::thread::sleep(Duration::from_millis(250));
}
}
}
+271
View File
@@ -0,0 +1,271 @@
//! Projection metadata comes from the exact LUT used by panel capture.
use super::settings::{Eye, Output, Settings, Source};
use anyhow::{ensure, Context, Result};
use serde_json::{json, Value};
use std::{
io::Read,
path::Path,
process::{Command, Stdio},
time::{Duration, Instant},
};
#[derive(Default)]
pub struct Region {
pub editing: bool,
pub editor_id: String,
enabled: bool,
settings: Option<Settings>,
description: Option<Value>,
pub error: Option<String>,
}
pub fn supported(settings: &Settings) -> bool {
settings.source == Source::Screen && settings.output == Output::Eye
}
pub fn validate_description(value: &Value) -> Result<()> {
ensure!(value["projection"]=="factory-panel-v1" && matches!(value["eye"].as_u64(),Some(0|1)), "缺少有效的面板校准数据,无法显示准确录制区域");
let tan = value["tanHalfHorizontal"]
.as_f64()
.context("Missing capture FOV")?;
let aspect = value["aspect"].as_f64().context("Missing capture aspect")?;
ensure!(
tan.is_finite() && tan > 0. && aspect.is_finite() && aspect > 0.,
"Invalid capture view"
);
for name in ["centerTanX", "centerTanY"] {
ensure!(
value[name].as_f64().is_some_and(f64::is_finite),
"Invalid capture center"
);
}
let matrix: [[f64; 4]; 3] = serde_json::from_value(value["captureToHead"].clone())?;
ensure!(
matrix.iter().flatten().all(|v| v.is_finite()),
"Invalid capture transform"
);
for i in 0..3 {
for j in 0..3 {
let dot: f64 = (0..3).map(|k| matrix[i][k] * matrix[j][k]).sum();
ensure!(
(dot - if i == j { 1. } else { 0. }).abs() < 0.01,
"Invalid capture basis"
);
}
}
Ok(())
}
impl Region {
/// Last editor identity remains available after closing so the native host
/// can reconcile it on every poll, even if a one-shot close command is lost.
pub fn editor_state(&self) -> Value {
json!({"id":self.editor_id,"open":self.editing})
}
pub fn acknowledge_editor_closed(&mut self, id: &str) {
// A delayed acknowledgement for the previous window must not close a
// newer editor that has already been opened.
if self.editor_id == id {
self.editing = false;
}
}
pub fn require_visible(view: &Value) -> Result<()> {
ensure!(view["regionFullyVisible"] == true, "取景范围超出头显可见区域,请缩小或调整位置");
Ok(())
}
pub fn invalidate(&mut self) {
self.description = None;
self.settings = None;
}
pub fn refresh(
&mut self,
settings: &Settings,
binaries: &Path,
active: Option<Value>,
) -> Result<()> {
self.enabled = settings.recording_region_visible;
if !supported(settings) || (!self.enabled && !self.editing) || (active.is_none() && !self.editing) {
self.description = None;
self.settings = None;
self.error = None;
return Ok(());
}
if let Some(value) = active {
// Never substitute configured geometry for a live recording.
validate_description(&value)?;
if !self.editing { Self::require_visible(&value)?; }
self.description = Some(value);
self.settings = Some(settings.clone());
self.error = None;
return Ok(());
}
let eye = match settings.eye {
Eye::Left => 0,
Eye::Right => 1,
Eye::SteamVR => super::runtime::dominant_eye()?,
};
if self
.settings
.as_ref()
.is_some_and(|s| s.same_capture(settings))
&& self.description.as_ref().is_some_and(|d| d["eye"] == eye)
{
if !self.editing { Self::require_visible(self.description.as_ref().unwrap())?; }
return Ok(());
}
self.description = None;
self.settings = None;
let mut command = Command::new(binaries.join("framely-capture"));
command.args([
"--describe-view",
"--width",
&settings.width.to_string(),
"--height",
&settings.height.to_string(),
"--eye",
&eye.to_string(),
]);
command.args([
format!("--center-x={}", settings.center_x),
format!("--center-y={}", settings.center_y),
]);
if let Some(fov) = settings.horizontal_fov {
command.arg(format!("--fov={fov}"));
}
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("无法读取实际录制区域,请更新采集程序")?;
// Drain metadata while the worker runs: the visibility grid can be
// larger than a small pipe's capacity. Waiting first would deadlock.
let stdout=child.stdout.take().context("Missing geometry output")?;
let output=std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
let mut bytes=Vec::new();stdout.take(65537).read_to_end(&mut bytes)?;Ok(bytes)
});
let deadline = Instant::now() + Duration::from_secs(12);
let status = loop {
match child.try_wait() {
Ok(Some(status))=>break status,
Ok(None)=>{},
Err(error)=>{let _=child.kill();let _=child.wait();let _=output.join();return Err(error.into());}
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
let _ = output.join();
anyhow::bail!("读取录制区域超时");
}
std::thread::sleep(Duration::from_millis(20));
};
let bytes=output.join().map_err(|_|anyhow::anyhow!("Invalid geometry output reader"))??;
ensure!(
status.success(),
"无法读取实际录制区域,请确认 SteamVR 已启动"
);
ensure!(bytes.len() <= 65536, "Invalid capture geometry output");
let value: Value = serde_json::from_slice(&bytes)?;
validate_description(&value)?;
if !self.editing { Self::require_visible(&value)?; }
self.description = Some(value);
self.settings = Some(settings.clone());
self.error = None;
Ok(())
}
pub fn fail(&mut self, error: &anyhow::Error) {
self.description = None;
self.error = Some(format!("{error:#}"));
}
pub fn snapshot(&self, active:bool) -> Value {
json!({"visible":active && self.enabled && !self.editing && self.description.is_some(),"editing":self.editing,"view":self.description,"settings":self.settings,"error":self.error})
}
pub fn editor(&self) -> Result<Value> {
let view = self
.description
.as_ref()
.context("无法读取实际录制区域,请确认 SteamVR 已启动")?;
let reference=view["editorReferenceTan"].as_f64().filter(|v|v.is_finite()&&*v>0.).context("无法读取实际录制区域,请更新采集程序")?;
let visibility=&view["visibility"];
let n=visibility["size"].as_u64().filter(|n|(8..=256).contains(n)&&n%4==0).context("无法读取实际录制区域,请更新采集程序")?;
ensure!(visibility["extent"].as_f64()==Some(reference) && visibility["cells"].as_str().is_some_and(|s|s.len()==(n*n/4) as usize&&s.bytes().all(|c|c.is_ascii_hexdigit())),"无法读取实际录制区域,请更新采集程序");
Ok(
json!({"id":self.editor_id,"settings":self.settings,"view":view,"referenceTan":reference}),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recording_region_editor_close_state_survives_poll_and_rejects_stale_ack() {
let mut region=Region::default();
region.editor_id="first".into();region.editing=true;
assert_eq!(region.editor_state(),json!({"id":"first","open":true}));
region.acknowledge_editor_closed("first");
assert_eq!(region.editor_state(),json!({"id":"first","open":false}));
// Reading a state response must not consume the required close state.
assert_eq!(region.editor_state(),json!({"id":"first","open":false}));
region.editor_id="second".into();region.editing=true;
region.acknowledge_editor_closed("first");
assert_eq!(region.editor_state(),json!({"id":"second","open":true}));
region.acknowledge_editor_closed("second");
assert!(!region.editing);
}
#[test]
fn recording_region_drains_large_metadata_before_waiting() {
use std::os::unix::fs::PermissionsExt;
let directory=tempfile::tempdir().unwrap();
let view=json!({"projection":"factory-panel-v1","eye":0,"regionFullyVisible":true,"editorReferenceTan":1.7,"visibility":{"size":8,"extent":1.7,"cells":"f".repeat(16)},"tanHalfHorizontal":0.5,"aspect":4./3.,"centerTanX":0.,"centerTanY":0.,"captureToHead":[[1,0,0,0],[0,1,0,0],[0,0,1,0]],"padding":"x".repeat(48000)});
let worker=directory.path().join("framely-capture");
std::fs::write(&worker,format!("#!/bin/sh\nprintf '%s' '{}'\n",view)).unwrap();
std::fs::set_permissions(&worker,std::fs::Permissions::from_mode(0o700)).unwrap();
let mut region=Region::default();region.editing=true;
let started=Instant::now();
region.refresh(&Settings {eye:Eye::Left,..Settings::default()},directory.path(),None).unwrap();
assert!(started.elapsed()<Duration::from_secs(3));
assert_eq!(region.editor().unwrap()["referenceTan"],1.7);
}
#[test]
fn recording_region_visibility_persists_independently_of_capture() {
let root=tempfile::tempdir().unwrap();
let mut core=crate::tests::accepted_service(root.path(),1000).unwrap();
let before=core.handle("status",json!({})).unwrap()["database"]["casting"].clone();
core.handle("cast.region.visible.save",json!({"enabled":true})).unwrap();
let mut enabled=core.handle("status",json!({})).unwrap()["database"]["casting"].clone();
assert_eq!(enabled["recordingRegionVisible"],true);enabled["recordingRegionVisible"]=json!(false);assert_eq!(enabled,before);
drop(core);
let mut restored=crate::service::Service::load(root.path(),1000).unwrap();
assert_eq!(restored.handle("status",json!({})).unwrap()["database"]["casting"]["recordingRegionVisible"],true);
assert!(!serde_json::from_value::<Settings>(json!({})).unwrap().recording_region_visible);
}
#[test]
fn recording_region_rejects_missing_invalid_or_stale_geometry() {
let view = json!({"projection":"factory-panel-v1","eye":0,"regionFullyVisible":true,"editorReferenceTan":1.7,"visibility":{"size":8,"extent":1.7,"cells":"f".repeat(16)},"tanHalfHorizontal":1.,"aspect":4./3.,"centerTanX":0.1,"centerTanY":-0.1,"captureToHead":[[1,0,0,-0.032],[0,1,0,0],[0,0,1,0]]});
assert!(Region::require_visible(&json!({"regionFullyVisible":false})).is_err());
assert!(Region::require_visible(&json!({})).is_err());
validate_description(&view).unwrap();
let mut region = Region::default();
let mut settings = Settings::default();
settings.recording_region_visible = true;
region
.refresh(&settings, Path::new("/missing"), Some(view))
.unwrap();
assert_eq!(region.snapshot(true)["visible"], true);
assert_eq!(region.editor().unwrap()["referenceTan"],1.7);
let mut smaller=region.description.clone().unwrap();smaller["tanHalfHorizontal"]=json!(0.1);
region.refresh(&settings,Path::new("/missing"),Some(smaller)).unwrap();
assert_eq!(region.editor().unwrap()["referenceTan"],1.7);
assert_eq!(region.snapshot(false)["visible"], false);
region.refresh(&settings,Path::new("/missing"),None).unwrap();
assert_eq!(region.snapshot(true)["visible"], false);
assert!(region
.refresh(&settings, Path::new("/missing"), Some(json!({})))
.is_err());
region.fail(&anyhow::anyhow!("invalid"));
assert_eq!(region.snapshot(true)["visible"], false);
settings.output = Output::Raw;
region
.refresh(&settings, Path::new("/missing"), None)
.unwrap();
assert_eq!(region.snapshot(true)["visible"], false);
}
}
+168
View File
@@ -0,0 +1,168 @@
use super::{
runtime::Casting,
settings::{Eye, Settings},
};
use serde_json::Value;
use std::{
fs,
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant},
};
#[test]
#[ignore = "displays calibration markers on Steam Frame; requires explicit user approval"]
fn recording_region_device_alignment() {
let media = PathBuf::from(std::env::var("FRAMELY_REGION_TEST_MEDIA").unwrap());
let marker = std::env::var("FRAMELY_REGION_TEST_MARKER").unwrap();
let directory = tempfile::tempdir().unwrap();
for (eye, width, height, fov, cx, cy) in [
(Eye::Left, 640, 480, None, 0., 0.),
(Eye::Right, 640, 360, Some(55.), 8., -5.),
(Eye::Left, 360, 640, Some(45.), -6., 4.),
(Eye::Right, 1920, 1440, Some(70.), 0., 0.),
(Eye::Right, 2560, 1600, Some(76.47219658546469), 0., -1.504517427660815),
(Eye::Left, 2560, 1600, Some(76.47219658546469), 0., -1.504517427660815),
] {
let mut command = Command::new(media.join("framely-capture"));
command.args([
"--describe-view",
"--eye",
if eye == Eye::Left { "0" } else { "1" },
"--width",
&width.to_string(),
"--height",
&height.to_string(),
]);
command.args([format!("--center-x={cx}"), format!("--center-y={cy}")]);
if let Some(fov) = fov {
command.arg(format!("--fov={fov}"));
}
let output = command.output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let mut view: Value = serde_json::from_slice(&output.stdout).unwrap();
super::region::validate_description(&view).unwrap();
view["width"] = width.into();
view["height"] = height.into();
let geometry = directory.path().join("geometry.json");
let ready = directory.path().join("ready");
let stop = directory.path().join("stop");
let _ = fs::remove_file(&ready);
let _ = fs::remove_file(&stop);
fs::write(&geometry, view.to_string()).unwrap();
let mut overlay = Command::new(&marker)
.arg(&geometry)
.arg(&ready)
.arg(&stop)
.stdout(Stdio::null())
.spawn()
.unwrap();
let end = Instant::now() + Duration::from_secs(5);
while !ready.exists() {
assert!(overlay.try_wait().unwrap().is_none());
assert!(Instant::now() < end);
std::thread::sleep(Duration::from_millis(20));
}
let helper = crate::ipc::call(
Path::new("/run/framely/control.sock"),
"cast.panel.start",
serde_json::json!({}),
)
.unwrap();
let socket = PathBuf::from(helper["socket"].as_str().unwrap());
let mut cast = Casting::new(directory.path().join("capture"), media.clone());
let settings = Settings {
eye,
width,
height,
horizontal_fov: fov,
center_x: cx,
center_y: cy,
system_audio: false,
..Settings::default()
};
let result = (|| -> anyhow::Result<Vec<u8>> {
let status=cast.start(&settings, Some(&socket))?;
super::region::validate_description(&status["view"])?;
for key in ["tanHalfHorizontal","centerTanX","centerTanY","aspect"] {
anyhow::ensure!((status["view"][key].as_f64().unwrap()-view[key].as_f64().unwrap()).abs()<1e-10,"described and active capture differ: {key}");
}
let frame = Command::new("ffmpeg")
.args([
"-v",
"error",
"-rtsp_transport",
"tcp",
"-i",
&format!("rtsp://127.0.0.1:{}/headset", cast.test_rtsp_port()),
"-frames:v",
"1",
"-pix_fmt",
"rgb24",
"-f",
"rawvideo",
"pipe:1",
])
.output()?;
anyhow::ensure!(
frame.status.success(),
"{}",
String::from_utf8_lossy(&frame.stderr)
);
Ok(frame.stdout)
})();
cast.stop();
fs::write(&stop, b"stop").unwrap();
let _ = overlay.wait();
let rgb = result.unwrap();
assert_eq!(rgb.len(), width as usize * height as usize * 3);
let positions = [
(16, 16),
(width - 16, 16),
(16, height - 16),
(width - 16, height - 16),
(width / 2, height / 2),
];
for (index, (x, y)) in positions.into_iter().enumerate() {
let mut n = 0.;
let mut sx = 0.;
let mut sy = 0.;
for dy in -24..24 {
for dx in -24..24 {
let px = x as i32 + dx;
let py = y as i32 + dy;
if px < 0 || py < 0 || px >= width as i32 || py >= height as i32 { continue; }
let i = (py as usize * width as usize + px as usize) * 3;
let (r, g, b) = (rgb[i], rgb[i + 1], rgb[i + 2]);
let hit = match index {
0 => r > 160 && g < 100 && b < 100,
1 => g > 160 && r < 100 && b < 100,
2 => b > 160 && r < 100 && g < 100,
3 => r > 160 && b > 160 && g < 100,
_ => r > 200 && g > 200 && b > 200,
};
if hit {
n += 1.;
sx += px as f64;
sy += py as f64;
}
}
}
assert!(n > 400., "fiducial {index} missing for {eye:?}: {n} pixels");
let error = (sx / n - (x as f64 - 0.5))
.abs()
.max((sy / n - (y as f64 - 0.5)).abs());
println!(
"ALIGNMENT {eye:?} {width}x{height} marker={index} error={error:.2}px pixels={n}"
);
assert!(
error <= 3.,
"recording border does not match sampled image: {error:.2}px"
);
}
}
}
+300 -24
View File
@@ -15,6 +15,7 @@ pub struct Casting {
pub binaries: PathBuf,
workers: Vec<Child>,
started: Option<Instant>,
streaming: bool,
pub whep_port: u16,
rtsp_port: u16,
api_port: u16,
@@ -26,6 +27,10 @@ pub struct Casting {
pub stream_key: String,
error: Option<String>,
preview_restore: Option<Option<Settings>>,
recording: Option<super::recording::Recording>,
recording_error: Option<String>,
recording_path: Option<PathBuf>,
pub region: super::region::Region,
}
fn port() -> Result<u16> {
Ok(std::net::TcpListener::bind("127.0.0.1:0")?
@@ -39,6 +44,7 @@ impl Casting {
binaries,
workers: Vec::new(),
started: None,
streaming: false,
whep_port: 0,
rtsp_port: 0,
api_port: 0,
@@ -50,9 +56,35 @@ impl Casting {
stream_key: String::new(),
error: None,
preview_restore: None,
recording: None,
recording_error: None,
recording_path: None,
region: Default::default(),
}
}
pub fn status(&mut self) -> Value {
self.poll_recording();
self.poll_workers();
let view = fs::File::open(self.directory.join("stream.log")).ok().and_then(|file| { let mut log = String::new(); file.take(65536).read_to_string(&mut log).ok()?; Some(log) }).and_then(|log| log.lines().filter_map(|line|serde_json::from_str::<Value>(line).ok()).find(|v|v["event"]=="view"));
json!({"running":self.streaming && self.started.is_some(),"error":self.error,"preview":self.preview_restore.is_some(),"view":view,
"watchPath":if self.streaming && self.started.is_some(){Some("/cast/watch")}else{None},
"elapsedSeconds":if self.streaming {self.started.map(|t|t.elapsed().as_secs())} else {None},
"codec":self.active_settings.as_ref().map(|s|s.codec.name()),"dominantEye":dominant_eye().ok(),"recording":self.recording_status()})
}
pub fn refresh_region(&mut self, configured: &Settings) -> Result<()> {
let mut effective=self.active_settings.clone().unwrap_or_else(||configured.clone());
effective.recording_region_visible=configured.recording_region_visible;
let active=if self.started.is_some() && super::region::supported(&effective) && (effective.recording_region_visible || self.region.editing) {
Some(self.status()["view"].clone())
} else {None};
let result=self.region.refresh(&effective,&self.binaries,active);
if let Err(error)=&result {self.region.fail(error);}
result
}
pub fn region_status(&self) -> Value {
self.region.snapshot((self.streaming && self.started.is_some()) || self.recording.is_some())
}
fn poll_workers(&mut self) {
let exited = self
.workers
.iter_mut()
@@ -61,13 +93,65 @@ impl Casting {
self.error = Some(format!("串流进程已退出:{code}"));
self.stop_workers();
}
let view = fs::File::open(self.directory.join("stream.log")).ok().and_then(|file| { let mut log = String::new(); file.take(65536).read_to_string(&mut log).ok()?; Some(log) }).and_then(|log| log.lines().filter_map(|line|serde_json::from_str::<Value>(line).ok()).find(|v|v["event"]=="view"));
json!({"running":self.started.is_some(),"error":self.error,"preview":self.preview_restore.is_some(),"view":view,
"watchPath":if self.started.is_some(){Some("/cast/watch")}else{None},
"elapsedSeconds":self.started.map(|t|t.elapsed().as_secs()),
"codec":self.active_settings.as_ref().map(|s|s.codec.name()),"dominantEye":dominant_eye().ok()})
}
pub fn recording_status(&self) -> Value {
let mut value = self.recording.as_ref().map(|r|r.status()).unwrap_or(json!({"active":false,"path":self.recording_path}));
value["error"] = json!(self.recording_error);
value
}
fn poll_recording(&mut self) {
if self.recording.as_mut().is_some_and(|r|r.exited().unwrap_or(true)) {
self.recording_error = Some(format!("录制进程意外退出:{}",self.recording.as_ref().unwrap().detail()));
let _ = self.stop_recording();
}
}
pub fn validate_recording_settings(&self, settings: &Settings) -> Result<()> {
ensure!(self.recording.is_none() || self.active_settings.as_ref().is_some_and(|s|s.same_capture(settings)), "请先停止录制再调整取景");
Ok(())
}
pub fn record(&mut self, settings: &Settings, panel_socket: Option<&Path>, output: &Path) -> Result<Value> {
ensure!(!self.region.editing, "请先关闭画幅调整窗口");
self.status();
ensure!(self.preview_restore.is_none(), "取景预览期间不能录制");
ensure!(self.recording.is_none(), "录制已在运行");
let capture_started = self.started.is_none();
if capture_started {
self.start_capture(settings, panel_socket, false)?;
}
let result = self.start_recording(output);
if result.is_err() && capture_started { self.stop_workers(); }
result
}
fn start_recording(&mut self, output: &Path) -> Result<Value> {
self.status();
ensure!(self.started.is_some(), "请先开始串流");
ensure!(self.preview_restore.is_none(), "取景预览期间不能录制");
ensure!(self.recording.is_none(), "录制已在运行");
self.recording_error = None;
match super::recording::Recording::start(self.rtsp_port, &self.directory, output, self.codec()) {
Ok(recording) => {self.recording = Some(recording); Ok(self.recording_status())},
Err(error) => {self.recording_error = Some(format!("{error:#}")); Err(error)}
}
}
pub fn stop_recording(&mut self) -> Result<Value> {
let result = self.finish_recording();
if !self.streaming {self.stop_workers();}
result
}
fn finish_recording(&mut self) -> Result<Value> {
if let Some(mut recording) = self.recording.take() {
match recording.finish() {
Ok(path) => self.recording_path = Some(path),
Err(error) => {self.recording_error = Some(format!("{error:#}")); return Err(error);}
}
}
Ok(self.recording_status())
}
pub fn stop_recording_id(&mut self, id: &str) {
if self.recording.as_ref().is_some_and(|r|r.id == id) {let _ = self.stop_recording();}
}
fn stop_workers(&mut self) {
let _ = self.finish_recording();
for child in &mut self.workers {
if child.try_wait().ok().flatten().is_some() {
continue;
@@ -93,13 +177,22 @@ impl Casting {
self.dlna_started = false;
self.stop_vp8();
self.active_settings = None;
self.region.invalidate();
self.started = None;
self.streaming = false;
self.stream_key.clear();
}
/// Negotiate with browsers that omit H.264 (including the bundled CEF).
/// All VP8 viewers share one on-demand compatibility encoder.
#[cfg(test)]
pub(crate) fn test_rtsp_port(&self) -> u16 {self.rtsp_port}
#[cfg(test)]
pub(crate) fn test_whep_server(&mut self, port: u16) {
self.started = Some(Instant::now());
self.streaming = true;
self.active_settings = Some(Settings::default());
self.whep_port = port;
}
pub fn codec(&self) -> Codec { self.active_settings.as_ref().map(|s|s.codec).unwrap_or_default() }
pub fn ensure_vp8(&mut self) -> Result<()> {
ensure!(self.status()["running"] == true, "请先开始头显串流");
@@ -189,6 +282,8 @@ impl Casting {
}
}
pub fn maintain(&mut self) {
self.poll_recording();
self.poll_workers();
if self.compatibility_poll.elapsed() < Duration::from_secs(2) {
return;
}
@@ -277,7 +372,26 @@ impl Casting {
self.error = None;
json!(true)
}
fn configure_streaming(&mut self, enabled: bool) -> Result<()> {
ureq::AgentBuilder::new().redirects(0).timeout(Duration::from_secs(3)).build()
.patch(&format!("http://127.0.0.1:{}/v3/config/global/patch", self.api_port))
.send_json(json!({"webrtc":enabled,"hls":enabled}))
.context("无法切换串流服务")?;
self.streaming = enabled;
if !enabled {self.stream_key.clear();self.stop_vp8();}
else {self.stream_key = crate::session::cast_random_key();}
Ok(())
}
pub fn stop_streaming(&mut self) -> Result<Value> {
self.status();
if self.recording.is_some() {
if self.streaming {self.configure_streaming(false)?;}
} else {self.stop();}
Ok(json!(true))
}
pub fn start_preview(&mut self, settings: &Settings, socket: &Path) -> Result<Value> {
ensure!(!self.region.editing, "请先关闭画幅调整窗口");
ensure!(self.recording.is_none(), "请先停止录制再调整取景");
ensure!(self.preview_restore.is_none(), "取景预览已在运行");
let restore = self.active_settings.clone();
self.stop_workers();
@@ -318,12 +432,27 @@ impl Casting {
Ok(())
}
pub fn start(&mut self, settings: &Settings, panel_socket: Option<&Path>) -> Result<Value> {
ensure!(!self.region.editing, "请先关闭画幅调整窗口");
self.status();
ensure!(!self.streaming, "串流已在运行");
if self.started.is_some() {
// Recording already owns the encoder. Enabling viewing must not
// restart it or change the recording's resolution/bitrate.
ensure!(self.active_settings.as_ref().is_some_and(|s|s.same_capture(settings)), "请先停止录制再调整取景");
self.configure_streaming(true)?;
return Ok(self.status());
}
self.start_capture(settings, panel_socket, true)
}
fn start_capture(&mut self, settings: &Settings, panel_socket: Option<&Path>, streaming: bool) -> Result<Value> {
self.region.invalidate();
settings.validate()?;
ensure!(self.started.is_none(), "串流已在运行");
fs::create_dir_all(&self.directory)?;
fs::set_permissions(&self.directory, fs::Permissions::from_mode(0o700))?;
// A new session does not accumulate unbounded log history.
fs::write(self.directory.join("stream.log"), [])?;
self.streaming = streaming;
let result = self.start_inner(settings, panel_socket);
if let Err(e) = &result {
use std::io::{Read, Seek, SeekFrom};
@@ -353,8 +482,8 @@ impl Casting {
self.api_port = port()?;
self.whep_port = port()?;
self.hls_port = port()?;
self.stream_key = crate::session::cast_random_key();
let configuration = format!("logLevel: warn\nrtspAddress: 127.0.0.1:{rtsp}\nrtspTransports: [tcp]\nrtmp: no\nmoq: no\nsrt: no\napi: yes\napiAddress: 127.0.0.1:{}\nmetrics: no\nplayback: no\nwebrtc: yes\nwebrtcAddress: 127.0.0.1:{}\nwebrtcLocalUDPAddress: :0\nwebrtcLocalTCPAddress: :0\nhls: yes\nhlsAddress: 127.0.0.1:{}\nhlsVariant: mpegts\nhlsSegmentCount: 3\nhlsSegmentDuration: 1s\npaths:\n headset:\n source: publisher\n dlna:\n source: publisher\n headset_vp8:\n source: publisher\n", self.api_port, self.whep_port, self.hls_port);
self.stream_key = if self.streaming {crate::session::cast_random_key()} else {String::new()};
let configuration = format!("logLevel: warn\nrtspAddress: 127.0.0.1:{rtsp}\nrtspTransports: [tcp]\nrtmp: no\nmoq: no\nsrt: no\napi: yes\napiAddress: 127.0.0.1:{}\nmetrics: no\nplayback: no\nwebrtc: {}\nwebrtcAddress: 127.0.0.1:{}\nwebrtcLocalUDPAddress: :0\nwebrtcLocalTCPAddress: :0\nhls: {}\nhlsAddress: 127.0.0.1:{}\nhlsVariant: mpegts\nhlsSegmentCount: 3\nhlsSegmentDuration: 1s\npaths:\n headset:\n source: publisher\n dlna:\n source: publisher\n headset_vp8:\n source: publisher\n", self.api_port, self.streaming, self.whep_port, self.streaming, self.hls_port);
let config_path = self.directory.join("mediamtx.yml");
fs::write(&config_path, configuration)?;
self.spawn(Command::new(mediamtx).arg(config_path))?;
@@ -409,11 +538,11 @@ impl Casting {
if let Some(fov) = s.horizontal_fov {
command.args(["--fov", &fov.to_string()]);
}
// Bind signed values to their options: older capture workers'
// clap parser treats a separate negative number as a new flag.
command.args([
"--center-x",
&s.center_x.to_string(),
"--center-y",
&s.center_y.to_string(),
format!("--center-x={}", s.center_x),
format!("--center-y={}", s.center_y),
]);
}
bind_to_session(&mut command);
@@ -452,6 +581,11 @@ impl Casting {
"0",
"-framerate",
&s.fps.to_string(),
// Timestamp raw packets before FFmpeg schedules the inputs.
// Use the capture frame count rather than arrival wallclock:
// pipe reads can batch frames and distort scheduler pacing.
"-bsf:v",
&video_packet_timestamps(s.fps),
"-f",
if s.codec == Codec::H265 {"hevc"} else {"h264"},
"-i",
@@ -460,7 +594,6 @@ impl Casting {
} else {
ffmpeg.args(["-thread_queue_size", "4", "-f", "v4l2", "-i", "/dev/video99"]);
}
let audio = s.system_audio || s.microphone;
if s.system_audio {
add_pulse_input(&mut ffmpeg, "@DEFAULT_MONITOR@");
}
@@ -468,22 +601,15 @@ impl Casting {
add_pulse_input(&mut ffmpeg, "default");
}
ffmpeg.args(["-map", "0:v:0"]);
if s.system_audio && s.microphone {
ffmpeg.args([
"-filter_complex",
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0[a]",
"-map",
"[a]",
]);
} else if audio {
ffmpeg.args(["-map", "1:a:0"]);
}
let audio = map_audio(&mut ffmpeg, s.system_audio, s.microphone);
if s.source == Source::Screen {
ffmpeg.args(["-c:v", "copy"]);
if s.codec==Codec::H265 {
if s.codec == Codec::H265 {
// Iris HEVC headers omit reliable frame timing. Write the requested
// timing without decoding/re-encoding, avoiding false 50 fps playback.
ffmpeg.args(["-bsf:v",&format!("hevc_metadata=tick_rate={}:num_ticks_poc_diff_one=1",s.fps)]);
ffmpeg.args(["-bsf:v", &format!(
"hevc_metadata=tick_rate={}:num_ticks_poc_diff_one=1", s.fps
)]);
}
} else {
ffmpeg.args(["-vf", &format!("fps={},scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2,format=yuv420p",s.fps,s.width,s.height,s.width,s.height),
@@ -598,6 +724,29 @@ fn add_pulse_input(command: &mut Command, device: &str) {
command.args(["-thread_queue_size", "4", "-f", "pulse", "-sample_rate", "48000", "-channels", "2", "-fragment_size", "3840", "-i", device]);
}
// Pulse capture timestamps use the audio server's clock, while raw panel video
// starts at zero. Generate audio PTS from the sample count in that same origin.
// libopus low-delay mode subtracts its 120-sample pre-roll from packet PTS, so
// offset the input by 120 samples and keep the first RTSP packet non-negative.
fn map_audio(command: &mut Command, system_audio: bool, microphone: bool) -> bool {
let audio = system_audio || microphone;
if system_audio && microphone {
command.args([
"-filter_complex",
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0,asetpts=(N+120)/SR/TB[a]",
"-map",
"[a]",
]);
} else if audio {
command.args(["-map", "1:a:0", "-af", "asetpts=(N+120)/SR/TB"]);
}
audio
}
fn video_packet_timestamps(fps: u32) -> String {
format!("setts=time_base=1/{fps}:pts=N:dts=N:duration=1")
}
pub fn bind_to_session(command: &mut Command) {
// Linux PDEATHSIG follows the spawning thread, not the session process.
// HTTP request threads end immediately after returning a response. Worker
@@ -609,6 +758,133 @@ pub fn bind_to_session(command: &mut Command) {
mod tests {
use super::*;
#[test]
fn audio_mapping_generates_monotonic_timestamps() {
let arguments = |system_audio, microphone| {
let mut command = Command::new("ffmpeg");
let enabled = map_audio(&mut command, system_audio, microphone);
let arguments = command
.get_args()
.map(|argument| argument.to_string_lossy().into_owned())
.collect::<Vec<_>>();
(enabled, arguments)
};
assert_eq!(arguments(false, false), (false, vec![]));
for inputs in [(true, false), (false, true)] {
assert_eq!(
arguments(inputs.0, inputs.1),
(
true,
vec!["-map", "1:a:0", "-af", "asetpts=(N+120)/SR/TB"]
.into_iter()
.map(str::to_owned)
.collect()
)
);
}
assert_eq!(
arguments(true, true),
(
true,
vec![
"-filter_complex",
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0,asetpts=(N+120)/SR/TB[a]",
"-map",
"[a]",
]
.into_iter()
.map(str::to_owned)
.collect()
)
);
}
#[test]
fn cast_recording_only_stays_private_and_releases_capture_on_stop() {
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
let mut cast=Casting::new(dir.path().into(),dir.path().into());
cast.started=Some(Instant::now());cast.active_settings=Some(Settings::default());
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
let status=cast.status();
assert_eq!(status["running"],false);assert_eq!(status["recording"]["active"],true);
assert!(status["watchPath"].is_null());assert!(cast.stream_key.is_empty());
cast.stop_streaming().unwrap();assert!(cast.recording.is_some());
let id=cast.recording.as_ref().unwrap().id.clone();cast.stop_recording_id(&id);
assert!(cast.started.is_none());assert!(cast.active_settings.is_none());
assert_eq!(cast.recording_path.as_ref().unwrap().extension().unwrap(),"mp4");
}
#[test]
fn cast_recording_stream_toggle_keeps_encoder_and_recording() {
use tiny_http::{Response,Server};
let server=Server::http("127.0.0.1:0").unwrap();
let port=server.server_addr().to_ip().unwrap().port();
let worker=std::thread::spawn(move || {
for enabled in [true,false,true] {
let mut req=server.recv().unwrap();assert_eq!(req.method(),&tiny_http::Method::Patch);
assert_eq!(req.url(),"/v3/config/global/patch");
let value:Value=serde_json::from_reader(req.as_reader()).unwrap();
assert_eq!(value,json!({"webrtc":enabled,"hls":enabled}));
req.respond(Response::from_string("{}")).unwrap();
}
});
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
let mut cast=Casting::new(dir.path().into(),dir.path().into());
let settings=Settings::default();cast.api_port=port;
cast.started=Some(Instant::now());cast.active_settings=Some(settings.clone());
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
let started=cast.started;let id=cast.recording.as_ref().unwrap().id.clone();
assert_eq!(cast.start(&settings,None).unwrap()["running"],true);
cast.stop_streaming().unwrap();assert_eq!(cast.status()["running"],false);
assert_eq!(cast.recording.as_ref().unwrap().id,id);assert_eq!(cast.started,started);
assert_eq!(cast.start(&settings,None).unwrap()["running"],true);
cast.stop_recording().unwrap();assert!(cast.streaming);assert_eq!(cast.started,started);
worker.join().unwrap();cast.stop();
}
#[test]
fn cast_recording_capture_settings_are_shared_and_locked() {
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
let mut cast=Casting::new(dir.path().into(),dir.path().into());
let mut settings=Settings::default();assert_eq!(settings.width*3,settings.height*4);
cast.started=Some(Instant::now());cast.active_settings=Some(settings.clone());
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
settings.height=1080;assert!(cast.validate_recording_settings(&settings).is_err());
assert!(cast.start(&settings,None).is_err());
settings.height=1440;settings.bitrate_mbps+=1;assert!(cast.validate_recording_settings(&settings).is_err());
settings.bitrate_mbps-=1;settings.airplay=true;settings.receiver_name="Another name".into();
assert!(cast.validate_recording_settings(&settings).is_ok());
}
#[test]
fn cast_recording_stale_indicator_does_not_stop_new_recording_and_stream_stop_finishes() {
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
let mut cast=Casting::new(dir.path().into(),dir.path().into());
assert!(cast.start_recording(&output).is_err());
cast.started=Some(Instant::now());
cast.streaming=true;
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
assert!(cast.start_recording(&output).is_err());
assert!(cast.start_preview(&Settings::default(),dir.path()).is_err());
cast.stop_recording_id("old-recording-id");assert!(cast.recording.is_some());
let id=cast.recording.as_ref().unwrap().id.clone();cast.stop_recording_id(&id);
assert!(cast.recording.is_none());assert!(cast.started.is_some());
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
cast.stop();assert!(cast.recording.is_none());assert!(cast.started.is_none());
assert_eq!(fs::read_to_string(cast.recording_path.as_ref().unwrap()).unwrap(),"header\ntrailer\n");
}
#[test]
fn cast_recording_process_failure_keeps_stream_and_reports_error() {
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
let mut cast=Casting::new(dir.path().into(),dir.path().into());cast.started=Some(Instant::now());cast.streaming=true;
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,true));
let end=Instant::now()+Duration::from_secs(3);
while cast.recording.is_some()&&Instant::now()<end {cast.maintain();std::thread::sleep(Duration::from_millis(25));}
assert!(cast.recording.is_none());assert!(cast.started.is_some());assert!(cast.recording_error.is_some());
}
#[test]
fn copied_video_packets_receive_frame_timestamps() {
assert_eq!(
video_packet_timestamps(60),
"setts=time_base=1/60:pts=N:dts=N:duration=1"
);
}
#[test]
fn media_worker_survives_the_http_request_thread() {
let mut child = std::thread::spawn(|| {
let mut command = Command::new("sleep");
+23 -1
View File
@@ -56,6 +56,8 @@ pub struct Settings {
pub airplay: bool,
pub dlna: bool,
pub receiver_name: String,
pub recording_region_visible: bool,
pub recording_indicator_visible: bool,
}
impl Default for Settings {
fn default() -> Self {
@@ -65,7 +67,7 @@ impl Default for Settings {
output: Output::Eye,
eye: Eye::SteamVR,
width: 1920,
height: 1080,
height: 1440,
fps: 30,
bitrate_mbps: 8,
horizontal_fov: None,
@@ -76,10 +78,21 @@ impl Default for Settings {
airplay: false,
dlna: false,
receiver_name: "Framely".into(),
recording_region_visible: false,
recording_indicator_visible: true,
}
}
}
impl Settings {
pub fn same_capture(&self, other: &Self) -> bool {
let mut capture = self.clone();
capture.airplay = other.airplay;
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<()> {
ensure!(
(160..=4096).contains(&self.width)
@@ -119,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);
+100
View File
@@ -0,0 +1,100 @@
//! Persistent, watch-only capability; deliberately separate from cast settings.
use anyhow::{ensure, Result};
use serde::{Deserialize, Serialize};
use std::{fs, io::Write, os::unix::fs::OpenOptionsExt, path::Path};
#[derive(Default, Serialize, Deserialize)]
pub struct Grant {
pub token: Option<String>,
}
impl Grant {
pub fn load(root: &Path) -> Result<Self> {
let bytes = match fs::read(root.join("cast-watch-access.json")) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => return Err(e.into()),
};
let grant: Self = serde_json::from_slice(&bytes)?;
ensure!(grant.token.as_ref().is_none_or(|t| t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())), "Invalid watch access record");
Ok(grant)
}
pub fn save(root: &Path, enabled: bool) -> Result<Self> {
let mut grant = Self::load(root)?;
if enabled && grant.token.is_none() {
grant.token = Some(crate::session::cast_random_key());
} else if !enabled {
grant.token = None;
}
let temporary = root.join("cast-watch-access.json.tmp");
let mut file = fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(&temporary)?;
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
let result = (|| -> Result<()> {
file.write_all(&serde_json::to_vec(&grant)?)?;
file.sync_all()?;
fs::rename(&temporary, root.join("cast-watch-access.json"))?;
fs::File::open(root)?.sync_all()?;
Ok(())
})();
if result.is_err() { let _ = fs::remove_file(&temporary); }
result?;
Ok(grant)
}
}
#[derive(Default)]
pub struct Access {
pub token: Option<String>,
pub sessions: std::collections::BTreeMap<String, String>,
pub revoked: Vec<String>,
}
impl Access {
pub fn accepts(&self, candidate: &str) -> bool {
self.token.as_deref().is_some_and(|token| {
token.len() == candidate.len() && token.bytes().zip(candidate.bytes()).fold(0u8, |diff, (a,b)| diff | (a ^ b)) == 0
})
}
pub fn replace(&mut self, token: Option<String>) {
if self.token != token {
self.revoked.extend(std::mem::take(&mut self.sessions).into_values());
self.token = token;
}
}
pub fn path(&self) -> String {
match &self.token {
Some(token) => format!("/cast/watch?token={token}"),
None => "/cast/watch".into(),
}
}
pub fn disconnect_revoked(&mut self) {
let client = ureq::AgentBuilder::new().redirects(0).timeout(std::time::Duration::from_secs(2)).build();
self.revoked.retain(|target| {
match client.delete(target).set("If-Match", "*").call() {
Ok(_) | Err(ureq::Error::Status(404 | 410, _)) => false,
Err(_) => true,
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn watch_access_persists_rotates_and_preserves_on_failure() {
let root = tempfile::tempdir().unwrap();
assert!(Grant::load(root.path()).unwrap().token.is_none());
let first = Grant::save(root.path(), true).unwrap().token.unwrap();
assert_eq!(first.len(), 64);
assert_eq!(Grant::load(root.path()).unwrap().token.as_ref(), Some(&first));
assert_eq!(Grant::save(root.path(), true).unwrap().token.as_ref(), Some(&first));
assert_eq!(fs::metadata(root.path().join("cast-watch-access.json")).unwrap().permissions().mode() & 0o777, 0o600);
fs::create_dir(root.path().join("cast-watch-access.json.tmp")).unwrap();
assert!(Grant::save(root.path(), false).is_err());
assert_eq!(Grant::load(root.path()).unwrap().token.as_ref(), Some(&first));
fs::remove_dir(root.path().join("cast-watch-access.json.tmp")).unwrap();
assert!(Grant::save(root.path(), false).unwrap().token.is_none());
assert_ne!(Grant::save(root.path(), true).unwrap().token.unwrap(), first);
}
}
+204 -12
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.
@@ -45,6 +54,20 @@ pub(crate) fn interruptible<T: Send + 'static>(
cancel: &crate::jobs::Cancellation,
timeout: Duration,
action: impl FnOnce(crate::jobs::Cancellation) -> Result<T> + Send + 'static,
) -> Result<T> {
interruptible_inner(cancel, Some(timeout), action)
}
/// Downloads have an idle socket timeout, not a wall-clock deadline.
pub(crate) fn interruptible_download<T: Send + 'static>(
cancel: &crate::jobs::Cancellation,
action: impl FnOnce(crate::jobs::Cancellation) -> Result<T> + Send + 'static,
) -> Result<T> {
interruptible_inner(cancel, None, action)
}
fn interruptible_inner<T: Send + 'static>(
cancel: &crate::jobs::Cancellation,
timeout: Option<Duration>,
action: impl FnOnce(crate::jobs::Cancellation) -> Result<T> + Send + 'static,
) -> Result<T> {
use std::sync::{atomic::Ordering, mpsc};
cancel.check()?;
@@ -78,12 +101,17 @@ pub(crate) fn interruptible<T: Send + 'static>(
let _permit = permit;
let _ = tx.send(action(worker_token));
})?;
let deadline = std::time::Instant::now() + timeout;
let deadline = timeout.map(|timeout| std::time::Instant::now() + timeout);
loop {
cancel.check()?;
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
ensure!(!remaining.is_zero(), "网络请求超时");
match rx.recv_timeout(remaining.min(Duration::from_millis(50))) {
let wait = if let Some(deadline) = deadline {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
ensure!(!remaining.is_zero(), "网络请求超时");
remaining.min(Duration::from_millis(50))
} else {
Duration::from_millis(50)
};
match rx.recv_timeout(wait) {
Ok(result) => return result,
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => bail!("网络请求任务异常"),
@@ -98,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
@@ -109,7 +140,42 @@ pub fn get_with_proxy_cancel(
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
get_blocking(&initial, allow_http, timeout, &refs, &proxy, &token)
get_blocking(&initial, allow_http, timeout, &refs, &proxy, &token, false)
})
}
/// Bound connection/individual socket waits while allowing a progressing body
/// to take arbitrarily long. Metadata requests retain their total deadlines.
pub fn get_download_with_proxy(
initial: &str,
allow_http: bool,
idle_timeout: Duration,
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
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
// Only obtaining headers has a total deadline; the returned body does not.
interruptible(&Default::default(), idle_timeout, move |token| {
let refs: Vec<_> = headers
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
get_blocking(
&initial,
allow_http,
idle_timeout,
&refs,
&proxy,
&token,
true,
)
})
}
fn get_blocking(
@@ -119,14 +185,22 @@ fn get_blocking(
headers: &[(&str, &str)],
proxy: &crate::model::ProxySettings,
cancel: &crate::jobs::Cancellation,
download: bool,
) -> Result<ureq::Response> {
cancel.check()?;
proxy.validate()?;
crate::model::validate_url(initial, allow_http)?;
let mut builder = ureq::AgentBuilder::new()
.timeout(timeout)
let builder = ureq::AgentBuilder::new()
.redirects(0)
.try_proxy_from_env(false);
let mut builder = if download {
builder
.timeout_connect(timeout)
.timeout_read(timeout)
.timeout_write(timeout)
} else {
builder.timeout(timeout)
};
if proxy.http_enabled {
builder = builder.proxy(ureq::Proxy::new(&proxy.http)?);
}
@@ -135,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);
}
@@ -190,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
@@ -211,6 +287,118 @@ 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},
net::TcpListener,
};
for chunked in [false, true] {
let server = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/slow", server.local_addr().unwrap());
let server_thread = std::thread::spawn(move || {
let (mut stream, _) = server.accept().unwrap();
let headers = if chunked {
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
} else {
"HTTP/1.1 200 OK\r\nContent-Length: 12\r\nConnection: close\r\n\r\n"
};
stream.write_all(headers.as_bytes()).unwrap();
for _ in 0..12 {
std::thread::sleep(Duration::from_millis(100));
stream
.write_all(if chunked { b"1\r\nx\r\n" } else { b"x" })
.unwrap();
stream.flush().unwrap();
}
if chunked {
stream.write_all(b"0\r\n\r\n").unwrap();
}
});
let start = std::time::Instant::now();
let result = interruptible_download(&Default::default(), move |_| {
let response = get_download_with_proxy(
&url,
true,
Duration::from_millis(500),
&[],
&Default::default(),
)?;
let mut body = Vec::new();
response.into_reader().read_to_end(&mut body)?;
Ok(body)
});
server_thread.join().unwrap();
assert_eq!(result.unwrap(), b"xxxxxxxxxxxx");
assert!(start.elapsed() > Duration::from_millis(500));
}
}
#[test]
fn download_stalled_body_still_times_out() {
use std::{
io::{Read, Write},
net::TcpListener,
sync::mpsc,
};
let server = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/stalled", server.local_addr().unwrap());
let (release_tx, release_rx) = mpsc::channel();
let server_thread = std::thread::spawn(move || {
let (mut stream, _) = server.accept().unwrap();
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nh")
.unwrap();
release_rx.recv_timeout(Duration::from_secs(3)).unwrap();
});
let response = get_download_with_proxy(
&url,
true,
Duration::from_millis(250),
&[],
&Default::default(),
)
.unwrap();
let start = std::time::Instant::now();
let result = response.into_reader().read_to_end(&mut Vec::new());
release_tx.send(()).unwrap();
server_thread.join().unwrap();
assert!(result.is_err());
assert!(start.elapsed() < Duration::from_secs(2));
}
#[test]
fn download_without_total_deadline_remains_cancellable() {
use std::sync::mpsc;
let token = crate::jobs::Cancellation::default();
let worker_token = token.clone();
let (seen_tx, seen_rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
interruptible_download(&worker_token, move |cancel| {
seen_tx.send(()).unwrap();
loop {
cancel.check()?;
std::thread::sleep(Duration::from_millis(5));
}
#[allow(unreachable_code)]
Ok(())
})
});
seen_rx.recv_timeout(Duration::from_secs(2)).unwrap();
let start = std::time::Instant::now();
token.stop();
assert!(worker.join().unwrap().is_err());
assert!(start.elapsed() < Duration::from_millis(500));
}
#[test]
fn redirect_download_retains_integrity_and_bounds() {
use tiny_http::{Header, Response, Server};
let server = Server::http("127.0.0.1:0").unwrap();
@@ -281,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(),
@@ -333,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
@@ -390,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
);
}
}
}
+4 -2
View File
@@ -80,6 +80,9 @@ impl Default for Jobs {
}
}
impl Jobs {
pub fn active_kind(&self, kind: &str) -> bool {
self.state.lock().unwrap().jobs.values().any(|job| job.active && job.public["kind"] == kind)
}
fn expire(state: &mut State) {
for job in state.jobs.values_mut() {
if !job.active
@@ -272,9 +275,8 @@ impl Jobs {
let download_request = request.clone();
let download_jobs = jobs.clone();
let download_key = key.clone();
let bytes = crate::http::interruptible(
let bytes = crate::http::interruptible_download(
&cancel,
std::time::Duration::from_secs(180),
move |token| {
package::stage_request_proxy(
&download_request,
+1
View File
@@ -21,6 +21,7 @@ mod relations;
mod service;
mod session;
mod steam;
mod storage_location;
mod subscriptions;
mod terminal;
#[cfg(test)]
+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");
+12 -2
View File
@@ -1,7 +1,7 @@
use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Component, Path};
use std::path::{Component, Path, PathBuf};
pub const API_VERSION: u32 = 1;
@@ -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!(
@@ -814,6 +822,8 @@ pub struct AgreementAcceptance {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Database {
#[serde(default)]
pub plugin_storage: Option<PathBuf>,
#[serde(default)]
pub casting: crate::cast::settings::Settings,
#[serde(default = "default_language")]
+17 -7
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)?;
@@ -223,7 +229,7 @@ pub fn stage_request_proxy(
progress(0, None)?;
let url = v["url"].as_str().context("Missing package/URL")?;
validate_url(url, v["allowHttp"].as_bool().unwrap_or(false))?;
let response = crate::http::get_with_proxy(
let response = crate::http::get_download_with_proxy(
url,
v["allowHttp"].as_bool().unwrap_or(false),
std::time::Duration::from_secs(60),
@@ -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))?;
@@ -421,7 +434,7 @@ pub fn from_request_proxy(
progress(0, None)?;
let url = v["url"].as_str().context("Missing package/URL")?;
validate_url(url, v["allowHttp"].as_bool().unwrap_or(false))?;
let response = crate::http::get_with_proxy(
let response = crate::http::get_download_with_proxy(
url,
v["allowHttp"].as_bool().unwrap_or(false),
std::time::Duration::from_secs(60),
@@ -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");
}
+6 -6
View File
@@ -133,11 +133,9 @@ pub fn open_web_link(url: &str) -> Result<()> {
Ok(())
}
fn command(m: &Manifest, payload: &Path, manager: u32, logs: &Path, unit: &str) -> Result<Command> {
fn command(m: &Manifest, payload: &Path, manager: u32, _logs: &Path, storage: &Path, unit: &str) -> Result<Command> {
let b = m.backend.as_ref().context("Plugin has no backend")?;
let home = logs
.parent()
.context("Missing state directory")?
let home = storage
.join("data")
.join(&m.id)
.join(match b.run_as {
@@ -216,6 +214,7 @@ pub fn hook(
payload: &Path,
manager: u32,
logs: &Path,
storage: &Path,
context: Value,
timeout: Duration,
) -> Result<()> {
@@ -231,7 +230,7 @@ pub fn hook(
ui_visibility_events: false,
});
let unit = format!("framely-hook-{}-{:032x}", m.id, rand::random::<u128>());
let cmd = command(&launch, payload, manager, logs, &unit)?;
let cmd = command(&launch, payload, manager, logs, storage, &unit)?;
// Insert env/property arguments before the executable delimiter.
let args: Vec<_> = cmd.get_args().map(|a| a.to_owned()).collect();
let split = args
@@ -295,9 +294,10 @@ impl Running {
manager: u32,
events: Events,
logs: &Path,
storage: &Path,
) -> Result<Self> {
let unit = format!("framely-backend-{}", m.id);
let mut cmd = command(m, payload, manager, logs, &unit)?;
let mut cmd = command(m, payload, manager, logs, storage, &unit)?;
fs::create_dir_all(logs)?;
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().context("Cannot start plugin service")?;
+321 -32
View File
@@ -43,11 +43,22 @@ pub struct Service {
runtime: BTreeMap<String, Value>,
waiting: BTreeSet<String>,
batching: bool,
storage_jobs: crate::jobs::Jobs,
plugin_migration: bool,
visibility: crate::visibility::Tracker,
visibility_pending: BTreeSet<String>,
launch_contexts: BTreeMap<String, Value>,
}
impl Service {
fn plugin_store(&self) -> &Path {
self.db.plugin_storage.as_deref().unwrap_or(&self.root)
}
fn ensure_plugin_store(&self) -> Result<()> {
let path = self.plugin_store();
let meta = if self.db.plugin_storage.is_some() { fs::symlink_metadata(path)? } else { fs::metadata(path)? };
ensure!(meta.is_dir() && (self.db.plugin_storage.is_none() || !meta.file_type().is_symlink()), "插件存储目录不可用:{}", path.display());
Ok(())
}
pub fn prepare_uninstall(&mut self) -> Result<Value> {
self.cancel_automatic_check();
self.updater.ensure_idle()?;
@@ -78,7 +89,7 @@ impl Service {
Value::Null,
)
.with_context(|| format!("插件 {id} 卸载失败,本体保留,请重试"))?;
fs::remove_dir_all(self.root.join("plugins").join(id))?;
fs::remove_dir_all(self.plugin_store().join("plugins").join(id))?;
self.db.plugins.remove(id);
self.db.notification_settings.plugins.remove(id);
self.runtime.remove(id);
@@ -99,6 +110,13 @@ impl Service {
} else {
Database::default()
};
let migration = root.join("plugin-storage-migration.json");
if let Ok(bytes) = fs::read(&migration) {
let record: Value = serde_json::from_slice(&bytes)?;
if record["target"].as_str() == db.plugin_storage.as_deref().and_then(Path::to_str) {
fs::remove_file(migration)?;
}
}
db.update_check.validate()?;
db.launcher.validate()?;
db.casting.validate()?;
@@ -124,8 +142,9 @@ impl Service {
if let Ok(bytes) = fs::read(root.join("install-transaction.json")) {
let journal: Value = serde_json::from_slice(&bytes)?;
let original: Database = serde_json::from_value(journal["database"].clone())?;
restore_replaced(root, &journal)?;
restore_links(
root,
original.plugin_storage.as_deref().unwrap_or(root),
&original,
&serde_json::from_value::<Vec<String>>(journal["targets"].clone())?,
)?;
@@ -144,7 +163,7 @@ impl Service {
crate::subscriptions::initialize(&mut db)?;
// Rehydrate presentation after a state-schema downgrade from immutable payloads.
for installed in db.plugins.values_mut() {
let path = root
let path = db.plugin_storage.as_deref().unwrap_or(root)
.join("plugins")
.join(&installed.manifest.id)
.join("versions")
@@ -203,6 +222,8 @@ impl Service {
runtime: BTreeMap::new(),
waiting: resume,
batching: false,
storage_jobs: Default::default(),
plugin_migration: false,
visibility: Default::default(),
visibility_pending: BTreeSet::new(),
launch_contexts: BTreeMap::new(),
@@ -240,7 +261,7 @@ impl Service {
fn payload(&self, id: &str) -> Result<PathBuf> {
let p = self.plugin(id)?;
Ok(self
.root
.plugin_store()
.join("plugins")
.join(id)
.join("versions")
@@ -288,7 +309,7 @@ impl Service {
previous: Option<&str>,
extra: Value,
) -> Value {
json!({"pluginId":m.id,"phase":phase,"reason":reason,"version":m.version,"previousVersion":previous,"dataDir":self.root.join("data").join(&m.id).join(match m.run_as(){RunAs::Steamos=>"steamos",RunAs::Root=>"root"}),"exit":extra,"launchContext":self.launch_contexts.get(&format!("{}/quick",m.id))})
json!({"pluginId":m.id,"phase":phase,"reason":reason,"version":m.version,"previousVersion":previous,"dataDir":self.plugin_store().join("data").join(&m.id).join(match m.run_as(){RunAs::Steamos=>"steamos",RunAs::Root=>"root"}),"exit":extra,"launchContext":self.launch_contexts.get(&format!("{}/quick",m.id))})
}
fn command_hook(
&mut self,
@@ -315,6 +336,7 @@ impl Service {
payload,
self.manager,
&self.root.join("logs"),
self.plugin_store(),
self.context(m, phase, reason, previous, extra),
Duration::from_secs(l.timeout_seconds),
);
@@ -376,6 +398,7 @@ impl Service {
self.manager,
self.events.clone(),
&self.root.join("logs"),
self.plugin_store(),
)?;
if let Some(l) = m.lifecycle.as_ref().filter(|l| l.on_start) {
running.call_timeout(
@@ -634,6 +657,7 @@ impl Service {
}
}
pub fn maintenance(&mut self) {
if self.plugin_migration { return; }
self.expire_notifications();
if self.visibility.expire(Instant::now()) {
self.publish_visibility();
@@ -736,6 +760,9 @@ impl Service {
.is_some_and(|a| a.version == AGREEMENT_VERSION && a.accepted_at > 0)
}
pub fn handle(&mut self, method: &str, p: Value) -> Result<Value> {
if self.plugin_migration && (method.starts_with("plugin.") || matches!(method, "install" | "install.batch" | "safeMode" | "system.uninstall.prepare")) {
bail!("插件存储正在迁移,请等待完成");
}
ensure!(
self.agreement_accepted()
|| matches!(
@@ -752,6 +779,7 @@ impl Service {
| "agreement.revoke"
| "network.password.verify"
| "network.password.configured"
| "cast.watchAccess.get"
| "network.password.setup"
| "system.uninstall.prepare"
),
@@ -766,6 +794,18 @@ impl Service {
}
let id = p["plugin"].as_str().unwrap_or("").to_owned();
match method {
"storage.plugin.status" => {
let store = self.plugin_store();
let (program_bytes, program_incomplete) = crate::storage_location::disk_bytes(&store.join("plugins"))?;
let (data_bytes, data_incomplete) = crate::storage_location::disk_bytes(&store.join("data"))?;
let details = self.db.plugins.iter().map(|(id, installed)| {
let (program, a) = crate::storage_location::disk_bytes(&store.join("plugins").join(id))?;
let (data, b) = crate::storage_location::disk_bytes(&store.join("data").join(id))?;
Ok(json!({"id":id,"name":installed.manifest.name,"bytes":program.saturating_add(data),"programBytes":program,"dataBytes":data,"sizeIncomplete":a || b}))
}).collect::<Result<Vec<Value>>>()?;
let device = fs::metadata(store).ok().map(|m| std::os::unix::fs::MetadataExt::dev(&m));
Ok(json!({"path":store,"bytes":program_bytes.saturating_add(data_bytes),"sizeIncomplete":program_incomplete || data_incomplete,"device":device,"details":details,"available":self.ensure_plugin_store().is_ok(),"migrating":self.plugin_migration,"interrupted":fs::read(self.root.join("plugin-storage-migration.json")).ok().and_then(|b|serde_json::from_slice::<Value>(&b).ok()).map(|v|v["target"].clone())}))
},
"host.ui.visibility" => {
let views = serde_json::from_value(p)?;
if self.visibility.report(views, Instant::now())? {
@@ -1007,9 +1047,9 @@ impl Service {
self.db.notification_settings.plugins.remove(&id);
self.notifications.retain(|_, n| n.plugin != id);
self.save_notifications()?;
fs::remove_dir_all(self.root.join("plugins").join(&id))?;
fs::remove_dir_all(self.plugin_store().join("plugins").join(&id))?;
if p["purge"].as_bool() == Some(true) {
let data = self.root.join("data").join(&id);
let data = self.plugin_store().join("data").join(&id);
if data.exists() {
fs::remove_dir_all(data)?;
}
@@ -1081,6 +1121,11 @@ impl Service {
self.save()?;
Ok(json!(true))
}
"cast.watchAccess.get" => Ok(serde_json::to_value(crate::cast::watch_access::Grant::load(&self.root)?)?),
"cast.watchAccess.save" => {
let enabled = p["enabled"].as_bool().context("Missing enabled")?;
Ok(serde_json::to_value(crate::cast::watch_access::Grant::save(&self.root, enabled)?)?)
}
"cast.settings.save" => {
let settings: crate::cast::settings::Settings = serde_json::from_value(p)?;
settings.validate()?;
@@ -1088,6 +1133,23 @@ impl Service {
self.save()?;
Ok(json!(true))
}
"cast.region.visible.save" => {
let enabled=p["enabled"].as_bool().context("Missing region visibility")?;
let previous=self.db.casting.recording_region_visible;
self.db.casting.recording_region_visible=enabled;
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(), "缺少内置屏幕采集助手");
@@ -1580,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" => {
@@ -2024,35 +2086,41 @@ impl Service {
"Unknown installation source"
);
}
let base = self.root.join("plugins").join(&id);
let base = self.plugin_store().join("plugins").join(&id);
self.ensure_plugin_store()?;
fs::create_dir_all(base.join("versions"))?;
let dest = base.join("versions").join(&v.manifest.version);
let newly_created = !dest.exists();
if newly_created {
let stage = base.join(format!(".stage-{}", rand::random::<u64>()));
if let Err(e) = package::unpack(&v, &stage) {
let _ = fs::remove_dir_all(&stage);
return Err(e);
}
fs::rename(stage, &dest)?;
} else {
let cached: Manifest = serde_json::from_slice(&fs::read(dest.join("manifest.json"))?)?;
ensure!(
serde_json::to_value(&cached)? == serde_json::to_value(&v.manifest)?,
"同一插件版本的发布内容发生变化,拒绝替换"
);
for (file, expected) in &v.manifest.files {
let path = dest.join(file);
ensure!(
fs::symlink_metadata(&path)?.file_type().is_file()
&& package::digest(&fs::read(path)?) == *expected,
"本机版本文件校验失败:{file}"
);
}
let stage = base.join(format!(".stage-{}", rand::random::<u64>()));
if let Err(e) = package::unpack(&v, &stage) {
let _ = fs::remove_dir_all(&stage);
return Err(e);
}
let was_running = self.running.contains_key(&id);
let old = self.db.plugins.get(&id).cloned();
if !newly_created {
if let Err(e) = record_replacement(&self.root, &id, &v.manifest.version, &self.db, was_running, self.batching) {
let _ = fs::remove_dir_all(&stage);
return Err(e);
}
}
self.stop_reason(&id, if old.is_some() { "update" } else { "install" });
let switch = if !newly_created {
let journal: Value = serde_json::from_slice(&fs::read(self.root.join("install-transaction.json"))?)?;
let backup = replacement_path(&self.root, &journal, &id)?;
fs::rename(&dest, &backup).and_then(|_| fs::rename(&stage, &dest))
} else {
fs::rename(&stage, &dest)
};
if let Err(error) = switch {
let _ = fs::remove_dir_all(&stage);
if !newly_created {
restore_one_replacement(&self.root, &id)?;
if !self.batching { clear_replacement_journal(&self.root)?; }
}
if !self.batching && was_running { let _ = self.start_reason(&id, "install-rollback"); }
return Err(error.into());
}
let phase = if old.is_some() {
"onUpdate"
} else {
@@ -2066,6 +2134,10 @@ impl Service {
old.as_ref().map(|p| p.manifest.version.as_str()),
Value::Null,
) {
if !newly_created {
restore_one_replacement(&self.root, &id)?;
if !self.batching { clear_replacement_journal(&self.root)?; }
}
if newly_created {
let _ = fs::remove_dir_all(&dest);
}
@@ -2100,6 +2172,10 @@ impl Service {
fs::rename(link, base.join("current"))?;
self.db.plugins.insert(id.clone(), installed);
if let Err(e) = self.save() {
if !newly_created {
restore_one_replacement(&self.root, &id)?;
if !self.batching { clear_replacement_journal(&self.root)?; }
}
if let Some(old) = old.clone() {
let _ = fs::remove_file(base.join("current"));
let _ = symlink(
@@ -2125,6 +2201,10 @@ impl Service {
if let Err(error) = self.start_reason(&id, "install") {
// A startup hook failure is an installation failure, not a successful activation.
self.stop_reason(&id, "install-rollback");
if !newly_created {
restore_one_replacement(&self.root, &id)?;
clear_replacement_journal(&self.root)?;
}
if let Some(previous) = old {
let _ = fs::remove_file(base.join("current"));
let _ = symlink(
@@ -2146,6 +2226,9 @@ impl Service {
return Err(error);
}
}
if !self.batching && !newly_created {
clear_replacement_journal(&self.root)?;
}
Ok(json!(self.db.plugins[&id]))
}
fn disable_dependents(&mut self, id: &str, p: &Value) -> Result<()> {
@@ -2361,7 +2444,9 @@ impl Service {
self.stop_reason(id, "transaction-rollback");
self.waiting.remove(id);
}
restore_links(&self.root, &original, &targets)?;
let journal: Value = serde_json::from_slice(&fs::read(&path)?)?;
restore_replaced(&self.root, &journal)?;
restore_links(original.plugin_storage.as_deref().unwrap_or(&self.root), &original, &targets)?;
self.db = original;
self.save()?;
for id in &running {
@@ -2371,7 +2456,9 @@ impl Service {
fs::remove_file(path)?;
return Err(error).context("批量安装失败,已恢复原版本和启用状态");
}
let journal: Value = serde_json::from_slice(&fs::read(&path)?)?;
fs::remove_file(path)?;
discard_replacements(&self.root, &journal)?;
fs::File::open(&self.root)?.sync_all()?;
for id in impacted {
process::event(
@@ -2402,6 +2489,70 @@ fn restore_links(root: &Path, original: &Database, targets: &[String]) -> Result
}
Ok(())
}
fn replacement_path(root: &Path, journal: &Value, id: &str) -> Result<PathBuf> {
valid_id(id)?;
let name = journal["backups"][id]
.as_str()
.context("缺少同版本安装备份记录")?;
ensure!(name.starts_with(".previous-") && !name.contains('/') && !name.contains('\\'), "无效插件备份路径");
let original: Database = serde_json::from_value(journal["database"].clone())?;
Ok(original.plugin_storage.as_deref().unwrap_or(root).join("plugins").join(id).join(name))
}
fn record_replacement(root: &Path, id: &str, version: &str, original: &Database, running: bool, batching: bool) -> Result<()> {
let path = root.join("install-transaction.json");
let mut journal: Value = if batching {
serde_json::from_slice(&fs::read(&path)?)?
} else {
json!({"database": original, "targets": [id], "running": if running {vec![id]} else {vec![]}})
};
let name = format!(".previous-{}-{:016x}", version, rand::random::<u64>());
ensure!(!name.contains('/') && !name.contains('\\'), "无效插件版本");
journal["backups"][id] = json!(name);
journal["replacementVersions"][id] = json!(version);
let temporary = root.join("install-transaction.tmp");
fs::write(&temporary, serde_json::to_vec(&journal)?)?;
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
fs::File::open(&temporary)?.sync_all()?;
fs::rename(temporary, &path)?;
fs::File::open(root)?.sync_all()?;
Ok(())
}
fn restore_one_replacement(root: &Path, id: &str) -> Result<()> {
let journal: Value = serde_json::from_slice(&fs::read(root.join("install-transaction.json"))?)?;
let backup = replacement_path(root, &journal, id)?;
if backup.is_dir() {
let version = journal["replacementVersions"][id].as_str().context("缺少替换版本")?;
let original: Database = serde_json::from_value(journal["database"].clone())?;
let dest = original.plugin_storage.as_deref().unwrap_or(root).join("plugins").join(id).join("versions").join(version);
if dest.exists() { fs::remove_dir_all(&dest)?; }
fs::rename(backup, dest)?;
}
Ok(())
}
fn restore_replaced(root: &Path, journal: &Value) -> Result<()> {
if let Some(backups) = journal["backups"].as_object() {
for id in backups.keys() {
restore_one_replacement(root, id)?;
}
}
Ok(())
}
fn discard_replacements(root: &Path, journal: &Value) -> Result<()> {
if let Some(backups) = journal["backups"].as_object() {
for id in backups.keys() {
let path = replacement_path(root, journal, id)?;
if path.exists() { fs::remove_dir_all(path)?; }
}
}
Ok(())
}
fn clear_replacement_journal(root: &Path) -> Result<()> {
let path = root.join("install-transaction.json");
let journal: Value = serde_json::from_slice(&fs::read(&path)?)?;
fs::remove_file(path)?;
fs::File::open(root)?.sync_all()?;
discard_replacements(root, &journal)
}
fn plugin_restarts(db: &Database, id: &str) -> bool {
db.plugins.get(id).is_some_and(|p| {
p.enabled
@@ -2423,6 +2574,105 @@ pub fn now_ms() -> u64 {
.as_millis() as u64
}
fn dispatch(core: &Arc<Mutex<Service>>, method: &str, params: Value) -> Result<Value> {
if method == "storage.plugin.job" {
let service = core.lock().unwrap();
return service.storage_jobs.status(params["job"].as_str().context("缺少任务 ID")?);
}
if method == "storage.plugin.start" {
let path = PathBuf::from(params["path"].as_str().context("缺少目标路径")?);
let (jobs, source, target, root, resume, follow_links) = {
let mut service = core.lock().unwrap();
ensure!(service.agreement_accepted(), "请先同意用户协议和隐私声明");
ensure!(!service.plugin_migration, "插件存储正在迁移");
ensure!(!service.root.join("install-transaction.json").exists(), "插件安装正在进行");
let source = service.plugin_store().to_path_buf();
service.ensure_plugin_store()?;
let follow_links = service.db.plugin_storage.is_none();
let root = service.root.clone();
let journal = root.join("plugin-storage-migration.json");
let (target, resume) = if journal.exists() {
let record: Value = serde_json::from_slice(&fs::read(&journal)?)?;
ensure!(record["source"] == json!(source), "存储迁移来源已改变,请检查原目录");
if record["target"] == json!(path) {
let resume = path.exists() && crate::storage_location::verified_selected(&source, &path, &["plugins".into(), "data".into()], follow_links)?;
ensure!(!path.exists() || resume || (path.is_dir() && fs::read_dir(&path)?.next().is_none()), "上次迁移副本已与原目录不同,请选择新的空目录");
(path.clone(), resume)
} else { (crate::storage_location::validate_target(&source, &path, false)?, false) }
} else {
(crate::storage_location::validate_target(&source, &path, false)?, false)
};
service.plugin_migration = true;
(service.storage_jobs.clone(), source, target, root, resume, follow_links)
};
let shared = core.clone();
let task = jobs.task_progress("storage:plugin", move |cancel, progress| {
let mut running = Vec::new();
let result = (|| -> Result<Value> {
crate::storage_location::write_journal(&root.join("plugin-storage-migration.json"), &json!({"source":source,"target":target}))?;
progress(json!({"step":"stopping"}));
{
let mut service = shared.lock().unwrap();
running = service.running.keys().cloned().collect();
for id in running.iter().rev() { service.stop_reason(id, "storage-migration"); }
service.recovery.clear();
}
cancel.check()?;
if !resume { crate::storage_location::copy_selected(&source, &target, &["plugins".into(), "data".into()], follow_links, |step| progress(json!({"step":step})))?; }
cancel.check()?;
{
let mut service = shared.lock().unwrap();
let previous = service.db.plugin_storage.clone();
service.db.plugin_storage = Some(target.clone());
if let Err(error) = service.save() {
service.db.plugin_storage = previous;
return Err(error);
}
progress(json!({"step":"restarting"}));
let restart = (|| -> Result<()> {
for id in &running { service.start_reason(id, "storage-migration")?; }
Ok(())
})();
if let Err(error) = restart {
for id in &running { service.stop_reason(id, "storage-rollback"); }
service.db.plugin_storage = previous;
service.save()?;
return Err(error).context("迁移后插件启动失败,已恢复原位置");
}
}
fs::remove_file(root.join("plugin-storage-migration.json"))?;
progress(json!({"step":"cleaning"}));
let mut cleanup_errors = Vec::new();
for name in ["plugins", "data"] {
let old = source.join(name);
if old.exists() {
let remove = if follow_links && fs::symlink_metadata(&old).is_ok_and(|m|m.file_type().is_symlink()) {
let physical = old.canonicalize();
physical.and_then(|directory| {
for entry in fs::read_dir(directory)? {
let path = entry?.path();
if path.is_dir() && !fs::symlink_metadata(&path)?.file_type().is_symlink() { fs::remove_dir_all(path)?; }
else { fs::remove_file(path)?; }
}
Ok(())
})
} else { fs::remove_dir_all(old) };
if let Err(error) = remove { cleanup_errors.push(error.to_string()); }
}
}
Ok(json!({"path":target,"cleanupWarnings":cleanup_errors}))
})();
{
let mut service = shared.lock().unwrap();
if result.is_err() {
for id in &running { let _ = service.start_reason(id, "storage-rollback"); }
}
service.plugin_migration = false;
}
result
});
if task.is_err() { core.lock().unwrap().plugin_migration = false; }
return task;
}
if method == "network.password.verify" {
let (root, verifier) = {
let service = core.lock().unwrap();
@@ -2578,6 +2828,45 @@ mod request_tests {
assert_eq!(worker.join().unwrap(), json!(false));
}
}
#[cfg(test)]
mod storage_tests {
use super::*;
#[test]
fn plugin_storage_migration_moves_files_and_survives_reload() {
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
let state = temp.path().join("state");
let mut service = crate::tests::accepted_service(&state, unsafe { libc::geteuid() }).unwrap();
let package = crate::tests::fixture(temp.path(), "test.storage", "1.0.0", 1, None);
service.handle("install", json!({"package":base64::Engine::encode(&base64::engine::general_purpose::STANDARD, package),"approve":true})).unwrap();
let data = state.join("data/test.storage/steamos/save.txt");
fs::create_dir_all(data.parent().unwrap()).unwrap();
fs::write(&data, "saved").unwrap();
let persistent = temp.path().join("persistent");
fs::create_dir(&persistent).unwrap();
for name in ["plugins", "data"] {
fs::rename(state.join(name), persistent.join(name)).unwrap();
symlink(persistent.join(name), state.join(name)).unwrap();
}
let target = temp.path().join("relocated");
let core = Arc::new(Mutex::new(service));
let job = dispatch(&core, "storage.plugin.start", json!({"path":target})).unwrap()["job"].as_str().unwrap().to_owned();
let result = loop {
let status = dispatch(&core, "storage.plugin.job", json!({"job":job})).unwrap();
if status["phase"] == "done" || status["phase"] == "failed" { break status; }
std::thread::sleep(Duration::from_millis(20));
};
assert_eq!(result["phase"], "done", "{result}");
assert_eq!(fs::read_to_string(target.join("data/test.storage/steamos/save.txt")).unwrap(), "saved");
assert!(target.join("plugins/test.storage/versions/1.0.0/manifest.json").is_file());
assert!(fs::symlink_metadata(state.join("plugins")).unwrap().file_type().is_symlink());
assert!(fs::read_dir(persistent.join("plugins")).unwrap().next().is_none());
assert!(fs::read_dir(persistent.join("data")).unwrap().next().is_none());
drop(core);
let loaded = Service::load(&state, unsafe { libc::geteuid() }).unwrap();
assert_eq!(loaded.db.plugin_storage.as_deref(), Some(target.as_path()));
assert!(loaded.payload("test.storage").unwrap().is_dir());
}
}
#[cfg(test)]
mod notification_tests {
+505 -17
View File
@@ -11,6 +11,7 @@ use std::{
time::Duration,
};
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
static APK_STORAGE_API_GATE: Mutex<()> = Mutex::new(());
struct FrameAccess {
plugin: String,
@@ -36,6 +37,8 @@ struct Agent {
files: Arc<crate::manager_files::Files>,
catalog_cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
casting: Mutex<crate::cast::runtime::Casting>,
recording_overlay_cache: Mutex<Value>,
watch_access: Mutex<crate::cast::watch_access::Access>,
receivers: Mutex<crate::cast::receiver::Receivers>,
}
#[derive(Default)]
@@ -74,6 +77,25 @@ fn push(q: &Mutex<VecDeque<Value>>, v: Value) {
q.push_back(v);
}
impl Agent {
fn watch_cookie(r: &Request) -> Option<String> {
r.headers().iter().filter(|h| h.field.equiv("Cookie"))
.flat_map(|h| h.value.as_str().split(';'))
.find_map(|cookie| cookie.trim().strip_prefix("framely-watch=").map(str::to_owned))
}
fn watch_resource(path: &str, method: &Method) -> bool {
(method == &Method::Get && matches!(path, "/cast/watch" | "/cast/access" | "/assets/app.js" | "/assets/app.css"))
|| ((path == "/cast/whep" || path.starts_with("/cast/whep/"))
&& matches!(method, Method::Post | Method::Patch | Method::Delete | Method::Options))
}
fn watch_expired(r: Request) -> Result<()> {
r.respond(Response::from_string("<!doctype html><meta charset=\"utf-8\"><title>Framely</title><p>观看链接已失效</p>")
.with_status_code(403)
.with_header(header("Content-Type", "text/html; charset=utf-8"))
.with_header(header("X-Framely-Watch-Expired", "1"))
.with_header(header("Cache-Control", "no-store"))
.with_header(header("Referrer-Policy", "no-referrer")))?;
Ok(())
}
fn core(&self, m: &str, p: Value) -> Result<Value> {
ipc::call(&self.socket, m, p)
}
@@ -180,10 +202,10 @@ impl Agent {
plugin["manifest"]["files"].get(file).is_some(),
"Unlisted asset"
);
let store = status["database"]["pluginStorage"].as_str().map(std::path::PathBuf::from).unwrap_or_else(|| self.state.clone());
return send_file(
r,
&self
.state
&store
.join("plugins")
.join(id)
.join("versions")
@@ -228,8 +250,30 @@ impl Agent {
return Ok(());
}
}
let configured = remote && self.core("network.password.configured", json!({}))? == true;
if remote && (!configured || !self.network_authorized(&r)) {
let watch_cookie = Self::watch_cookie(&r);
if remote && path == "/cast/watch" && r.method() == &Method::Get {
let token = r.url().split_once('?').and_then(|(_, query)| url::form_urlencoded::parse(query.as_bytes()).find(|(key,_)| key == "token").map(|(_,value)| value.into_owned()));
if let Some(token) = token {
if !self.watch_access.lock().unwrap().accepts(&token) { return Self::watch_expired(r); }
r.respond(Response::empty(StatusCode(303))
.with_header(header("Location", "/cast/watch"))
.with_header(header("Set-Cookie", &format!("framely-watch={token}; Path=/; HttpOnly; SameSite=Strict")))
.with_header(header("Cache-Control", "no-store"))
.with_header(header("Referrer-Policy", "no-referrer")))?;
return Ok(());
}
}
let manager_authorized = remote && self.network_authorized(&r);
let guest = remote && !manager_authorized && Self::watch_resource(&path, r.method())
&& watch_cookie.as_ref().is_some_and(|token| self.watch_access.lock().unwrap().accepts(token));
if remote && Self::watch_resource(&path, r.method()) && !manager_authorized && !guest
&& (watch_cookie.is_some() || path == "/cast/access") { return Self::watch_expired(r); }
if remote && path == "/cast/access" && (guest || manager_authorized) {
r.respond(Response::empty(StatusCode(204)).with_header(header("Cache-Control", "no-store")))?;
return Ok(());
}
let configured = remote && !guest && self.core("network.password.configured", json!({}))? == true;
if remote && !guest && (!configured || !manager_authorized) {
let status = self.core("status", json!({}))?;
let protected = status["database"]["networkPanel"]["passwordEnabled"]
.as_bool()
@@ -365,6 +409,15 @@ impl Agent {
r.as_reader().take(32769).read_to_end(&mut bytes)?;
ensure!(bytes.len() <= 32768, "Visibility report too large");
let views: Value = serde_json::from_slice(&bytes)?;
// Empty acknowledgements are sent on ordinary native polls.
// Do not wait for the casting lock while describe/start is
// running: the native HTTP timeout is intentionally short.
if let Some(id) = views["stopRecording"].as_str().filter(|id|!id.is_empty()) {
self.casting.lock().unwrap().stop_recording_id(id);
}
if let Some(id)=views["framingClosed"].as_str().filter(|id|!id.is_empty()) {
self.casting.lock().unwrap().region.acknowledge_editor_closed(id);
}
if views["views"].is_object() {
self.core("host.ui.visibility",views["views"].clone())?;
if let Some(ids)=views["closedCasts"].as_array(){ensure!(ids.len()<=32,"Too many closed casts");for id in ids{if let Some(id)=id.as_str(){let _=self.receivers.lock().unwrap().control(id,"stop",json!({}));}}}
@@ -394,7 +447,7 @@ impl Agent {
.unwrap_or(json!({}));
return send_json(
r,
json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"]}),
{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/") {
@@ -410,8 +463,8 @@ impl Agent {
ensure!(!p.is_null(), "Unknown plugin");
let m: Manifest = serde_json::from_value(p["manifest"].clone())?;
ensure!(m.files.contains_key(file), "Unlisted asset");
let root = self
.state
let store = status["database"]["pluginStorage"].as_str().map(std::path::PathBuf::from).unwrap_or_else(|| self.state.clone());
let root = store
.join("plugins")
.join(id)
.join("versions")
@@ -510,6 +563,15 @@ impl Agent {
}
if let Some(rel) = path.strip_prefix("/assets/") {
safe_path(rel)?;
if guest {
let mime = if path.ends_with(".js") { "text/javascript" } else { "text/css" };
r.respond(Response::from_data(fs::read(self.assets.join(&path[1..]))?)
.with_header(header("Content-Type", mime))
.with_header(header("X-Content-Type-Options", "nosniff"))
.with_header(header("Cache-Control", "no-store"))
.with_header(header("Referrer-Policy", "no-referrer")))?;
return Ok(());
}
return send_file(r, &self.assets.join(&path[1..]), true);
}
if let Some(key) = path.strip_prefix("/boot/") {
@@ -537,7 +599,7 @@ impl Agent {
}
ensure!(
if remote {
self.network_authorized(&r)
guest || self.network_authorized(&r)
} else {
self.authorized(&r)
},
@@ -790,11 +852,20 @@ impl Agent {
);
}
if path == "/cast/whep" || path.starts_with("/cast/whep/") {
// Serialize guest creation/registration with revocation: disabling
// cannot miss a WHEP session that is still being created upstream.
let mut grant = if guest { Some(self.watch_access.lock().unwrap()) } else { None };
if let Some(access) = grant.as_ref() {
if !watch_cookie.as_ref().is_some_and(|token| access.accepts(token)) { return Self::watch_expired(r); }
}
ensure!(matches!(r.method(), Method::Post | Method::Patch | Method::Delete | Method::Options), "Invalid WHEP method");
let origin = r.headers().iter().find(|h| h.field.equiv("Origin")).map(|h|h.value.as_str());
ensure!(origin == Some(expected_origin.as_str()), "Cross-origin playback request denied");
if r.method()==&Method::Options {r.respond(Response::empty(StatusCode(204)).with_header(header("Accept-Post","application/sdp")))?;return Ok(());}
let suffix = path.strip_prefix("/cast/whep").unwrap();
if let Some(access) = grant.as_ref() {
if !suffix.is_empty() && !access.sessions.contains_key(&path) { return Self::watch_expired(r); }
}
let mut body = Vec::new();
r.as_reader().take(65537).read_to_end(&mut body)?;
ensure!(body.len() <= 65536, "SDP too large");
@@ -818,7 +889,7 @@ impl Agent {
}
let response = match request.send_bytes(&body) { Ok(v) => v, Err(ureq::Error::Status(_,v)) => v, Err(e) => return Err(e.into()) };
let code = response.status();
let mut headers = vec![header("Cache-Control", "no-store")];
let mut headers = vec![header("Cache-Control", "no-store"), header("Referrer-Policy", "no-referrer")];
for name in ["Content-Type", "ETag", "Accept-Patch"] {
if let Some(value) = response.header(name) { headers.push(header(name,value)); }
}
@@ -827,6 +898,14 @@ impl Agent {
let prefix=format!("/{media_path}/whep/");
ensure!(parsed.path().starts_with(&prefix), "Invalid media session location");
headers.push(header("Location", &format!("/cast/whep/{codec}/{}",parsed.path().strip_prefix(&prefix).unwrap())));
if (200..300).contains(&code) {
if let Some(access) = grant.as_mut() {
access.sessions.insert(format!("/cast/whep/{codec}/{}", parsed.path().strip_prefix(&prefix).unwrap()), parsed.to_string());
}
}
}
if r.method() == &Method::Delete && matches!(code, 200..=299 | 404 | 410) {
if let Some(access) = grant.as_mut() { access.sessions.remove(&path); }
}
let mut bytes = Vec::new(); response.into_reader().take(65537).read_to_end(&mut bytes)?;
ensure!(bytes.len() <= 65536, "Media response too large");
@@ -852,6 +931,10 @@ impl Agent {
if remote && request["method"] == "ui.visibility.get" {
request["params"] = json!({"view":""});
}
ensure!(
!remote || !request["method"].as_str().is_some_and(|m| m.starts_with("apk.permission.")),
"Headset permission window is local only"
);
let result = if remote && request["method"] == "agreement.decline" {
Ok(json!({"closed":false}))
} else {
@@ -865,7 +948,9 @@ impl Agent {
}
if path == "/"
|| path == "/cast/watch"
|| path == "/cast/framing"
|| path.starts_with("/cast/window/")
|| path == "/apk/permission"
|| path == "/manager"
|| path == "/notifications"
|| path == "/launcher"
@@ -883,7 +968,8 @@ impl Agent {
} else {
data
};
let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
let data = if guest { String::from_utf8(data)?.replacen("<head>", "<head><meta name=\"framely-cast-guest\" content=\"true\">", 1).into_bytes() } else { data };
let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Cache-Control","no-store")).with_header(header("Referrer-Policy","no-referrer")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
r.respond(response)?;
return Ok(());
}
@@ -892,6 +978,10 @@ impl Agent {
}
fn maintain_cast(&self) {
self.casting.lock().unwrap().maintain();
// Retry failed disconnects, without interrupting authenticated viewers.
if let Ok(mut access) = self.watch_access.try_lock() {
if !access.revoked.is_empty() { access.disconnect_revoked(); }
}
let events=self.receivers.lock().unwrap().poll();
for event in events {
let session=&event["session"];
@@ -939,20 +1029,124 @@ impl Agent {
match method {
"cast.status" => {
let mut value = self.casting.lock().unwrap().status();
value["recordingRegion"]=self.casting.lock().unwrap().region_status();
let access = self.watch_access.lock().unwrap();
let watch_path = access.path();
value["watchAccessEnabled"] = json!(access.token.is_some());
value["watchPath"] = json!(watch_path);
let receivers = self.receivers.lock().unwrap();
value["sessions"] = json!(receivers.sessions.values().collect::<Vec<_>>());
value["receiverError"] = json!(receivers.error);
let network=self.network_status.lock().unwrap();
value["watchAddresses"]=if network["enabled"]==true{json!(network_ipv4_addresses().into_iter().map(|ip|format!("http://{ip}:{}/cast/watch",network["port"])).collect::<Vec<_>>())}else{json!([])};
value["watchAddresses"]=if network["enabled"]==true{json!(network_ipv4_addresses().into_iter().map(|ip|format!("http://{ip}:{}{watch_path}",network["port"])).collect::<Vec<_>>())}else{json!([])};
Ok(value)
},
"cast.watchAccess.save" => {
let mut access = self.watch_access.lock().unwrap();
let result = self.core(method, p)?;
access.replace(result["token"].as_str().map(str::to_owned));
access.disconnect_revoked();
Ok(json!({"enabled":access.token.is_some()}))
},
"cast.watchAccess.get" => anyhow::bail!("Internal watch access operation"),
"cast.settings.save" => {
let settings: crate::cast::settings::Settings = serde_json::from_value(p.clone())?;
settings.validate()?;
let cast = self.casting.lock().unwrap();
ensure!(!cast.region.editing, "请先关闭画幅调整窗口");
cast.validate_recording_settings(&settings)?;
let result = self.core(method,p)?;
drop(cast);
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())?;
ensure!(!enabled || crate::cast::region::supported(&settings),"录制区域显示仅支持屏幕采集的单眼画面");
let previous=settings.clone();
settings.recording_region_visible=enabled;
let mut cast=self.casting.lock().unwrap();
// Resolve geometry before persisting an enabled switch.
cast.refresh_region(&settings)?;
if let Err(error)=self.core("cast.region.visible.save",json!({"enabled":enabled})) {let _=cast.refresh_region(&previous);return Err(error);}
Ok(json!({"enabled":enabled}))
},
"cast.framing.open" => {
let settings:crate::cast::settings::Settings=serde_json::from_value(self.core("status",json!({}))?["database"]["casting"].clone())?;
ensure!(crate::cast::region::supported(&settings),"画幅调整仅支持屏幕采集的单眼画面");
let mut cast=self.casting.lock().unwrap();
ensure!(cast.recording_status()["active"]!=true,"请先停止录制再调整取景");
ensure!(!cast.region.editing && cast.status()["preview"]!=true,"请先关闭画幅调整窗口");
cast.region.editing=true;
cast.region.editor_id=hex::encode(rand::random::<[u8;16]>());
if let Err(error)=cast.refresh_region(&settings) {cast.region.editing=false;return Err(error);}
let geometry=match cast.region.editor() {
Ok(geometry)=>geometry,
Err(error)=>{cast.region.editing=false;return Err(error);}
};
self.commands.lock().unwrap().push_back(json!({"kind":"cast.framing.open","geometry":geometry}));
Ok(geometry)
},
"cast.framing.get" => {
let cast=self.casting.lock().unwrap();ensure!(cast.region.editing,"画幅调整窗口已关闭");cast.region.editor()
},
"cast.framing.reference" => {
let cast=self.casting.lock().unwrap();
ensure!(cast.region.editing && p["id"]==cast.region.editor_id,"画幅调整窗口已关闭");
let cx=p["centerX"].as_f64().context("Invalid reference center")?;
let cy=p["centerY"].as_f64().context("Invalid reference center")?;
ensure!([cx,cy].into_iter().all(|v|v.is_finite()&&(-60.0..=60.0).contains(&v)),"Invalid reference center");
// Presentation-only depth probe; never persist a draft crop.
self.commands.lock().unwrap().push_back(json!({"kind":"cast.framing.reference","id":cast.region.editor_id,"centerTanX":cx.to_radians().tan(),"centerTanY":cy.to_radians().tan()}));
Ok(json!(true))
},
"cast.framing.cancel" => {
let mut cast=self.casting.lock().unwrap();
ensure!(p["id"]==cast.region.editor_id,"画幅调整窗口已关闭");
cast.region.editing=false;
self.commands.lock().unwrap().push_back(json!({"kind":"cast.framing.close","id":cast.region.editor_id}));Ok(json!(true))
},
"cast.framing.apply" => {
let mut settings:crate::cast::settings::Settings=serde_json::from_value(self.core("status",json!({}))?["database"]["casting"].clone())?;
let mut cast=self.casting.lock().unwrap();ensure!(cast.region.editing,"画幅调整窗口已关闭");
ensure!(p["id"]==cast.region.editor_id,"画幅调整窗口已关闭");
ensure!(cast.recording_status()["active"]!=true,"请先停止录制再调整取景");
settings.width=p["width"].as_u64().and_then(|v|u32::try_from(v).ok()).context("Invalid width")?;
settings.height=p["height"].as_u64().and_then(|v|u32::try_from(v).ok()).context("Invalid height")?;
settings.horizontal_fov=Some(p["horizontalFov"].as_f64().context("Invalid FOV")?);
settings.center_x=p["centerX"].as_f64().context("Invalid center")?;
settings.center_y=p["centerY"].as_f64().context("Invalid center")?;
settings.validate()?;
let mut candidate=crate::cast::region::Region::default();
candidate.editing=true;
candidate.refresh(&settings,&cast.binaries,None)?;
ensure!(p["expectedEye"]==candidate.editor()?["view"]["eye"],"录制眼已改变,请重新打开画幅调整");
crate::cast::region::Region::require_visible(&candidate.editor()?["view"])?;
// Opening the editor does not pause streaming. Check the live
// state now rather than replaying a stale state from opening.
let running=cast.status()["running"]==true;
self.core("cast.settings.save",serde_json::to_value(&settings)?)?;
cast.region.editing=false;
// Close before starting workers, which can take several polls.
// The persistent framingEditor state also retries lost closes.
self.commands.lock().unwrap().push_back(json!({"kind":"cast.framing.close","id":cast.region.editor_id}));
if running {
cast.stop();
let restart=(|| -> Result<()> {
let panel=self.core("cast.panel.start",json!({}))?;
let socket=PathBuf::from(panel["socket"].as_str().context("Missing panel socket")?);
cast.start(&settings,Some(&socket))?;
Ok(())
})();
restart.context("取景已保存,但重新启动串流失败")?;
}
// Saved settings and a closed editor are committed even if the
// optional passive guide cannot refresh; never revive a stale UI.
cast.refresh_region(&settings).context("取景已保存,但录制区域显示刷新失败")?;
Ok(json!(true))
},
"cast.devices" => self.receivers.lock().unwrap().devices(),
"cast.send" => {
let network=self.network_status.lock().unwrap().clone();ensure!(network["enabled"]==true,"请先开启网络管理面板,以便设备获取投屏画面");
@@ -1006,7 +1200,18 @@ impl Agent {
} else { None };
self.casting.lock().unwrap().start(&settings, panel.as_deref())
}
"cast.stop" => Ok(self.casting.lock().unwrap().stop()),
"cast.stop" => self.casting.lock().unwrap().stop_streaming(),
"cast.record.start" => {
let mut cast = self.casting.lock().unwrap();
let status = self.core("status", json!({}))?;
let settings: crate::cast::settings::Settings = serde_json::from_value(status["database"]["casting"].clone())?;
let panel = if settings.source == crate::cast::settings::Source::Screen {
let response = self.core("cast.panel.start", json!({}))?;
Some(PathBuf::from(response["socket"].as_str().context("Missing panel socket")?))
} else {None};
cast.record(&settings, panel.as_deref(), &crate::steam::home()?.join("Videos/framely"))
},
"cast.record.stop" => self.casting.lock().unwrap().stop_recording(),
"launcher.search.index" => {
let names: Vec<String> = serde_json::from_value(p["names"].clone())?;
ensure!(
@@ -1035,6 +1240,8 @@ impl Agent {
self.launcher_search.lock().unwrap().batch(&path, &names)
}
"apk.upload.start" => {
let _gate = APK_STORAGE_API_GATE.lock().unwrap();
ensure!(!self.jobs.active_kind("apk:storage"), "APK 存储正在迁移");
let size = p["size"].as_u64().context("Missing APK upload size")?;
let root = crate::apk::upload_dir(&crate::steam::home()?, size)?;
self.jobs.uploads.start_in(size, &root)
@@ -1069,7 +1276,38 @@ impl Agent {
&crate::steam::home()?,
p["ticket"].as_str().context("Missing APK review")?,
),
"apk.q2f.status" => crate::apk::q2f_status(&crate::steam::home()?),
"apk.q2f.start" => {
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(()) }, &proxy)
})
}
"apk.cleanup.list" => crate::apk::cleanup_list(&crate::steam::home()?),
"apk.storage.status" => crate::apk::storage_status(&crate::steam::home()?),
"apk.permission.pending" => Ok(json!(crate::apk::permissions::pending(&crate::steam::home()?)?)),
"apk.permission.frame" => crate::apk::permissions::frame(
&crate::steam::home()?, p["id"].as_str().context("Missing permission request")?
),
"apk.permission.click" => crate::apk::permissions::click(
&crate::steam::home()?, p["id"].as_str().context("Missing permission request")?,
p["x"].as_u64().context("Invalid Android input position")?.try_into()?,
p["y"].as_u64().context("Invalid Android input position")?.try_into()?,
),
"apk.storage.start" => {
let _gate = APK_STORAGE_API_GATE.lock().unwrap();
ensure!(!self.jobs.uploads.active() && !self.jobs.active_kind("apk:storage") && !self.jobs.active_kind("apk:operation"), "请等待 APK 上传或操作完成");
let home = crate::steam::home()?;
let kind = p["kind"].as_str().context("缺少存储类型")?.to_owned();
let path = std::path::PathBuf::from(p["path"].as_str().context("缺少目标目录")?);
self.jobs.task_progress("apk:storage", move |cancel, progress| {
crate::apk::migrate_storage(&home, &kind, &path, cancel, progress)
})
}
"apk.logs" => crate::apk::logs(
&crate::steam::home()?,
p["app"].as_str().context("Missing APK application")?,
@@ -1404,7 +1642,7 @@ impl Agent {
view.len() <= 160
&& (matches!(
view,
"menu" | "launcher" | "framely.manager" | "notifications"
"menu" | "launcher" | "framely.manager" | "notifications" | "framely.framing"
) || view.starts_with("framely.window.") || view.starts_with("framely.cast.")),
"Invalid haptic view"
);
@@ -1437,6 +1675,9 @@ impl Agent {
Ok(json!(true))
}
"status"
| "storage.plugin.status"
| "storage.plugin.start"
| "storage.plugin.job"
| "inspect"
| "install"
| "plugin.restart"
@@ -1513,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,
@@ -1551,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,
@@ -1702,6 +1949,9 @@ fn fetch_catalogs_progress(
fn send_json(r: Request, v: Value) -> Result<()> {
r.respond(
Response::from_string(serde_json::to_string(&v)?)
// The native poller reads a length-delimited JSON body, not HTTP
// chunks. Geometry metadata can exceed tiny_http's 32 KiB default.
.with_chunked_threshold(usize::MAX)
.with_header(header("Content-Type", "application/json"))
.with_header(header("Cache-Control", "no-store")),
)?;
@@ -1857,7 +2107,12 @@ pub fn serve(
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))?;
let agent = Arc::new(Agent {
watch_access: Mutex::new(crate::cast::watch_access::Access {
token: ipc::call(&socket, "cast.watchAccess.get", json!({}))?["token"].as_str().map(str::to_owned),
..Default::default()
}),
casting: Mutex::new(crate::cast::runtime::Casting::new(runtime.join("cast"), crate::cast::runtime::binary_directory(&assets))),
recording_overlay_cache: Mutex::new(json!({"active":false})),
receivers: Mutex::new(crate::cast::receiver::Receivers::new(runtime.join("receive"), crate::cast::runtime::binary_directory(&assets))),
socket,
state,
@@ -1879,11 +2134,39 @@ pub fn serve(
let cast_agent=agent.clone();
std::thread::spawn(move || { let mut configured=std::time::Instant::now()-Duration::from_secs(10);loop {
if configured.elapsed()>Duration::from_secs(2) {
if let Ok(status)=cast_agent.core("status",json!({})) {if status["agreement"]["accepted"]==true {if let Ok(settings)=serde_json::from_value(status["database"]["casting"].clone()){let _=cast_agent.receivers.lock().unwrap().configure(&settings);}}}
if let Ok(status)=cast_agent.core("status",json!({})) {if status["agreement"]["accepted"]==true {if let Ok(settings)=serde_json::from_value(status["database"]["casting"].clone()){let _=cast_agent.casting.lock().unwrap().refresh_region(&settings);let _=cast_agent.receivers.lock().unwrap().configure(&settings);}}}
configured=std::time::Instant::now();
}
cast_agent.maintain_cast();std::thread::sleep(Duration::from_millis(100));
}});
let permission_agent = agent.clone();
std::thread::spawn(move || {
let mut shown = String::new();
let mut missing = 0u8;
loop {
if let Ok(home) = crate::steam::home() {
if let Ok(pending) = crate::apk::permissions::pending(&home) {
let next = pending.as_ref().map(|p| p.id.clone()).unwrap_or_default();
if next.is_empty() && !shown.is_empty() && missing < 1 {
missing += 1;
std::thread::sleep(Duration::from_secs(2));
continue;
}
missing = 0;
if next != shown {
if !shown.is_empty() {
push(&permission_agent.commands, json!({"kind":"apk.permission.close"}));
}
if let Some(request) = pending {
push(&permission_agent.commands, json!({"kind":"apk.permission.open","id":request.id}));
}
shown = next;
}
}
}
std::thread::sleep(Duration::from_secs(2));
}
});
let file_agent = agent.clone();
std::thread::spawn(move || loop {
if let Ok(home) = crate::steam::home() {
@@ -2362,7 +2645,9 @@ mod tests {
fn agent(server: &Server, root: &Path) -> Arc<Agent> {
Arc::new(Agent {
watch_access: Mutex::default(),
casting: Mutex::new(crate::cast::runtime::Casting::new(root.join("cast"), root.join("media"))),
recording_overlay_cache: Mutex::new(json!({"active":false})),
receivers: Mutex::new(crate::cast::receiver::Receivers::new(root.join("receive"), root.join("media"))),
socket: root.join("control.sock"),
state: root.join("state"),
@@ -2398,6 +2683,62 @@ mod tests {
})
}
#[test]
fn framing_apply_restarts_live_stream_and_closes_even_when_restart_fails() {
use std::os::unix::fs::PermissionsExt;
for running in [false,true] {
let root=tempfile::tempdir().unwrap();
let server=Server::http("127.0.0.1:0").unwrap();
let agent=agent(&server,root.path());
let mut core=crate::tests::accepted_service(&agent.state,1000).unwrap();
let mut settings=crate::cast::settings::Settings::default();
settings.eye=crate::cast::settings::Eye::Left;
core.handle("cast.settings.save",serde_json::to_value(&settings).unwrap()).unwrap();
fs::create_dir(root.path().join("media")).unwrap();
let view=json!({"projection":"factory-panel-v1","eye":0,"regionFullyVisible":true,"editorReferenceTan":1.7,"visibility":{"size":8,"extent":1.7,"cells":"f".repeat(16)},"tanHalfHorizontal":0.5,"aspect":4./3.,"centerTanX":0.,"centerTanY":0.,"captureToHead":[[1,0,0,0],[0,1,0,0],[0,0,1,0]]});
let capture=root.path().join("media/framely-capture");
fs::write(&capture,format!("#!/bin/sh\nprintf '%s' '{}'\n",view)).unwrap();
fs::set_permissions(&capture,fs::Permissions::from_mode(0o700)).unwrap();
{
let mut cast=agent.casting.lock().unwrap();
if running {cast.test_whep_server(1);}
cast.region.editor_id="apply-test".into();cast.region.editing=true;
}
let listener=UnixListener::bind(&agent.socket).unwrap();
listener.set_nonblocking(true).unwrap();
let stop=Arc::new(AtomicBool::new(false));let core_stop=stop.clone();
let panel_calls=Arc::new(std::sync::atomic::AtomicUsize::new(0));let calls=panel_calls.clone();
let worker=std::thread::spawn(move || {
while !core_stop.load(Ordering::Relaxed) {
if let Ok((mut stream,_))=listener.accept() {
let request=ipc::read(&mut stream).unwrap();
let method=request["method"].as_str().unwrap();
let result=if method=="cast.panel.start" {
calls.fetch_add(1,Ordering::Relaxed);
Err(anyhow::anyhow!("test restart failure"))
} else {core.handle(method,request["params"].clone())};
ipc::write(&mut stream,&ipc::response(result)).unwrap();
} else {std::thread::sleep(Duration::from_millis(5));}
}
Ok::<_,anyhow::Error>(())
});
assert!(agent.api(json!({"method":"cast.framing.reference","params":{"id":"old-test","centerX":0.,"centerY":0.}})).is_err());
assert!(agent.api(json!({"method":"cast.framing.reference","params":{"id":"apply-test","centerX":61.,"centerY":0.}})).is_err());
agent.api(json!({"method":"cast.framing.reference","params":{"id":"apply-test","centerX":4.5,"centerY":-3.}})).unwrap();
let reference=agent.commands.lock().unwrap().back().unwrap().clone();
assert_eq!(reference["kind"],"cast.framing.reference");
assert!((reference["centerTanX"].as_f64().unwrap()-4.5_f64.to_radians().tan()).abs()<1e-12);
assert_eq!(agent.core("status",json!({})).unwrap()["database"]["casting"]["centerX"],0.);
let result=agent.api(json!({"method":"cast.framing.apply","params":{"id":"apply-test","expectedEye":0,"width":1280,"height":960,"horizontalFov":60.,"centerX":0.,"centerY":0.}}));
assert_eq!(result.is_err(),running);
if let Err(error)=result {assert!(format!("{error:#}").contains("取景已保存,但重新启动串流失败"));}
assert_eq!(panel_calls.load(Ordering::Relaxed),usize::from(running));
assert_eq!(agent.core("status",json!({})).unwrap()["database"]["casting"]["width"],1280);
assert_eq!(agent.casting.lock().unwrap().region.editor_state(),json!({"id":"apply-test","open":false}));
assert_eq!(agent.commands.lock().unwrap().back().unwrap(),&json!({"kind":"cast.framing.close","id":"apply-test"}));
stop.store(true,Ordering::Relaxed);worker.join().unwrap().unwrap();
}
}
#[test]
fn calibration_preview_allows_unvalidated_error_but_rejects_invalid_geometry() {
let root = tempfile::tempdir().unwrap();
let server = Server::http("127.0.0.1:0").unwrap();
@@ -2811,6 +3152,126 @@ mod tests {
thread.join().unwrap();
}
#[test]
fn watch_access_limits_http_routes_and_revokes_only_guest_whep() {
let root = tempfile::tempdir().unwrap();
let server = Server::http("127.0.0.1:0").unwrap();
let agent = agent(&server, root.path());
let mut core = crate::tests::accepted_service(&agent.state, unsafe { libc::geteuid() }).unwrap();
// Authentication is exercised through existing manager cookies, not a
// password hash, so no password derivation is needed in this fixture.
fs::write(agent.state.join("network-password.json"), b"{}").unwrap();
core.db.network_panel.password_enabled = true;
let socket = UnixListener::bind(&agent.socket).unwrap();
socket.set_nonblocking(true).unwrap();
let stop = Arc::new(AtomicBool::new(false));
let core_stop = stop.clone();
let core_worker = std::thread::spawn(move || {
while !core_stop.load(Ordering::Relaxed) {
if let Ok((mut stream,_)) = socket.accept() {
let request = ipc::read(&mut stream).unwrap();
let response = ipc::response(core.handle(request["method"].as_str().unwrap(), request["params"].clone()));
ipc::write(&mut stream, &response).unwrap();
} else { std::thread::sleep(Duration::from_millis(5)); }
}
});
let worker_agent = agent.clone();
let worker_stop = stop.clone();
let worker = std::thread::spawn(move || {
while !worker_stop.load(Ordering::Relaxed) {
if let Ok(Some(r)) = server.recv_timeout(Duration::from_millis(50)) {
let _ = worker_agent.handle_http(r, true);
}
}
});
let media = Server::http("127.0.0.1:0").unwrap();
agent.casting.lock().unwrap().test_whep_server(media.server_addr().to_ip().unwrap().port());
let media_stop = stop.clone();
let deleted = Arc::new(Mutex::new(Vec::new()));
let media_deleted = deleted.clone();
let (creating_tx, creating_rx) = std::sync::mpsc::channel();
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
let media_worker = std::thread::spawn(move || {
let mut number = 0;
while !media_stop.load(Ordering::Relaxed) {
if let Ok(Some(r)) = media.recv_timeout(Duration::from_millis(50)) {
if r.method() == &Method::Post {
number += 1;
if number == 3 {
creating_tx.send(()).unwrap();
finish_rx.recv_timeout(Duration::from_secs(3)).unwrap();
}
r.respond(Response::from_string("answer").with_status_code(201)
.with_header(header("Location", &format!("/headset/whep/00000000-0000-0000-0000-{number:012}")))).unwrap();
} else if r.method() == &Method::Delete {
media_deleted.lock().unwrap().push(r.url().to_owned());
r.respond(Response::empty(StatusCode(204))).unwrap();
} else { r.respond(Response::empty(StatusCode(400))).unwrap(); }
}
}
});
agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":true}})).unwrap();
let token = agent.watch_access.lock().unwrap().token.clone().unwrap();
let client = ureq::AgentBuilder::new().redirects(0).timeout(Duration::from_secs(3)).build();
let origin = &agent.origin;
let grant = client.get(&format!("{origin}/cast/watch?token={token}")).call().unwrap();
assert_eq!(grant.status(), 303);
assert_eq!(grant.header("Location"), Some("/cast/watch"));
assert_eq!(grant.header("Referrer-Policy"), Some("no-referrer"));
let cookie_header = grant.header("Set-Cookie").unwrap();
assert!(cookie_header.contains("HttpOnly") && cookie_header.contains("SameSite=Strict"));
let cookie = cookie_header.split(';').next().unwrap().to_owned();
fs::remove_file(agent.state.join("network-password.json")).unwrap();
let watch = client.get(&format!("{origin}/cast/watch")).set("Cookie", &cookie).call().unwrap();
assert_eq!(watch.header("Cache-Control"), Some("no-store"));
assert!(watch.into_string().unwrap().contains("framely-cast-guest"));
for asset in ["app.js", "app.css"] {
assert!(client.get(&format!("{origin}/assets/{asset}")).set("Cookie", &cookie).call().is_ok());
}
fs::write(agent.state.join("network-password.json"), b"{}").unwrap();
let manager = client.get(&format!("{origin}/manager")).set("Cookie", &cookie).call().unwrap().into_string().unwrap();
assert!(manager.contains("action=\"/login\""));
for path in ["/api", "/manager-api/files", "/api/upload/a/0", "/host/poll"] {
assert!(client.post(&format!("{origin}{path}")).set("Cookie", &cookie).set("Origin", origin).send_json(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).is_err());
}
assert!(client.get(&format!("{origin}/cast/watch?token=invalid")).call().is_err());
assert!(client.post(&format!("{origin}/cast/whep")).set("Cookie", &cookie).set("Origin", "http://evil.test").send_string("a=rtpmap:96 H264/90000").is_err());
let guest = client.post(&format!("{origin}/cast/whep")).set("Cookie", &cookie).set("Origin", origin).send_string("a=rtpmap:96 H264/90000").unwrap();
assert_eq!(guest.status(), 201);
let guest_resource = guest.header("Location").unwrap().to_owned();
let manager_cookie = format!("framely-network={}", agent.network_key.lock().unwrap());
let logged_in = client.post(&format!("{origin}/cast/whep")).set("Cookie", &manager_cookie).set("Origin", origin).send_string("a=rtpmap:96 H264/90000").unwrap();
assert_eq!(logged_in.status(), 201);
assert_eq!(agent.watch_access.lock().unwrap().sessions.len(), 1);
agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).unwrap();
assert_eq!(deleted.lock().unwrap().as_slice(), &["/headset/whep/00000000-0000-0000-0000-000000000001"]);
assert!(client.get(&format!("{origin}/cast/access")).set("Cookie", &cookie).call().is_err());
assert!(client.get(&format!("{origin}/cast/watch?token={token}")).call().is_err());
assert!(client.delete(&format!("{origin}{guest_resource}")).set("Cookie", &cookie).set("Origin", origin).call().is_err());
assert_eq!(client.get(&format!("{origin}/cast/access")).set("Cookie", &manager_cookie).call().unwrap().status(), 204);
// Revoke while the upstream WHEP POST is in flight. Disabling must
// wait for registration and then delete that new session as well.
agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":true}})).unwrap();
let new_token = agent.watch_access.lock().unwrap().token.clone().unwrap();
assert_ne!(new_token, token);
let race_origin = origin.clone();
let creating = std::thread::spawn(move || {
ureq::post(&format!("{race_origin}/cast/whep")).set("Cookie", &format!("framely-watch={new_token}"))
.set("Origin", &race_origin).send_string("a=rtpmap:96 H264/90000").unwrap().status()
});
creating_rx.recv_timeout(Duration::from_secs(3)).unwrap();
assert!(agent.watch_access.try_lock().is_err());
let disabling_agent = agent.clone();
let disabling = std::thread::spawn(move || disabling_agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).unwrap());
finish_tx.send(()).unwrap();
assert_eq!(creating.join().unwrap(), 201);
disabling.join().unwrap();
assert_eq!(deleted.lock().unwrap().len(), 2);
assert!(deleted.lock().unwrap()[1].ends_with("000000000003"));
stop.store(true, Ordering::Relaxed);
worker.join().unwrap(); core_worker.join().unwrap(); media_worker.join().unwrap();
}
#[test]
fn network_panel_requires_login_and_same_origin_and_blocks_native_routes() {
let password = "密码".repeat(70);
@@ -3066,10 +3527,22 @@ mod tests {
let root = tempfile::tempdir().unwrap();
let server = Server::http("127.0.0.1:0").unwrap();
let agent = agent(&server, root.path());
// The native host parses the raw HTTP body as JSON. Exercise a
// framing command larger than tiny_http's default chunk threshold.
let framing=json!({"kind":"cast.framing.open","geometry":{"visibility":"f".repeat(48000)}});
agent.commands.lock().unwrap().push_back(framing.clone());
agent.casting.lock().unwrap().region.editor_id="closed-test".into();
let listener = std::os::unix::net::UnixListener::bind(&agent.socket).unwrap();
let socket_worker = std::thread::spawn(move || {
let mut core = crate::service::Service::load(&root.path().join("state"), 1000).unwrap();
for expected in [
"host.ui.visibility",
"host.notification.badge",
"status",
"launcher.gaze.calibration.get",
"host.notification.badge",
"status",
"launcher.gaze.calibration.get",
"host.ui.visibility",
"host.notification.badge",
"status",
@@ -3101,15 +3574,30 @@ mod tests {
.set("Cookie", &format!("framely={}", agent.web_key))
.send_json(json!({"menu":true}))
.is_err());
let poll: Value = ureq::post(&url)
let response = ureq::post(&url)
.set("X-Framely-Native", &agent.native_key)
.send_json(json!({"menu":true}))
.unwrap()
.into_json()
.unwrap();
assert!(response.header("Transfer-Encoding").is_none());
assert!(response.header("Content-Length").unwrap().parse::<usize>().unwrap()>48000);
let poll:Value=response.into_json().unwrap();
assert_eq!(poll["commands"],json!([framing]));
assert_eq!(poll["feedback"], json!({"haptics":true,"sounds":false}));
assert!(poll.get("gazeCalibration").is_some());
assert!(poll["launcher"].is_object());
assert_eq!(poll["framingEditor"],json!({"id":"closed-test","open":false}));
let retry:Value=ureq::get(&url).set("X-Framely-Native",&agent.native_key).call().unwrap().into_json().unwrap();
assert_eq!(retry["commands"],json!([]));
assert_eq!(retry["framingEditor"],poll["framingEditor"]);
// The real native host includes empty acknowledgement fields on every
// poll. An applying editor may own this lock for several seconds.
let cast=agent.casting.lock().unwrap();
let busy:Value=ureq::post(&url).timeout(Duration::from_secs(1))
.set("X-Framely-Native",&agent.native_key)
.send_json(json!({"views":{"menu":true},"stopRecording":"","framingClosed":""}))
.unwrap().into_json().unwrap();
assert!(busy["framingEditor"].is_null());
drop(cast);
assert!(agent
.api(json!({"method":"host.ui.visibility","params":{"menu":false}}))
.is_err());
+460
View File
@@ -0,0 +1,460 @@
use anyhow::{ensure, Context, Result};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, ffi::CString, fs, io::Read, os::unix::fs::{MetadataExt, FileTypeExt, symlink, PermissionsExt}, path::{Path, PathBuf}, process::Command};
/// Disk blocks used by a tree. The root may be an intentional storage link
/// (the default plugin directories are links); nested links and mounts are skipped.
pub fn disk_bytes(path: &Path) -> Result<(u64, bool)> {
let root = match fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, false)),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
Err(error) => return Err(error.into()),
};
let path = if root.file_type().is_symlink() {
match path.canonicalize() {
Ok(path) => path,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, true)),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
Err(error) => return Err(error.into()),
}
} else { path.to_path_buf() };
let root = fs::symlink_metadata(&path)?;
let mut seen = std::collections::HashSet::new();
let mut total = 0u64;
let mut incomplete = false;
fn walk(path: &Path, dev: u64, seen: &mut std::collections::HashSet<(u64, u64)>, total: &mut u64, incomplete: &mut bool) -> Result<()> {
let meta = match fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
Err(error) => return Err(error.into()),
};
if meta.dev() != dev || !seen.insert((meta.dev(), meta.ino())) { return Ok(()); }
*total = total.saturating_add(meta.blocks().saturating_mul(512));
if meta.is_dir() && !meta.file_type().is_symlink() {
let entries = match fs::read_dir(path) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
Err(error) => return Err(error.into()),
};
for entry in entries {
match entry {
Ok(entry) => walk(&entry.path(), dev, seen, total, incomplete)?,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => *incomplete = true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
Err(error) => return Err(error.into()),
}
}
}
Ok(())
}
walk(&path, root.dev(), &mut seen, &mut total, &mut incomplete)?;
Ok((total, incomplete))
}
pub fn volume(path: &Path) -> Result<Value> {
let device = fs::metadata(path)?.dev();
let path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?;
let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
let result = unsafe { libc::statvfs(path.as_ptr(), stat.as_mut_ptr()) };
if result != 0 { return Err(std::io::Error::last_os_error().into()); }
let stat = unsafe { stat.assume_init() };
let block = stat.f_frsize as u64;
let total = (stat.f_blocks as u64).saturating_mul(block);
let free = (stat.f_bfree as u64).saturating_mul(block);
Ok(serde_json::json!({"total":total,"free":free,"device":device}))
}
pub fn bytes(path: &Path) -> Result<u64> {
let meta = match fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(error.into()),
};
let root = if meta.file_type().is_symlink() { path.canonicalize()? } else { path.to_path_buf() };
let mut total = 0u64;
fn walk(path: &Path, total: &mut u64) -> Result<()> {
let m = fs::symlink_metadata(path)?;
if m.is_file() { *total = total.saturating_add(m.len()); }
if m.is_dir() && !m.file_type().is_symlink() {
for entry in fs::read_dir(path)? { walk(&entry?.path(), total)?; }
}
Ok(())
}
walk(&root, &mut total)?;
Ok(total)
}
/// Legacy logical-size check retained for the migration permission test.
#[cfg(test)]
pub fn display_bytes(path: &Path) -> Result<(u64, bool)> {
let meta = match fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, false)),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
Err(error) => return Err(error.into()),
};
let root = if meta.file_type().is_symlink() {
match path.canonicalize() {
Ok(root) => root,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
Err(error) => return Err(error.into()),
}
} else { path.to_path_buf() };
fn walk(path: &Path, total: &mut u64, incomplete: &mut bool) -> Result<()> {
let meta = match fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
Err(error) => return Err(error.into()),
};
if meta.is_file() { *total = total.saturating_add(meta.len()); }
if meta.is_dir() && !meta.file_type().is_symlink() {
let entries = match fs::read_dir(path) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
Err(error) => return Err(error.into()),
};
for entry in entries { walk(&entry?.path(), total, incomplete)?; }
}
Ok(())
}
let (mut total, mut incomplete) = (0, false);
walk(&root, &mut total, &mut incomplete)?;
Ok((total, incomplete))
}
fn inventory(path: &Path) -> Result<BTreeMap<PathBuf, String>> {
let mut files = BTreeMap::new();
fn walk(root: &Path, rel: &Path, files: &mut BTreeMap<PathBuf, String>) -> Result<()> {
let path = root.join(rel);
let meta = fs::symlink_metadata(&path)?;
let value = if meta.file_type().is_symlink() {
format!("link:{}:{}:{}", fs::read_link(&path)?.display(), meta.uid(), meta.gid())
} else if meta.is_dir() {
for entry in fs::read_dir(&path)? { walk(root, &rel.join(entry?.file_name()), files)?; }
if rel.as_os_str().is_empty() { "directory".to_owned() }
else { format!("directory:{}:{}:{}", meta.uid(), meta.gid(), meta.mode() & 0o7777) }
} else if meta.is_file() {
let mut file = fs::File::open(path)?;
let mut hash = Sha256::new();
let mut buffer = [0u8; 65536];
loop { let n = file.read(&mut buffer)?; if n == 0 { break; } hash.update(&buffer[..n]); }
format!("sha256:{}:{}:{}:{}", hex::encode(hash.finalize()), meta.uid(), meta.gid(), meta.mode() & 0o7777)
} else if meta.file_type().is_char_device() && meta.rdev() == 0 {
"overlay-whiteout".to_owned()
} else { anyhow::bail!("存储中含有无法迁移的文件:{}", path.display()); };
files.insert(rel.to_path_buf(), value);
Ok(())
}
walk(path, Path::new(""), &mut files)?;
Ok(files)
}
pub fn verified_equal(source: &Path, target: &Path) -> Result<bool> {
if !source.is_dir() || !target.is_dir() { return Ok(false); }
let _source_readable = ReadableDirs::prepare(source)?;
let _target_readable = ReadableDirs::prepare(target)?;
Ok(inventory(source)? == inventory(target)?)
}
fn selected_source(source: &Path, name: &str, follow_root_links: bool) -> Result<PathBuf> {
let path = source.join(name);
let meta = match fs::symlink_metadata(&path) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(path),
Err(error) => return Err(error.into()),
};
ensure!(follow_root_links || !meta.file_type().is_symlink(), "存储目录不能是链接:{}", path.display());
let resolved = if meta.file_type().is_symlink() { path.canonicalize()? } else { path };
ensure!(resolved.is_dir(), "存储项目不是目录:{}", resolved.display());
Ok(resolved)
}
pub fn verified_selected(source: &Path, target: &Path, names: &[String], follow_root_links: bool) -> Result<bool> {
if !target.is_dir() { return Ok(false); }
let mut expected = std::collections::BTreeSet::new();
for name in names {
if selected_source(source, name, follow_root_links)?.is_dir() { expected.insert(name.clone()); }
}
let actual: std::collections::BTreeSet<String> = fs::read_dir(target)?.map(|entry| entry.map(|e|e.file_name().to_string_lossy().to_string())).collect::<std::io::Result<_>>()?;
if actual != expected { return Ok(false); }
for name in expected {
if !verified_equal(&selected_source(source, &name, follow_root_links)?, &target.join(name))? { return Ok(false); }
}
Ok(true)
}
pub fn write_journal(path: &Path, value: &Value) -> Result<()> {
let temporary = path.with_extension("tmp");
fs::write(&temporary, serde_json::to_vec(value)?)?;
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
fs::File::open(&temporary)?.sync_all()?;
fs::rename(temporary, path)?;
fs::File::open(path.parent().context("缺少存储状态目录")?)?.sync_all()?;
Ok(())
}
pub fn validate_target(source: &Path, target: &Path, allow_root: bool) -> Result<PathBuf> {
ensure!(target.is_absolute() && target != Path::new("/"), "请选择绝对目录路径");
let parent = target.parent().context("缺少目标目录的上级目录")?.canonicalize()?;
ensure!(parent.is_dir(), "目标目录的上级目录不存在");
let target = parent.join(target.file_name().context("无效目录名称")?);
ensure!(!target.starts_with("/tmp") && !target.starts_with("/proc") && !target.starts_with("/sys") && !target.starts_with("/dev") && !target.starts_with("/run/framely"), "不能在临时目录或系统目录保存数据");
ensure!(allow_root || target.starts_with("/home") || target.starts_with("/run/media"), "请选择用户目录或已挂载存储");
let source = source.canonicalize().unwrap_or_else(|_| source.to_path_buf());
ensure!(source != target && !source.starts_with(&target) && !target.starts_with(&source), "目标目录不能位于原目录内部或包含原目录");
if let Ok(meta) = fs::symlink_metadata(&target) {
ensure!(meta.is_dir() && !meta.file_type().is_symlink(), "目标必须是空目录,不能是链接");
ensure!(fs::read_dir(&target)?.next().is_none(), "目标目录必须为空");
}
Ok(target)
}
fn prepare_space(parent: &Path, needed: u64) -> Result<()> {
let c_path = CString::new(parent.as_os_str().as_encoded_bytes())?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
ensure!(unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } == 0, "无法检查目标存储空间");
let available = stat.f_bavail.saturating_mul(stat.f_frsize);
ensure!(available >= needed.saturating_add(128 * 1024 * 1024), "目标存储空间不足");
let probe = parent.join(format!(".framely-probe-{:016x}", rand::random::<u64>()));
fs::create_dir(&probe)?;
let result = (|| -> Result<()> {
let file = probe.join("file");
fs::write(&file, b"framely")?;
fs::set_permissions(&file, fs::Permissions::from_mode(0o600))?;
ensure!(fs::metadata(&file)?.permissions().mode() & 0o777 == 0o600, "目标文件系统不支持 Linux 文件权限");
fs::hard_link(&file, probe.join("hardlink"))?;
symlink("file", probe.join("symlink"))?;
ensure!(fs::read_link(probe.join("symlink"))? == Path::new("file"), "目标文件系统不支持符号链接");
Ok(())
})();
let _ = fs::remove_dir_all(probe);
result
}
struct ReadableDirs {
root: PathBuf,
changed: Vec<(PathBuf, u32)>,
}
impl ReadableDirs {
fn prepare(root: &Path) -> Result<Self> {
Self::prepare_with(root, 0o500)
}
fn prepare_with(root: &Path, required: u32) -> Result<Self> {
let mut guard = Self { root: root.to_path_buf(), changed: Vec::new() };
if root.exists() { guard.walk(root, required)?; }
Ok(guard)
}
fn walk(&mut self, path: &Path, required: u32) -> Result<()> {
let meta = fs::symlink_metadata(path)?;
if !meta.is_dir() || meta.file_type().is_symlink() { return Ok(()); }
let mode = meta.permissions().mode() & 0o7777;
if mode & required != required {
ensure!(meta.uid() == unsafe { libc::geteuid() } || unsafe { libc::geteuid() } == 0,
"存储目录权限不足,无法迁移:{}", path.display());
fs::set_permissions(path, fs::Permissions::from_mode(mode | required))?;
self.changed.push((path.strip_prefix(&self.root)?.to_path_buf(), mode));
}
for entry in fs::read_dir(path)? { self.walk(&entry?.path(), required)?; }
Ok(())
}
fn restore_copy(&self, copy: &Path) -> Result<()> {
for (relative, mode) in self.changed.iter().rev() {
fs::set_permissions(copy.join(relative), fs::Permissions::from_mode(*mode))?;
}
Ok(())
}
}
impl Drop for ReadableDirs {
fn drop(&mut self) {
for (relative, mode) in self.changed.iter().rev() {
let _ = fs::set_permissions(self.root.join(relative), fs::Permissions::from_mode(*mode));
}
}
}
pub fn remove_tree(path: &Path) -> Result<()> {
let _readable = ReadableDirs::prepare_with(path, 0o700)?;
fs::remove_dir_all(path)?;
Ok(())
}
pub fn copy_verified(source: &Path, target: &Path, mut progress: impl FnMut(&str)) -> Result<()> {
let target = validate_target(source, target, false)?;
let readable = ReadableDirs::prepare(source)?;
let parent = target.parent().unwrap();
prepare_space(parent, bytes(source)?)?;
let stage = parent.join(format!(".framely-storage-{:016x}", rand::random::<u64>()));
fs::create_dir(&stage)?;
let result = (|| -> Result<()> {
progress("copying");
if source.exists() {
let output = Command::new("cp").arg("-a").arg("--reflink=auto")
.arg("--").arg(source.join(".")).arg(&stage).output()?;
ensure!(output.status.success(), "复制存储失败:{}", String::from_utf8_lossy(&output.stderr));
}
progress("verifying");
let source_files = if source.exists() { inventory(source)? } else { BTreeMap::new() };
let target_files = inventory(&stage)?;
ensure!(source_files == target_files || (!source.exists() && target_files.len() == 1), "迁移后的文件校验失败,仍使用原位置");
readable.restore_copy(&stage)?;
progress("switching");
if target.exists() { fs::remove_dir(&target)?; }
fs::rename(&stage, &target)?;
fs::File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() { let _ = fs::remove_dir_all(&stage); }
result
}
pub fn copy_selected(source: &Path, target: &Path, names: &[String], follow_root_links: bool, mut progress: impl FnMut(&str)) -> Result<()> {
let target = validate_target(source, target, false)?;
let parent = target.parent().unwrap();
let mut needed = 0u64;
let mut readable = Vec::new();
for name in names {
let from = selected_source(source, name, follow_root_links)?;
readable.push((name.clone(), ReadableDirs::prepare(&from)?));
needed = needed.saturating_add(bytes(&from)?);
}
prepare_space(parent, needed)?;
let stage = parent.join(format!(".framely-storage-{:016x}", rand::random::<u64>()));
fs::create_dir(&stage)?;
let result = (|| -> Result<()> {
progress("copying");
for name in names {
ensure!(!name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\'), "无效容器名称");
let from = selected_source(source, name, follow_root_links)?;
if !from.exists() { continue; }
let output = Command::new("cp").args(["-a", "--reflink=auto", "--"])
.arg(&from).arg(stage.join(name)).output()?;
ensure!(output.status.success(), "复制容器失败:{}", String::from_utf8_lossy(&output.stderr));
}
progress("verifying");
for name in names {
let from = selected_source(source, name, follow_root_links)?;
if from.exists() { ensure!(inventory(&from)? == inventory(&stage.join(name))?, "容器迁移校验失败:{name}"); }
}
for (name, guard) in &readable { if stage.join(name).exists() { guard.restore_copy(&stage.join(name))?; } }
progress("switching");
if target.exists() { fs::remove_dir(&target)?; }
fs::rename(&stage, &target)?;
fs::File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() { let _ = fs::remove_dir_all(&stage); }
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copy_and_verify_preserve_data_and_reject_unsafe_targets() {
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
let source = temp.path().join("source");
fs::create_dir(&source).unwrap();
fs::write(source.join("saved.apk"), b"apk data").unwrap();
symlink("saved.apk", source.join("current")).unwrap();
let target = temp.path().join("moved");
let mut steps = Vec::new();
copy_verified(&source, &target, |step| steps.push(step.to_owned())).unwrap();
assert_eq!(steps, ["copying", "verifying", "switching"]);
assert_eq!(fs::read(target.join("saved.apk")).unwrap(), b"apk data");
assert_eq!(fs::read_link(target.join("current")).unwrap(), Path::new("saved.apk"));
assert_eq!(fs::read(source.join("saved.apk")).unwrap(), b"apk data");
assert!(validate_target(&source, &source.join("inside"), false).is_err());
assert!(copy_verified(&source, &target, |_| {}).is_err());
assert!(prepare_space(temp.path(), u64::MAX).is_err());
assert_eq!(fs::read(source.join("saved.apk")).unwrap(), b"apk data");
}
#[test]
fn selected_copy_excludes_external_containers() {
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
let source = temp.path().join("contexts");
fs::create_dir(&source).unwrap();
for name in ["managed", "external"] {
fs::create_dir(source.join(name)).unwrap();
fs::write(source.join(name).join("state"), name).unwrap();
}
let work = source.join("managed/app_workdir/work");
fs::create_dir_all(&work).unwrap();
fs::write(work.join("overlay-state"), b"private").unwrap();
fs::set_permissions(&work, fs::Permissions::from_mode(0o000)).unwrap();
let target = temp.path().join("android");
copy_selected(&source, &target, &["managed".into()], false, |_| {}).unwrap();
assert_eq!(fs::read_to_string(target.join("managed/state")).unwrap(), "managed");
assert!(verified_selected(&source, &target, &["managed".into()], false).unwrap());
fs::set_permissions(&work, fs::Permissions::from_mode(0o700)).unwrap();
fs::set_permissions(target.join("managed/app_workdir/work"), fs::Permissions::from_mode(0o700)).unwrap();
assert!(!target.join("external").exists());
assert!(source.join("external/state").is_file());
}
#[test]
fn plugin_roots_copy_real_data_instead_of_storage_links() {
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
let source = temp.path().join("state");
let physical = temp.path().join("persistent-plugins");
fs::create_dir(&source).unwrap();
fs::create_dir(&physical).unwrap();
fs::write(physical.join("manifest.json"), b"plugin").unwrap();
symlink(&physical, source.join("plugins")).unwrap();
let target = temp.path().join("moved");
assert!(copy_selected(&source,&target,&["plugins".into()],false,|_|{}).is_err());
copy_selected(&source,&target,&["plugins".into()],true,|_|{}).unwrap();
assert!(target.join("plugins").is_dir());
assert!(!fs::symlink_metadata(target.join("plugins")).unwrap().file_type().is_symlink());
assert_eq!(fs::read(target.join("plugins/manifest.json")).unwrap(),b"plugin");
assert!(verified_selected(&source,&target,&["plugins".into()],true).unwrap());
}
#[test]
fn display_size_marks_unreadable_overlay_work_without_hiding_migration_error() {
if unsafe { libc::geteuid() } == 0 { return; }
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
fs::write(temp.path().join("visible"), b"saved").unwrap();
let work = temp.path().join("app_workdir/work");
fs::create_dir_all(&work).unwrap();
fs::write(work.join("overlay-state"), b"private").unwrap();
fs::set_permissions(&work, fs::Permissions::from_mode(0o000)).unwrap();
let shown = display_bytes(temp.path());
let strict = bytes(temp.path());
assert_eq!(shown.unwrap(), (5, true));
assert!(strict.is_err());
let target = temp.path().parent().unwrap().join(format!("framely-storage-test-{:016x}", rand::random::<u64>()));
copy_verified(temp.path(), &target, |_| {}).unwrap();
assert_eq!(fs::metadata(&work).unwrap().permissions().mode() & 0o777, 0);
let copied_work = target.join("app_workdir/work");
assert_eq!(fs::metadata(&copied_work).unwrap().permissions().mode() & 0o777, 0);
assert!(verified_equal(temp.path(), &target).unwrap());
fs::set_permissions(&work, fs::Permissions::from_mode(0o700)).unwrap();
fs::set_permissions(&copied_work, fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(fs::read(copied_work.join("overlay-state")).unwrap(), b"private");
fs::remove_dir_all(target).unwrap();
}
#[test]
fn disk_usage_does_not_follow_links_or_count_hardlinks_twice() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("payload");
fs::write(&file, vec![7; 8192]).unwrap();
fs::hard_link(&file, temp.path().join("same-blocks")).unwrap();
symlink(&file, temp.path().join("shortcut")).unwrap();
let (used, incomplete) = disk_bytes(temp.path()).unwrap();
let root_blocks = fs::metadata(temp.path()).unwrap().blocks() * 512;
let file_blocks = fs::metadata(&file).unwrap().blocks() * 512;
let link_blocks = fs::symlink_metadata(temp.path().join("shortcut")).unwrap().blocks() * 512;
assert_eq!(used, root_blocks + file_blocks + link_blocks);
assert!(!incomplete);
}
#[test]
fn disk_usage_follows_storage_root_link_but_not_nested_links() {
let temp = tempfile::tempdir().unwrap();
let target = temp.path().join("real-plugins");
fs::create_dir(&target).unwrap();
fs::write(target.join("plugin"), vec![3; 8192]).unwrap();
let external = temp.path().join("unrelated");
fs::write(&external, vec![4; 8192]).unwrap();
symlink(&external, target.join("nested-link")).unwrap();
let root = temp.path().join("plugins");
symlink(&target, &root).unwrap();
assert_eq!(disk_bytes(&root).unwrap(), disk_bytes(&target).unwrap());
assert!(disk_bytes(&root).unwrap().0 > 0);
}
}
+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));
+91 -4
View File
@@ -253,11 +253,12 @@ fn selected_versions_and_reinstallation_preserve_favorites() {
*data = serde_json::to_vec(&m).unwrap();
}
});
assert!(install(&mut core, &altered, json!({}))
.unwrap_err()
.to_string()
.contains("同一插件版本"));
install(&mut core, &altered, json!({})).unwrap();
assert_eq!(core.db.plugins["test.plugin"].manifest.version, "1.0.0");
assert_eq!(core.db.plugins["test.plugin"].manifest.name, "Changed release");
assert!(core.db.plugins["test.plugin"].favorite);
let disk: Value = serde_json::from_slice(&fs::read(state.join("plugins/test.plugin/versions/1.0.0/manifest.json")).unwrap()).unwrap();
assert_eq!(disk["name"], "Changed release");
assert!(!state
.join("plugins/test.plugin/versions/1.0.0/public-key.hex")
.exists());
@@ -1145,6 +1146,60 @@ fn batch_hook_failure_and_interrupted_transaction_restore_state() {
assert!(!restored.root.join("plugins/test.first").exists());
}
#[test]
fn same_version_reinstall_restores_files_after_hook_failure_and_interruption() {
let tmp = tempfile::tempdir().unwrap();
let _tools = fake_backend_tools(tmp.path());
let state = tmp.path().join("state");
let mut core = accepted_service(&state, unsafe { libc::geteuid() }).unwrap();
let original = relation_package(tmp.path(), "test.same", "1.0.0", json!({}));
install(&mut core, &original, json!({})).unwrap();
let data = state.join("data/test.same/steamos/save");
fs::create_dir_all(data.parent().unwrap()).unwrap();
fs::write(&data, b"user data").unwrap();
let broken = relation_package(tmp.path(), "test.same", "1.0.0", json!({"name":"broken","lifecycle":{"runAs":"steamos","onUpdate":{"entry":"fail.sh"}}}));
assert!(install(&mut core, &broken, json!({})).is_err());
let payload = state.join("plugins/test.same/versions/1.0.0");
let restored: Value = serde_json::from_slice(&fs::read(payload.join("manifest.json")).unwrap()).unwrap();
assert_eq!(restored["name"], "test.same");
assert_eq!(fs::read(&data).unwrap(), b"user data");
assert!(!state.join("install-transaction.json").exists());
let old_db = core.db.clone();
let backup_name = ".previous-1.0.0-interrupted";
fs::write(state.join("install-transaction.json"), serde_json::to_vec(&json!({"database":old_db,"targets":["test.same"],"running":[],"backups":{"test.same":backup_name},"replacementVersions":{"test.same":"1.0.0"}})).unwrap()).unwrap();
fs::rename(&payload, state.join("plugins/test.same").join(backup_name)).unwrap();
fs::create_dir(&payload).unwrap();
fs::write(payload.join("manifest.json"), b"replaced").unwrap();
let recovered = Service::load(&state, core.manager).unwrap();
assert_eq!(recovered.db.plugins["test.same"].manifest.name, "test.same");
let manifest: Value = serde_json::from_slice(&fs::read(payload.join("manifest.json")).unwrap()).unwrap();
assert_eq!(manifest["name"], "test.same");
assert_eq!(fs::read(&data).unwrap(), b"user data");
}
#[test]
fn batch_failure_restores_same_version_payload() {
let tmp = tempfile::tempdir().unwrap();
let _tools = fake_backend_tools(tmp.path());
let mut core = accepted_service(&tmp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
let old = relation_package(tmp.path(), "test.replace", "1.0.0", json!({}));
install(&mut core, &old, json!({})).unwrap();
let replacement = relation_package(tmp.path(), "test.replace", "1.0.0", json!({"name":"new payload"}));
let bad = relation_package(tmp.path(), "test.fails", "1.0.0", json!({"lifecycle":{"runAs":"steamos","onInstall":{"entry":"fail.sh"}}}));
let plan = crate::planner::Plan {
fingerprint:crate::relations::fingerprint(&core.db).unwrap(), root:"test.fails".into(),
items:vec![
crate::planner::Item{manifest:package::verify(&replacement).unwrap().manifest,source:None,action:"reinstall".into(),run_as_changed:false},
crate::planner::Item{manifest:package::verify(&bad).unwrap().manifest,source:None,action:"install".into(),run_as_changed:false},
], add_sources:vec![], enable:vec!["test.fails".into()], disable:vec![], affected:vec!["test.replace".into(),"test.fails".into()],
};
let result = apply_prepared(&mut core, crate::planner::Prepared{plan,packages:vec![(package::Staged::from_bytes(&replacement).unwrap(),None),(package::Staged::from_bytes(&bad).unwrap(),None)]});
assert!(result.is_err());
let manifest:Value=serde_json::from_slice(&fs::read(core.root.join("plugins/test.replace/versions/1.0.0/manifest.json")).unwrap()).unwrap();
assert_eq!(manifest["name"],"test.replace");
assert_eq!(core.db.plugins["test.replace"].manifest.name,"test.replace");
assert!(!core.root.join("install-transaction.json").exists());
}
#[test]
fn cross_source_resolution_requires_choice_and_uses_the_chosen_package() {
use tiny_http::{Response, Server};
let tmp = tempfile::tempdir().unwrap();
@@ -1289,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();
@@ -1321,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();
@@ -1542,6 +1627,7 @@ fn hook_failure_does_not_stop_plugin_with_hook_suffix() {
unsafe { libc::geteuid() },
Default::default(),
&tmp.path().join("logs"),
tmp.path(),
)
.unwrap();
let pid = tmp.path().join("framely-backend-review.foo-hook.pid");
@@ -1575,6 +1661,7 @@ fn hook_failure_does_not_stop_plugin_with_hook_suffix() {
&hook_payload,
unsafe { libc::geteuid() },
&tmp.path().join("logs"),
tmp.path(),
json!({"phase":"onInstall"}),
std::time::Duration::from_secs(1),
);
+39 -25
View File
@@ -182,10 +182,10 @@ fn resolve_release(
let Ok(version) = semver::Version::parse(tag) else {
continue;
};
let testing = channel == UpdateChannel::Testing;
let is_prerelease = !version.pre.is_empty();
if release.draft
|| release.prerelease != testing
|| version.pre.is_empty() == testing
|| release.prerelease != is_prerelease
|| (channel == UpdateChannel::Stable && is_prerelease)
{
continue;
}
@@ -242,7 +242,7 @@ fn resolve_release(
let release = verify_descriptor(&bytes)?;
let version = descriptor_version(&release.version)?;
ensure!(
version.pre.is_empty() == (channel == UpdateChannel::Stable),
channel == UpdateChannel::Testing || version.pre.is_empty(),
"更新清单不属于所选渠道,请配置该渠道的清单地址"
);
Ok(Some((release, bytes, version.to_string())))
@@ -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,9 +351,10 @@ 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_with_proxy(&release.url, false, Duration::from_secs(120), &[], &proxy)?;
let response = crate::http::get_download_with_proxy(&release.url, false, Duration::from_secs(120), &[], &proxy)?;
if let Some(length) = response.header("Content-Length") {
ensure!(length.parse::<u64>()? == release.size, "下载长度与更新清单不一致");
}
@@ -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()}));
@@ -678,17 +682,29 @@ mod tests {
let testing = resolve_fixture(UpdateChannel::Testing, releases)
.unwrap()
.unwrap();
assert_eq!(testing.2, "0.4.2-preview.10");
assert_eq!(testing.2, "0.4.2");
assert!(resolve_fixture(
UpdateChannel::Stable,
vec![fixture("0.4.2-preview.1", false)]
)
.unwrap()
.is_none());
assert!(
assert_eq!(
resolve_fixture(UpdateChannel::Testing, vec![fixture("0.4.2", false)])
.unwrap()
.is_none()
.unwrap()
.2,
"0.4.2"
);
assert_eq!(
resolve_fixture(
UpdateChannel::Testing,
vec![fixture("0.4.2", false), fixture("0.4.3-preview.1", false)]
)
.unwrap()
.unwrap()
.2,
"0.4.3-preview.1"
);
}
#[test]
@@ -762,9 +778,10 @@ mod tests {
.ends_with("framely-0.4.2-preview.1-012345abcdef-linux-arm64.tar.gz"));
}
#[test]
fn custom_descriptors_distinguish_channels_and_ignore_packaging_hashes() {
fn custom_descriptors_allow_stable_on_testing_and_ignore_packaging_hashes() {
for (version, channel) in [
("0.4.2", UpdateChannel::Stable),
("0.4.2", UpdateChannel::Testing),
("0.4.2-beta.1", UpdateChannel::Testing),
] {
let (_, bytes) = fixture(version, false);
@@ -780,17 +797,14 @@ mod tests {
.2,
version
);
let other_channel = if channel == UpdateChannel::Stable {
UpdateChannel::Testing
} else {
UpdateChannel::Stable
};
assert!(
resolve_release(&source, other_channel, &Default::default(), &mut |_, _| Ok(
bytes.clone()
))
.is_err()
);
if version.contains('-') {
assert!(
resolve_release(&source, UpdateChannel::Stable, &Default::default(), &mut |_, _| Ok(
bytes.clone()
))
.is_err()
);
}
}
}
#[test]
+5
View File
@@ -20,6 +20,11 @@ struct Upload {
touched: Instant,
}
impl Uploads {
pub fn active(&self) -> bool {
let mut slot = self.0.lock().unwrap();
Self::expire(&mut slot);
slot.is_some()
}
fn expire(slot: &mut Option<Upload>) {
if slot
.as_ref()
+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.');
+12 -3
View File
@@ -15,7 +15,7 @@ window.fetch=async(url,init)=>{if(String(url).startsWith('/host/files/')){const
const id='job-'+counter++;let value:any=true;
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true,gamepadEnabled,gamepadTriggerThreshold,autoStopContainer,stopContainerOnClose};
else if(method==='apk.inspect.start')value={ticket:'review-ticket',metadata:meta('com.example.new','New APK'),bytes:4};
else{if(params.operation==='lifecycle.settings'){autoStopContainer=params.autoStopContainer;stopContainerOnClose=params.stopContainerOnClose;}if(params.operation==='gamepad.settings'){gamepadEnabled=params.enabled;if(params.triggerThreshold!==undefined)gamepadTriggerThreshold=params.triggerThreshold;}const app=apps.find(a=>a.id===params.app);if(params.operation==='steam.settings')app.steamLaunch=params.enabled;if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall'&&!uninstallError)app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
else{if(params.operation==='lifecycle.settings'){autoStopContainer=params.autoStopContainer;stopContainerOnClose=params.stopContainerOnClose;}if(params.operation==='gamepad.settings'){gamepadEnabled=params.enabled;if(params.triggerThreshold!==undefined)gamepadTriggerThreshold=params.triggerThreshold;}const app=apps.find(a=>a.id===params.app);if(params.operation==='steam.settings')app.steamLaunch=params.enabled;if(params.operation==='settings'){if('steamName' in params)app.steamName=params.steamName.trim()||null;else Object.assign(app,{activity:params.activity,showWindow:params.showWindow});}if(params.operation==='uninstall'&&!uninstallError)app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
jobs.set(id,{id,kind:'apk',phase:method==='apk.operation.start'&&(fail||(params.operation==='uninstall'&&uninstallError))?'failed':'done',result:value,error:uninstallError??'Fixture signature mismatch',received:0});result={job:id};
}else if(method==='job.status')result=jobs.get(params.job);
return new Response(JSON.stringify({result}));};
@@ -44,8 +44,17 @@ async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIE
button('Containers').click();await until(()=>document.querySelectorAll('.apk-container').length===3);
for(const title of ['Start container','Stop container']){button(title).click();await until(()=>!!document.querySelector('.apk-dialog'));if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Container operation displayed data deletion warning');if(!button('Confirm',document.querySelector('.apk-dialog')!).classList.contains('primary'))throw Error('Non-destructive container confirmation uses destructive styling');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));}
const external=document.querySelectorAll('.apk-container')[1];button('Delete container',external).click();await until(()=>document.querySelector('.apk-dialog'));if(!document.querySelector('.apk-dialog')!.textContent!.includes(t('容器数据、关联 APK 缓存和 Steam 库条目将永久删除,历史备份保留。')))throw Error('External deletion lacks data warning');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));if(calls.some(c=>c.params.operation==='container.delete'))throw Error('Cancelled deletion modified external container');
button('Apps').click();await until(()=>document.querySelectorAll('.apk-row').length===2);
button('Manage',document.querySelector('.apk-row')!).click();await until(()=>document.querySelector('.apk-dialog'));const advanced=document.querySelector<HTMLDetailsElement>('.apk-advanced')!;advanced.open=true;document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Handle window display automatically"]')!.click();await until(()=>calls.some(c=>c.method==='apk.operation.start'&&c.params.operation==='settings'));await until(()=>!document.querySelector('progress'));await until(()=>document.querySelector('.apk-dialog')&&!button('Uninstall app',document.querySelector('.apk-dialog')!).disabled);
button(t('应用')).click();await until(()=>document.querySelectorAll('.apk-row').length===2);
button('Manage',document.querySelector('.apk-row')!).click();await until(()=>document.querySelector('.apk-dialog'));const steamName=()=>document.querySelector<HTMLInputElement>('.apk-steam-name input')!;
if(!steamName()||steamName().placeholder!=='Wolvic')throw Error('Per-app Steam name setting missing');
for(const name of ['My Wolvic','']){
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(steamName(),name);steamName().dispatchEvent(new Event('input',{bubbles:true}));await wait();
button('Save',document.querySelector('.apk-steam-name')!).click();
await until(()=>calls.some(c=>c.params.operation==='settings'&&c.params.steamName===name));
await until(()=>!!steamName()&&!steamName().disabled);
if(steamName().value!==name||apps[0].metadata.name!=='Wolvic')throw Error('Custom name changed original APK name');
}
const advanced=document.querySelector<HTMLDetailsElement>('.apk-advanced')!;advanced.open=true;document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Handle window display automatically"]')!.click();await until(()=>calls.some(c=>c.method==='apk.operation.start'&&c.params.operation==='settings'));await until(()=>!document.querySelector('progress'));await until(()=>document.querySelector('.apk-dialog')&&!button('Uninstall app',document.querySelector('.apk-dialog')!).disabled);
button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>document.querySelector('[role=switch][aria-label="Delete app data"]'));
const purgeSwitch=()=>document.querySelector<HTMLButtonElement>('[aria-label="Delete app data"]')!;
const deleteSwitch=()=>document.querySelector<HTMLButtonElement>('[aria-label="Delete container"]')!;
+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');
+26
View File
@@ -0,0 +1,26 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
const listeners=new Map(),windowListeners=new Map();
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
globalThis.document={documentElement:{lang:''},addEventListener(name,fn){listeners.set(name,fn);}};
globalThis.window={addEventListener(name,fn){windowListeners.set(name,fn);}};
class Element {
constructor(attributes={},parentElement=null){this.attributes=attributes;this.parentElement=parentElement;}
closest(selector){for(let node=this;node;node=node.parentElement){const a=node.attributes;if(selector.startsWith('[inert]')){if(a.inert||a.disabled||a['aria-disabled']==='true')return node;}else if(a['data-framely-interactive']!==undefined||a.tabindex!==undefined)return node;}return null;}
}
globalThis.Element=Element;globalThis.getComputedStyle=()=>({cursor:'default'});
const result=await build({stdin:{contents:`import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {FramingEditor} from './ui/src/cast-framing-editor';export {installHoverFeedback} from './sdk/src/hover';export const render=()=>renderToStaticMarkup(<FramingEditor spatial settings={{width:1200,height:900,horizontalFov:60,centerX:0,centerY:0}} view={{tanHalfHorizontal:1.4,centerTanX:0,centerTanY:0,aspect:1}} onChange={()=>{}} onError={()=>{}}/>);`,loader:'tsx',resolveDir:process.cwd()},bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};new Function('module','exports','require',result.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const markup=module.exports.render();
assert.equal((markup.match(/data-framely-interactive=""/g)||[]).length,4,'all four corner markers use the shared haptic hook');
const pulses=[];module.exports.installHoverFeedback(kind=>pulses.push(kind));
const frame=new Element({tabindex:0}),nw=new Element({'data-framely-interactive':''},frame),ne=new Element({'data-framely-interactive':''},frame),child=new Element({},nw);
const over=target=>listeners.get('pointerover')({target});
const out=(target,relatedTarget)=>listeners.get('pointerout')({target,relatedTarget});
over(frame);out(frame,nw);over(nw);out(nw,child);over(child);out(child,ne);over(ne);
assert.deepEqual(pulses,['hover','hover','hover'],'entering each corner pulses once; children do not repeat');
frame.attributes.inert=true;out(ne,nw);over(nw);listeners.get('click')({target:nw});assert.equal(pulses.length,3,'pending/closed controls cannot pulse');
frame.attributes.inert=false;windowListeners.get('blur')();over(nw);listeners.get('click')({target:nw});assert.deepEqual(pulses.slice(-2),['hover','activate']);
console.log('PASS: four framing corners use existing preference-aware feedback, one hover pulse per corner, inert editors have no feedback');
+35
View File
@@ -0,0 +1,35 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
const result=await build({stdin:{contents:`export * from './ui/src/cast-framing-gesture';export {normalizeRegion} from './ui/src/cast-region';export {frameFromSettings} from './ui/src/cast-framing';`,resolveDir:process.cwd()},bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};new Function('module','exports','require',result.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const {FrameGesture,fitMinimumFrame,minimumFrameWidth,normalizeRegion,frameFromSettings}=module.exports;
const close=(a,b)=>assert.ok(Math.abs(a-b)<1e-10,`${a} != ${b}`);
for(const ratio of [4/3,3/4,16/9,9/16,1]){
const minimum=minimumFrameWidth(ratio,{width:.24,height:.28},.05);
const rect=fitMinimumFrame({x:.3,y:.3,width:.4,height:.4/ratio},ratio,minimum),cx=rect.x+rect.width/2,cy=rect.y+rect.height/2;
for(const corner of ['nw','ne','sw','se']){
const sx=corner.includes('w')?-1:1,sy=corner.includes('n')?-1:1,gesture=new FrameGesture();
gesture.begin(1,500,500,rect,corner);
const smaller=gesture.move(1,500-sx*40,500-sy*40,1,{width:1000,height:1000},ratio,minimum);
const larger=gesture.move(1,500+sx*40,500+sy*40,1,{width:1000,height:1000},ratio,minimum);
assert.ok(smaller.width<=rect.width&&larger.width>=rect.width);
for(const next of [smaller,larger]){close(next.x+next.width/2,cx);close(next.y+next.height/2,cy);assert.ok(next.width>=.24-1e-10&&next.height>=.28-1e-10);close(next.width/next.height,ratio);}
assert.equal(gesture.move(1,800,800,0,{width:1000,height:1000},ratio,minimum),null);
assert.equal(gesture.active(),false); // lost pointerup cannot leave a drag armed
assert.equal(gesture.move(1,300,300,1,{width:1000,height:1000},ratio,minimum),null);
gesture.begin(2,500,500,rect,corner);assert.equal(gesture.finish(1),false);assert.equal(gesture.finish(2),true);
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);
}
console.log('PASS: all four corners grow/shrink around center, menu minimum survives save/ratio changes, release/cancel/hover cannot continue dragging');
+42
View File
@@ -0,0 +1,42 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {readFile} from 'node:fs/promises';
const bundle=await build({stdin:{contents:`export * from './ui/src/cast-framing-limits';export {normalizeRegion} from './ui/src/cast-region';export {frameFromSettings,settingsFromFrame} from './ui/src/cast-framing';`,resolveDir:process.cwd()},bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const {FramingLimits,framingMenuScale,normalizeRegion,frameFromSettings,settingsFromFrame}=module.exports;
const close=(a,b)=>assert.ok(Math.abs(a-b)<1e-8,`${a} != ${b}`);
for(const extent of [.9,1.695,1.734])close(framingMenuScale(extent)*extent,.9);
for(const eye of [0,1]){
const size=64,bits=[];for(let y=0;y<size;y++)for(let x=0;x<size;x++){
const u=(x+.5)/size,v=(y+.5)/size,lower=.1+Math.abs(v-.5)*.2,upper=.9-Math.abs(v-.5)*.5;
bits.push(v>.12&&v<.88&&(eye===0?u>lower&&u<upper:1-u>lower&&1-u<upper));
}
let cells='';for(let i=0;i<bits.length;i+=4)cells+=(+bits[i]+(+bits[i+1]<<1)+(+bits[i+2]<<2)+(+bits[i+3]<<3)).toString(16);
const limit=new FramingLimits({size,extent:1.7,cells},1.7);
for(const ratio of [4/3,3/4,16/9,9/16,1]){
const min=Math.max(.10,.12*ratio),reset=limit.reset(ratio,min);
assert.ok(limit.contains(reset));close(reset.width/reset.height,ratio);
const grown=limit.fit({...reset,x:reset.x-.2,y:reset.y-.2/ratio,width:reset.width+.4,height:reset.height+.4/ratio},ratio,min,reset);
assert.ok(limit.contains(grown));close(grown.x+grown.width/2,reset.x+reset.width/2);close(grown.y+grown.height/2,reset.y+reset.height/2);
const moved=limit.fit({...reset,x:-2},ratio,min,reset);assert.ok(limit.contains(moved));close(moved.width,reset.width);
const smaller=limit.fit({...reset,x:reset.x+reset.width/4,y:reset.y+reset.height/4,width:reset.width/2,height:reset.height/2},ratio,min,reset);
assert.ok(limit.contains(smaller));assert.ok(smaller.width>=min-1e-10);
const reference={tanHalfHorizontal:1.7,centerTanX:0,centerTanY:0,aspect:1};
const sizeSettings={width:Math.round(ratio*1600),height:1600,...settingsFromFrame(grown,reference)};
const saved=normalizeRegion(sizeSettings,reference,{width:.1,height:.12},limit.fit.bind(limit));
const displayed=frameFromSettings(saved,saved.width/saved.height,reference);assert.ok(limit.contains(displayed));
}
assert.throws(()=>limit.reset(1,.95));
}
if(process.env.FRAMELY_VIEW_FIXTURE){
const views=JSON.parse(await readFile(process.env.FRAMELY_VIEW_FIXTURE,'utf8'));
const checks=[];
for(const view of views){const limit=new FramingLimits(view.visibility,view.editorReferenceTan),reference={tanHalfHorizontal:view.editorReferenceTan,centerTanX:0,centerTanY:0,aspect:1};
for(const [width,height] of [[2560,1920],[1920,2560],[2560,1440],[1440,2560],[1920,1920]]){
const ratio=width/height,rect=limit.reset(ratio,Math.max(.1,.1*ratio));assert.ok(limit.contains(rect));
checks.push({eye:view.eye,width,height,...settingsFromFrame(rect,reference)});
}
}
console.log(JSON.stringify(checks));
}else console.log('PASS: menu angular size fixed; both-eye masks clamp growth/movement/reset and saved crops stay inside actual visibility');
+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');
+17
View File
@@ -0,0 +1,17 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {readFile} from 'node:fs/promises';
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
globalThis.document={documentElement:{lang:''}};
const bundle=await build({stdin:{contents:`import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {RecordingButton} from './ui/src/cast-recording';export const render=(recording,disabled=false)=>renderToStaticMarkup(<RecordingButton recording={recording} disabled={disabled} onClick={()=>{}}/>);`,resolveDir:process.cwd(),loader:'tsx'},bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const idle=module.exports.render({active:false});assert.ok(idle.includes('开始录制'));assert.ok(!idle.includes('停止录制'));
const active=module.exports.render({active:true,elapsedSeconds:125,path:'/home/steamos/Videos/framely/test.mp4'});
assert.ok(active.includes('停止录制'));assert.ok(active.includes('02:05'));assert.ok(active.includes('test.mp4'));assert.ok(active.includes(' recording'));
assert.ok(module.exports.render({active:true},true).includes('disabled=""'));
for(const locale of ['en-US','zh-CN']){
const {messages}=JSON.parse(await readFile(`ui/locales/${locale}.json`,'utf8'));
for(const key of ['开始录制','停止录制','录制保存到 /home/steamos/Videos/framely'])assert.ok(messages[key]);
}
console.log('PASS: recording button idle/active/disabled states, timer, path and translations');
+17
View File
@@ -0,0 +1,17 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
const bundle=await build({stdin:{contents:`export {normalizeRegion} from './ui/src/cast-region';export {frameFromSettings,settingsFromFrame,framePresets,frameRatios} from './ui/src/cast-framing';`,resolveDir:process.cwd()},bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const {normalizeRegion,frameFromSettings,settingsFromFrame,framePresets}=module.exports;
assert.ok(module.exports.frameRatios.some(r=>r.label==='3:4'&&r.value===3/4));
const reference={tanHalfHorizontal:1.4,centerTanX:0,centerTanY:0,aspect:1};
for(const aspect of [4/3,3/4,16/9,9/16,1])for(const size of framePresets(aspect)){
const input={...size,horizontalFov:72,centerX:12,centerY:-8};
const saved=normalizeRegion(input,reference),rect=frameFromSettings(saved,saved.width/saved.height,reference),effective=settingsFromFrame(rect,reference);
for(const key of ['horizontalFov','centerX','centerY'])assert.ok(Math.abs(saved[key]-effective[key])<1e-9,`${key}: displayed box differs from saved capture`);
assert.ok(Math.abs(rect.width/rect.height-saved.width/saved.height)<1e-10);
}
const fitted=normalizeRegion({width:1080,height:1920,horizontalFov:150,centerX:60,centerY:60},reference);
assert.ok(fitted.horizontalFov<150);assert.ok(fitted.centerX<=60&&fitted.centerY<=60);
console.log('PASS: frame ratio/resolution changes save exactly the displayed crop');
+73
View File
@@ -0,0 +1,73 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {resolve} from 'node:path';
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
globalThis.document={documentElement:{lang:'',classList:{add(){},remove(){}}}};
globalThis.window={addEventListener(){},removeEventListener(){}};
const reactPath=resolve('node_modules/react/index.js');
// A small hook runner exercises this page's async lifecycle without mounting
// video/CEF or adding a DOM-test dependency. Children remain React elements.
const hooks=`
import React from ${JSON.stringify(reactPath)};
export * from ${JSON.stringify(reactPath)};
export default React;
let values=[],cursor=0,effects=[];
const same=(a,b)=>a&&b&&a.length===b.length&&a.every((v,i)=>Object.is(v,b[i]));
export function useState(initial){const i=cursor++;if(!(i in values))values[i]=typeof initial==='function'?initial():initial;return [values[i],value=>{values[i]=typeof value==='function'?value(values[i]):value;}];}
export function useRef(initial){const i=cursor++;return values[i]??(values[i]={current:initial});}
export function useMemo(fn,deps){const i=cursor++;if(!same(values[i]?.deps,deps))values[i]={deps,value:fn()};return values[i].value;}
export function useEffect(fn,deps){const i=cursor++;if(!same(values[i]?.deps,deps)){const old=values[i];values[i]={deps};effects.push(()=>{old?.cleanup?.();values[i].cleanup=fn();});}}
export const useLayoutEffect=useEffect;
export function render(Component){cursor=0;return Component();}
export function flush(){const pending=effects;effects=[];for(const effect of pending)effect();}
export function reset(){for(const value of values)value?.cleanup?.();values=[];cursor=0;effects=[];}
`;
const api=`let handler;export const calls=[];export function setHandler(next){handler=next;calls.length=0;}export function api(method,params,signal){calls.push({method,params,signal});return Promise.resolve().then(()=>handler(method,params));}`;
const result=await build({stdin:{contents:`export {SpatialFraming} from './ui/src/cast-spatial-framing';export {render,flush,reset} from 'react';export {calls,setHandler} from './ui/src/api';`,resolveDir:process.cwd()},bundle:true,platform:'node',format:'cjs',loader:{'.css':'empty'},write:false,plugins:[{name:'page-harness',setup(build){build.onResolve({filter:/^react$/},()=>({path:'hooks',namespace:'test'}));build.onLoad({filter:/^hooks$/,namespace:'test'},()=>({contents:hooks,loader:'js',resolveDir:process.cwd()}));build.onLoad({filter:/\/ui\/src\/api\.ts$/},()=>({contents:api,loader:'js'}));}}]});
const module={exports:{}};new Function('module','exports','require',result.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const {SpatialFraming,render,flush,reset,calls,setHandler}=module.exports;
const geometry={id:'editor-1',referenceTan:1.4,settings:{width:1200,height:900,horizontalFov:60,centerX:0,centerY:0},view:{tanHalfHorizontal:.7,centerTanX:0,centerTanY:0,eye:1,visibility:{size:32,extent:1.4,cells:'f'.repeat(256)}}};
const tick=()=>new Promise(resolve=>setImmediate(resolve));
function walk(node,visit){if(!node||typeof node!=='object')return;visit(node);for(const child of [node.props?.children].flat(Infinity))walk(child,visit);}
function draw(){const tree=render(SpatialFraming);walk(tree,node=>{if(node.props?.ref){const panel=node.type==='section';node.props.ref.current={getBoundingClientRect:()=>({width:panel?340:1600,height:panel?300:1600})};}});flush();return tree;}
function button(tree,label){let found;walk(tree,node=>{if(node.type==='button'&&node.props.children===label)found=node;});assert.ok(found,label);return found;}
async function mount(finish){reset();setHandler((method,params)=>method==='status'?{database:{language:'zh-CN'}}:method==='language.list'?{packs:[]}:method==='cast.framing.get'?geometry:method==='cast.framing.reference'?true:finish(method,params));for(let i=0;i<4;i++){draw();await tick();}return draw();}
let resolveApply;
let tree=await mount(method=>{assert.equal(method,'cast.framing.apply');return new Promise(resolve=>{resolveApply=resolve;});});
const apply=button(tree,'应用取景').props.onClick;
apply();apply();await tick();tree=draw();
assert.equal(tree.props.inert,true);
assert.equal(calls.filter(call=>call.method==='cast.framing.apply').length,1,'double click cannot apply twice');
resolveApply(true);await tick();assert.equal(draw(),null,'successful apply immediately removes the stale editor');
apply();await tick();assert.equal(calls.filter(call=>call.method==='cast.framing.apply').length,1);
tree=await mount(()=>{throw Error('Fixture save rejected');});
button(tree,'应用取景').props.onClick();await tick();tree=draw();
assert.ok(tree,'save failure leaves the editor usable');assert.equal(tree.props.inert,false);
let error;walk(tree,node=>{if(node.props?.role==='alert')error=node.props.children;});assert.equal(error,'Fixture save rejected');
assert.equal(button(tree,'应用取景').props.disabled,false);
tree=await mount(()=>true);button(tree,'取消').props.onClick();await tick();assert.equal(draw(),null,'cancel has the same terminal state');
tree=await mount(()=>{throw Error('Saved, but restart failed');});
setHandler(method=>{if(method==='cast.framing.apply')throw Error('Saved, but restart failed');if(method==='cast.framing.get')throw Error('画幅调整窗口已关闭');throw Error(method);});
button(tree,'应用取景').props.onClick();await tick();assert.equal(draw(),null,'post-save restart failure cannot revive a closed editor');
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
function frame(tree){let found;walk(tree,node=>{if(node.props?.spatial&&node.props?.settings&&node.props?.onChange)found=node;});assert.ok(found,'framing editor');return found;}
tree=await mount(()=>true);await delay(120);
let references=calls.filter(call=>call.method==='cast.framing.reference');
assert.equal(references.length,1);assert.deepEqual(references[0].params,{id:geometry.id,centerX:0,centerY:0});
frame(tree).props.onChange({centerX:5,centerY:-3});tree=draw();
frame(tree).props.onChange({centerX:6,centerY:-4});tree=draw();await delay(120);
references=calls.filter(call=>call.method==='cast.framing.reference');
assert.equal(references.length,2,'rapid committed centers are debounced');
assert.ok(Math.abs(references[1].params.centerX-6)<1e-8&&Math.abs(references[1].params.centerY+4)<1e-8,'live draft is sent in degrees');
assert.ok(references[0].signal.aborted,'previous reference request is canceled after a new center');
frame(tree).props.onChange({centerX:7});tree=draw();
button(tree,'取消').props.onClick();await tick();assert.equal(draw(),null);await delay(120);
assert.equal(calls.filter(call=>call.method==='cast.framing.reference').length,2,'closing cancels the pending depth-probe update');
console.log('PASS: terminal editor lifecycle, error recovery, debounced live-center depth updates and closing cancellation');
+24
View File
@@ -0,0 +1,24 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {readFile} from 'node:fs/promises';
Object.defineProperty(globalThis,'navigator',{value:{languages:['en-US']},configurable:true});
globalThis.document={documentElement:{lang:''},addEventListener(){}};
globalThis.window={addEventListener(){}};
const bundle=await build({stdin:{contents:`import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {SpatialFraming} from './ui/src/cast-spatial-framing';export {loadSpatialFraming} from './ui/src/cast-spatial-framing';export const render=()=>renderToStaticMarkup(<SpatialFraming/>);`,loader:'tsx',resolveDir:process.cwd()},bundle:true,platform:'node',format:'cjs',loader:{'.css':'empty'},write:false});
const module={exports:{}};new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const calls=[];
const geometry={id:'test'};
await module.exports.loadSpatialFraming(async method=>{calls.push(method);return method==='status'?{database:{language:'zh-CN'}}:method==='language.list'?{packs:[]}:geometry;});
assert.deepEqual(calls.sort(),['cast.framing.get','language.list','status']);
assert.equal(document.documentElement.lang,'zh-CN');
const chinese=module.exports.render();
for(const text of ['画幅调整','画面比例','录制分辨率','应用取景','重置大小'])assert.ok(chinese.includes(text),text);
assert.ok(!chinese.includes('<select'));
assert.equal((chinese.match(/aria-haspopup="listbox"/g)||[]).length,2);
await module.exports.loadSpatialFraming(async method=>method==='status'?{database:{language:'en-US'}}:method==='language.list'?{packs:[]}:geometry);
assert.ok(module.exports.render().includes('Adjust recording frame'));
const css=await readFile('ui/src/casting.css','utf8');
assert.match(css,/\.cast-quick-framing-row\{display:flex;align-items:center;justify-content:space-between/);
assert.match(css,/\.cast-quick-framing-row \.switch-row\{width:auto;min-width:0/);
console.log('PASS: spatial frame loads configured language and uses two in-page dropdowns; quick controls share one row');
+20
View File
@@ -0,0 +1,20 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
globalThis.document={documentElement:{lang:''},addEventListener(){}};
globalThis.window={addEventListener(){}};
const bundle=await build({stdin:{contents:`import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {Transfer} from './ui/src/store';export const render=(job)=>renderToStaticMarkup(<Transfer job={job} close={()=>{}}/>);`,resolveDir:process.cwd(),loader:'tsx'},bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};
new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const render=(received,total,phase='downloading')=>module.exports.render({id:'test',kind:'inspect',phase,received,total});
for(const received of [995,999,1000,1001]){
const html=render(received,1000);
assert.ok(html.includes('99%'));
assert.ok(!html.includes('100%'));
assert.ok(html.includes('value="99"'));
}
assert.ok(render(500,1000).includes('50%'));
assert.ok(!render(500,undefined).includes('NaN'));
assert.ok(!render(1000,1000,'verifying').includes('<progress'));
console.log('PASS: incomplete downloads never display 100%, including rounded and final-byte progress');
+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'));
+13
View File
@@ -0,0 +1,13 @@
#include "framing_lifecycle.h"
#include <cassert>
#include <iostream>
int main(){
using nlohmann::json;
assert(!framing_editor_should_close(nullptr,"current"));
assert(!framing_editor_should_close(json::object(),"current"));
assert(!framing_editor_should_close({{"id","current"},{"open",true}},"current"));
assert(framing_editor_should_close({{"id","current"},{"open",false}},"current"));
assert(!framing_editor_should_close({{"id","old"},{"open",false}},"current"));
assert(framing_editor_should_close({{"id","new"},{"open",true}},"current"));
std::cout<<"PASS: persistent framing state repairs lost closes and ignores stale acknowledgements\n";
}
+7
View File
@@ -24,6 +24,13 @@ registerPlugin({QuickPage:Page});
await native('X','x','keyboard-multiline-state');await native('ENTER','x\n','keyboard-multiline-state');
await native('Y','x\ny','keyboard-multiline-state');
await native('BACKSPACE','x\n','keyboard-multiline-state');await native('BACKSPACE','x','keyboard-multiline-state');await native('BACKSPACE','','keyboard-multiline-state');
const editor=document.createElement('textarea'),edits:string[]=[];
editor.addEventListener('framely.keyboard.edit',event=>{event.preventDefault();edits.push((event as CustomEvent<string>).detail);});
document.body.appendChild(editor);editor.focus();editor.dispatchEvent(new FocusEvent('focusin',{bubbles:true}));
console.log('FRAMELY_NATIVE_KEYBOARD_X');await until(()=>edits.length===1);
console.log('FRAMELY_NATIVE_KEYBOARD_BACKSPACE');await until(()=>edits.length===2);
if(edits[0]!=='x'||edits[1]!=='\b'||editor.value!=='')throw Error('custom keyboard editor did not consume native edits');
editor.remove();
console.log('FRAMELY_KEYBOARD_REACT_PASS');
(window as any).__framelyKeyboardFixtureDone=true;
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}})();
+26
View File
@@ -0,0 +1,26 @@
#include "openvr.h"
#include "recording_indicator.h"
#include "launcher_input.h"
#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));
assert(!click.update(true,true));assert(!click.update(false,true,false));assert(!click.update(false,true));
EntryAnalogTrigger trigger;RecordingIndicatorClick analog_click;
assert(!analog_click.update(trigger.update(0.f),true));
assert(!analog_click.update(trigger.update(.6f),true));
assert(!analog_click.update(trigger.update(.46f),true));
assert(analog_click.update(trigger.update(.44f),true));
for(auto seconds:{0ULL,59ULL,60ULL,3600ULL,9999999ULL}){
auto normal=recording_indicator_pixels(seconds,false),hover=recording_indicator_pixels(seconds,true);
assert(normal.size()==320*80*4&&hover.size()==normal.size());
assert(normal!=hover);assert(normal[3]==0);
auto red=(40*320+32)*4;assert(normal[red]>200&&normal[red+1]<100);
auto stop=(40*320+280)*4;assert(normal[stop]==245&&normal[stop+3]==255);
}
}
+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);}})();
+25
View File
@@ -0,0 +1,25 @@
#include "openvr.h"
#include "recording_region.h"
#include <cassert>
#include <iostream>
int main(){
for(double distance:{.5,1.,2.,4.})for(double roll:{-.2,0.,.2})for(double yaw:{-.3,0.,.3})for(double aspect:{4./3,16./9,9./16,1.}){
double c=cos(roll),s=sin(roll),cy=cos(yaw),sy=sin(yaw);
RegionJson view={{"captureToHead",{{c*cy,-s, c*sy,-.032},{s*cy,c,s*sy,.003},{-sy,0,cy,.01}}}};
const double tan=.8,cx=.15,center_y=-.08;
RecordingRegionPlane plane;assert(recording_region_plane(view,tan,cx,center_y,aspect,plane,distance));
// Undo the exported head basis: each overlay corner must be exactly the
// tangent ray consumed at the corresponding output-LUT corner.
for(double u:{0.,.25,1.})for(double v:{0.,.75,1.}){
double world[3];for(int i=0;i<3;i++)world[i]=plane.transform.m[i][3]+plane.transform.m[i][0]*(u-.5)*plane.width+plane.transform.m[i][1]*(.5-v)*plane.width/aspect-view["captureToHead"][i][3].get<double>();
double ray[3]{};for(int j=0;j<3;j++)for(int i=0;i<3;i++)ray[j]+=view["captureToHead"][i][j].get<double>()*world[i]/distance;
assert(std::abs(ray[0]-(cx+(2*u-1)*tan))<1e-6);
assert(std::abs(ray[1]-(-center_y+(1-2*v)*tan/aspect))<1e-6);
assert(std::abs(ray[2]+1.)<1e-6);
}
}
auto pixels=recording_region_pixels(100,80);assert(pixels[(40*100+50)*4+3]==0);assert(pixels[3]>0&&pixels[3]<100);
auto colored=recording_region_pixels(100,80,{12,34,56});assert(colored[0]==12&&colored[1]==34&&colored[2]==56&&colored[3]==72);
RecordingRegionPlane bad;assert(!recording_region_plane(RegionJson::object(),1,0,0,1,bad));
std::cout<<"PASS: recording border projection matches capture rays, including roll/yaw/eye offset and aspect ratios\n";
}
+86
View File
@@ -0,0 +1,86 @@
#include "recording_region_depth.h"
#include <cassert>
#include <iostream>
#include <limits>
using namespace recording_region_depth_detail;
static vr::HmdMatrix34_t pose(double yaw=0,double pitch=0,double roll=0,Point t={0,0,0}){
const double cy=cos(yaw),sy=sin(yaw),cp=cos(pitch),sp=sin(pitch),cr=cos(roll),sr=sin(roll);
vr::HmdMatrix34_t m{{
{float(cy*cr+sy*sp*sr),float(-cy*sr+sy*sp*cr),float(sy*cp),float(t[0])},
{float(cp*sr),float(cp*cr),float(-sp),float(t[1])},
{float(-sy*cr+cy*sp*sr),float(sy*sr+cy*sp*cr),float(cy*cp),float(t[2])}}};return m;
}
static vr::HmdMatrix34_t multiply(const vr::HmdMatrix34_t& a,const vr::HmdMatrix34_t& b){
vr::HmdMatrix34_t m{};for(int i=0;i<3;i++){
for(int j=0;j<3;j++)for(int k=0;k<3;k++)m.m[i][j]+=a.m[i][k]*b.m[k][j];
m.m[i][3]=a.m[i][3];for(int k=0;k<3;k++)m.m[i][3]+=a.m[i][k]*b.m[k][3];
}return m;
}
static void near(double a,double b,double tolerance=1e-5){assert(std::abs(a-b)<tolerance);}
int main(){
for(double ipd:{0.055,0.064,0.075})for(auto eye:{vr::Eye_Left,vr::Eye_Right}){
const auto left=pose(0,0,0,{-ipd/2,0,0.012}),right=pose(0,0,0,{ipd/2,0,0.012});
auto capture=pose(0.12,-0.08,0.04,{0,0,0});const auto before=capture;
assert(recording_region_refresh_eye_origin(eye,left,right,capture));
for(int i=0;i<3;i++)for(int j=0;j<3;j++)assert(capture.m[i][j]==before.m[i][j]);
const auto selected=eye==vr::Eye_Left?left:right;
for(int i=0;i<3;i++)near(capture.m[i][3],selected.m[i][3]);
for(double depth:{0.5,1.,2.,4.})for(double yaw:{-0.35,0.,0.35})for(double pitch:{-0.25,0.,0.25}){
const auto hmd=pose(0.4,-0.1,0.05,{0.5,1.6,-0.2});
const double cx=0.16,cy=-0.09;
const auto origin=add(translation(hmd),rotate(hmd,translation(capture)));
const auto direction=rotate(hmd,rotate(capture,{cx,-cy,-1.}));
const Point intersection={origin[0]+depth*direction[0],origin[1]+depth*direction[1],origin[2]+depth*direction[2]};
auto window=multiply(hmd,pose(yaw,pitch,0.07));for(int i=0;i<3;i++)window.m[i][3]=float(intersection[i]);
const auto result=recording_region_window_depth(hmd,window,capture,cx,cy);
assert(result);near(*result,depth);
const vr::HmdVector3_t hit{{float(intersection[0]),float(intersection[1]),float(intersection[2])}};
const auto hit_depth=recording_region_hit_depth(hmd,capture,cx,cy,hit);
assert(hit_depth);near(*hit_depth,depth);
}
// Any finite plane distance preserves selected-eye ray bounds. The other
// eye's shift is baseline/D, symmetric for left and right recordings.
for(double depth:{0.5,1.,2.,4.})for(double tx:{-0.7,0.,0.7}){
auto parallel=pose(0,0,0,{0,0,0});assert(recording_region_refresh_eye_origin(eye,left,right,parallel));
const auto origin=translation(parallel),other=translation(eye==vr::Eye_Left?right:left);
const Point point={origin[0]+depth*tx,origin[1],origin[2]-depth};
near((point[0]-origin[0])/(origin[2]-point[2]),tx);
const double baseline=origin[0]-other[0];
const double actual_other=(point[0]-other[0])/(other[2]-point[2]);
near(actual_other,tx+baseline/depth);
const double old_other=tx+baseline/2.;
near(actual_other-old_other,baseline*(1./depth-0.5));
if(depth!=2.)assert(std::abs(actual_other-old_other)>0.001);
}
}
// Changing IPD refreshes a stale exported origin without altering ray basis.
auto stale=pose(0,0,0,{0.032,0,0});
assert(recording_region_refresh_eye_origin(vr::Eye_Right,pose(0,0,0,{-0.0375,0,0}),pose(0,0,0,{0.0375,0,0}),stale));
near(stale.m[0][3],0.0375);
const auto identity=pose(),left=pose(0,0,0,{-0.032,0,0}),right=pose(0,0,0,{0.032,0,0});
auto unchanged=identity;
assert(!recording_region_refresh_eye_origin(vr::Eye_Right,pose(0,0,0,{-0.1,0,0}),pose(0,0,0,{0.1,0,0}),unchanged));
assert(!recording_region_refresh_eye_origin(vr::Eye_Right,right,left,unchanged));
assert(unchanged.m[0][3]==0);
auto invalid=identity;invalid.m[0][0]=std::numeric_limits<float>::quiet_NaN();
assert(!recording_region_refresh_eye_origin(vr::Eye_Right,invalid,right,unchanged));
assert(!recording_region_window_depth(invalid,pose(0,0,0,{0,0,-2}),identity,0,0));
assert(!recording_region_window_depth(vr::HmdMatrix34_t{},pose(0,0,0,{0,0,-2}),identity,0,0));
assert(!recording_region_window_depth(identity,pose(acos(-1.)/2,0,0,{0,0,-2}),identity,0,0));
for(double z:{-0.1,-8.,1.})assert(!recording_region_window_depth(identity,pose(0,0,0,{0,0,z}),identity,0,0));
assert(!recording_region_window_depth(identity,pose(0,0,0,{0,0,-2}),identity,std::numeric_limits<double>::infinity(),0));
// Eye Z matters: head-relative depth alone would return 2, but D=2.012.
const auto zdepth=recording_region_window_depth(identity,pose(0,0,0,{0,0,-2}),right,0,0);
assert(zdepth);near(*zdepth,2.0); // This fixture's right eye has Z=0.
const auto elevated=pose(0,0,0,{0.032,0,0.012});
const auto exact=recording_region_window_depth(identity,pose(0,0,0,{0,0,-2}),elevated,0,0);
assert(exact);near(*exact,2.012);
for(double depth:{0.25,0.5,1.,2.,4.,6.}){
const vr::HmdVector3_t hit{{0,0,float(-depth)}};
const auto result=recording_region_hit_depth(identity,identity,0,0,hit);assert(result);near(*result,depth);
}
for(const auto& hit:std::array<vr::HmdVector3_t,4>{{{{0.02f,0,-2}},{{0,0,2}},{{0,0,-8}},{{std::numeric_limits<float>::quiet_NaN(),0,-2}}}})
assert(!recording_region_hit_depth(identity,identity,0,0,hit));
assert(!recording_region_hit_depth(invalid,identity,0,0,vr::HmdVector3_t{{0,0,-2}}));
std::cout<<"PASS: current eye origin, exact center-ray UI depth, both-eye parallax and invalid-pose guards\n";
}
+37
View File
@@ -0,0 +1,37 @@
// Isolated device fixture: visible fiducials, never part of the installed app.
#include "openvr.h"
#include "recording_region.h"
#include <fstream>
#include <thread>
#include <chrono>
#include <filesystem>
#include <iostream>
int main(int argc,char** argv){
if(argc!=4)return 2;RegionJson view;std::ifstream(argv[1])>>view;
vr::EVRInitError error{};vr::VR_Init(&error,vr::VRApplication_Overlay);if(error)return 3;
auto* overlays=vr::VROverlay();vr::VROverlayHandle_t handle=0;
if(overlays->CreateOverlay("framely.region.alignment.fixture","Recording alignment fixture",&handle))return 4;
RecordingRegionPlane plane;if(!recording_region_plane(view,view.at("tanHalfHorizontal"),view.at("centerTanX"),view.at("centerTanY"),view.at("aspect"),plane))return 5;
const int w=view.at("width"),h=view.at("height"),texture=512;std::vector<unsigned char> pixels(texture*texture*4);
const int positions[5][2]={{16,16},{w-16,16},{16,h-16},{w-16,h-16},{w/2,h/2}};
const int colors[5][3]={{255,0,0},{0,255,0},{0,0,255},{255,0,255},{255,255,255}};
for(int y=0;y<texture;y++)for(int x=0;x<texture;x++)for(int p=0;p<5;p++){
double ox=(x+.5)*w/texture,oy=(y+.5)*h/texture;
if(ox<positions[p][0]-16||ox>=positions[p][0]+16||oy<positions[p][1]-16||oy>=positions[p][1]+16)continue;
auto i=(y*texture+x)*4;for(int c=0;c<3;c++)pixels[i+c]=colors[p][c];pixels[i+3]=255;
}
overlays->SetOverlayInputMethod(handle,vr::VROverlayInputMethod_None);
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,false);
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_VisibleInDashboard,true);
recording_region_foreground(overlays,handle);
overlays->SetOverlayWidthInMeters(handle,plane.width);
overlays->SetOverlayTexelAspect(handle,view.at("aspect"));
overlays->SetOverlayTransformTrackedDeviceRelative(handle,vr::k_unTrackedDeviceIndex_Hmd,&plane.transform);
overlays->SetOverlayRaw(handle,pixels.data(),texture,texture,4);overlays->ShowOverlay(handle);
std::ofstream(argv[2])<<"ready";
auto until=std::chrono::steady_clock::now()+std::chrono::seconds(40);
while(std::chrono::steady_clock::now()<until&&!std::filesystem::exists(argv[3])){
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
overlays->DestroyOverlay(handle);vr::VR_Shutdown();return 0;
}
+116
View File
@@ -0,0 +1,116 @@
// Isolated, opt-in wearer diagnostic. Never part of the installed app.
// Usage: helper geometry.json ready-file stop-file
#include "openvr.h"
#include "recording_region.h"
#include <fstream>
#include <thread>
#include <chrono>
#include <filesystem>
#include <iostream>
#include <stdexcept>
namespace {
constexpr int texture_size=512;
constexpr double aspect=4./3.;
constexpr int fovs[]{80,75,70,65,60};
constexpr std::array<unsigned char,3> colors[]{
{255,48,48},{255,230,32},{32,255,255},{48,255,64},{255,255,255}
};
// Five columns, seven rows; each row's most significant bit is the left pixel.
constexpr unsigned char digits[10][7]{
{14,17,19,21,25,17,14},{4,12,4,4,4,4,14},
{14,17,1,2,4,8,31},{30,1,1,14,1,1,30},
{2,6,10,18,31,2,2},{31,16,16,30,1,1,30},
{14,16,16,30,17,17,14},{31,1,2,4,8,8,8},
{14,17,17,14,17,17,14},{14,17,17,15,1,1,14}
};
std::vector<unsigned char> pixels(int fov,const std::array<unsigned char,3>& color){
std::vector<unsigned char> image(texture_size*texture_size*4);
auto put=[&](int x,int y){
auto index=(y*texture_size+x)*4;
for(int c=0;c<3;c++)image[index+c]=color[c];
image[index+3]=255;
};
constexpr int stroke=3;
for(int y=0;y<texture_size;y++)for(int x=0;x<texture_size;x++){
if(x<stroke||x>=texture_size-stroke||y<stroke||y>=texture_size-stroke)put(x,y);
}
// Center the FOV number just inside the top edge. Large block digits remain
// readable without CEF or a font installation; all other pixels are clear.
// Keep the label shorter than the angular gap to the next rectangle, so
// an inner horizontal border cannot strike through the outer FOV number.
constexpr int scale=2,digit_width=5*scale,gap=2*scale;
constexpr int start_x=(texture_size-(2*digit_width+gap))/2,start_y=5;
for(int digit=0;digit<2;digit++){
int value=digit==0?fov/10:fov%10;
for(int row=0;row<7;row++)for(int column=0;column<5;column++){
if(!(digits[value][row]&(1<<(4-column))))continue;
for(int dy=0;dy<scale;dy++)for(int dx=0;dx<scale;dx++)
put(start_x+digit*(digit_width+gap)+column*scale+dx,start_y+row*scale+dy);
}
}
return image;
}
void check(vr::IVROverlay* api,vr::EVROverlayError error,const char* operation){
if(error!=vr::VROverlayError_None)
throw std::runtime_error(std::string(operation)+": "+api->GetOverlayErrorNameFromEnum(error));
}
struct Cleanup {
vr::IVROverlay* api=nullptr;
std::vector<vr::VROverlayHandle_t> handles;
bool initialized=false;
~Cleanup(){
for(auto handle:handles)if(api){
auto hidden=api->HideOverlay(handle),destroyed=api->DestroyOverlay(handle);
if(hidden||destroyed)std::cerr<<"Diagnostic overlay cleanup error: "<<hidden<<", "<<destroyed<<"\n";
}
if(initialized)vr::VR_Shutdown();
}
};
}
int main(int argc,char** argv){
if(argc!=4){std::cerr<<"Usage: recording-visibility-device geometry.json ready-file stop-file\n";return 2;}
Cleanup cleanup;
try{
RegionJson view;std::ifstream input(argv[1]);if(!input)throw std::runtime_error("Cannot open geometry");input>>view;
const double cx=view.at("centerTanX"),cy=view.at("centerTanY");
vr::EVRInitError error{};vr::VR_Init(&error,vr::VRApplication_Overlay);
if(error)throw std::runtime_error(vr::VR_GetVRInitErrorAsEnglishDescription(error));
cleanup.initialized=true;cleanup.api=vr::VROverlay();
if(!cleanup.api)throw std::runtime_error("OpenVR overlay interface unavailable");
auto* api=cleanup.api;
for(int i=0;i<5;i++){
RecordingRegionPlane plane;
const double tangent=std::tan(fovs[i]*std::acos(-1.)/360.);
if(!recording_region_plane(view,tangent,cx,cy,aspect,plane))throw std::runtime_error("Invalid capture geometry");
vr::VROverlayHandle_t handle=0;
const std::string key="framely.recording.visibility.fixture."+std::to_string(fovs[i]);
check(api,api->CreateOverlay(key.c_str(),("Recording visibility "+std::to_string(fovs[i])).c_str(),&handle),"CreateOverlay");
cleanup.handles.push_back(handle);
check(api,api->SetOverlayInputMethod(handle,vr::VROverlayInputMethod_None),"SetOverlayInputMethod");
check(api,api->SetOverlayFlag(handle,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,false),"Disable interactive overlay");
check(api,api->SetOverlayFlag(handle,vr::VROverlayFlags_HideLaserIntersection,true),"Hide laser intersection");
check(api,api->SetOverlayFlag(handle,vr::VROverlayFlags_VisibleInDashboard,true),"VisibleInDashboard");
check(api,api->SetOverlayFlag(handle,vr::VROverlayFlags_SortWithNonSceneOverlays,true),"Foreground sort flag");
check(api,api->SetOverlaySortOrder(handle,1000+i),"SetOverlaySortOrder");
check(api,api->SetOverlayWidthInMeters(handle,plane.width),"SetOverlayWidthInMeters");
check(api,api->SetOverlayTexelAspect(handle,aspect),"SetOverlayTexelAspect");
check(api,api->SetOverlayTransformTrackedDeviceRelative(handle,vr::k_unTrackedDeviceIndex_Hmd,&plane.transform),"SetOverlayTransformTrackedDeviceRelative");
auto image=pixels(fovs[i],colors[i]);
check(api,api->SetOverlayRaw(handle,image.data(),texture_size,texture_size,4),"SetOverlayRaw");
check(api,api->ShowOverlay(handle),"ShowOverlay");
}
std::ofstream ready(argv[2]);if(!ready)throw std::runtime_error("Cannot open ready file");
ready<<"ready\n";ready.close();if(!ready)throw std::runtime_error("Cannot write ready file");
std::cout<<"Visibility diagnostic ready: eye="<<view.at("eye")<<", center="<<cx<<","<<cy
<<", 4:3 FOVs red80 yellow75 cyan70 green65 white60; auto-hide after 90 seconds\n"<<std::flush;
const auto until=std::chrono::steady_clock::now()+std::chrono::seconds(90);
while(std::chrono::steady_clock::now()<until&&!std::filesystem::exists(argv[3]))
std::this_thread::sleep_for(std::chrono::milliseconds(20));
return 0;
}catch(const std::exception& error){std::cerr<<"Recording visibility diagnostic: "<<error.what()<<"\n";return 1;}
}
+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:
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more