Compare commits

...
49 Commits
Author SHA1 Message Date
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
toorux d2ed96658d Prepare Framely 0.7.0-preview.2 testing release 2026-10-10 19:09:06 +08:00
toorux c08c4d323f Respect Cargo target directory when packaging capture binaries 2026-10-10 19:09:06 +08:00
toorux 1cb33ccec3 Prepare Framely 0.7.0-preview.1 testing release 2026-10-10 01:19:45 +08:00
toorux f78bf2f0ee Prompt for AirPlay mirror setup and enable gated HLS video reception 2026-10-10 01:11:54 +08:00
toorux 87ab0ad431 Correct AirPlay video classification and update accepted player windows 2026-10-10 01:01:02 +08:00
toorux de54f93a73 Redesign spatial notifications and add streaming to the quick panel 2026-10-10 00:39:22 +08:00
toorux 1ed4b80fd3 Hide empty notification overlays and route casting window controls correctly 2026-10-10 00:39:22 +08:00
toorux c44fad4fc4 Request AirPlay consent on media setup and renew pending reconnects 2026-10-10 00:39:22 +08:00
toorux 05f172e467 Give received casting windows Steam movement and resize controls 2026-10-10 00:39:22 +08:00
toorux a73e4a31a1 Use bundled FFmpeg decoding for AirPlay desktop mirroring 2026-10-10 00:19:14 +08:00
toorux 49ff883be7 Correct top-down decoded video orientation in casting windows 2026-10-10 00:13:19 +08:00
toorux 3456cad319 Fix live casting stalls caused by raw packet wallclock timestamps 2026-10-10 00:13:19 +08:00
toorux 7a9855769c fix: preserve Steam controls for plugin windows without Dock icons 2026-10-10 00:05:58 +08:00
toorux 37464e5158 Use FFmpeg 7 compatible frame-rate options for compatibility streams 2026-10-09 23:52:46 +08:00
toorux afa29ac8f2 Wait for DLNA compatibility media before publishing its playback address 2026-10-09 23:50:34 +08:00
toorux 885356ea4d Use the tested Chromium startup mode to avoid SteamOS child crashes 2026-10-09 23:49:07 +08:00
toorux 134eb3f4c4 Keep compatibility encoders at the requested frame rate 2026-10-09 23:48:52 +08:00
toorux b83da59115 Correct hardware HEVC frame timing without re-encoding 2026-10-09 23:46:13 +08:00
toorux 2716ec2e41 Check receiver decoder dependencies before install and verify HEVC DLNA conversion 2026-10-09 23:42:28 +08:00
toorux 1d1747749a Support selectable H264 and H265 casting with browser and DLNA compatibility 2026-10-09 23:39:51 +08:00
toorux caa4fa071d Reduce live casting latency by bounding audio and video input buffers 2026-10-09 23:39:43 +08:00
toorux 96f129d1dd Redesign responsive cast viewer with complete uncropped video 2026-10-09 23:39:31 +08:00
toorux 06863516c2 Refine cast settings controls and fix field-of-view slider endpoints 2026-10-09 23:39:22 +08:00
toorux 536ec1e3aa Fix corrupted DLNA video and blocked controls on Steam Frame 2026-10-09 23:39:08 +08:00
toorux 2eb921b2c8 Fix AirPlay startup with bundled FFmpeg decoders and clock properties 2026-10-09 23:38:15 +08:00
toorux 1e67a2d141 Add built-in headset casting and media controls 2026-10-09 23:23:13 +08:00
toorux 782e6076a7 Redesign upload confirmation with visible conflict choices 2026-10-09 19:18:08 +08:00
toorux a72cf7b3c1 Add mobile terminal controls and bilingual workflow coverage 2026-10-09 18:19:50 +08:00
toorux 423abf3007 Add touch file navigation and selection workflows 2026-10-09 18:19:45 +08:00
toorux c2d457ed84 Add mobile manager navigation and touch-first page sheets 2026-10-09 18:19:42 +08:00
toorux 418b0e0380 Show uploads in a floating queue panel with batch file counts 2026-10-09 15:50:24 +08:00
toorux fcf423c1c8 Document folder uploads and upload queue in English and Chinese 2026-10-09 15:26:10 +08:00
toorux 007849d1cb Add drag-and-drop file uploads and concurrent task queue 2026-10-09 15:25:58 +08:00
toorux 38d3152a73 Add safe upload preflight and atomic conflict handling 2026-10-09 15:16:45 +08:00
toorux b124409470 Add guarded Steam Input passthrough for Lepton applications 2026-10-07 22:02:01 +08:00
141 changed files with 12122 additions and 318 deletions

No files matched your search

+44 -5
View File
@@ -29,7 +29,13 @@ jobs:
node-version: '22'
cache: npm
- name: Install native build dependencies
run: sudo apt-get update && sudo apt-get install -y g++ libgl1-mesa-dev libx11-dev
run: |
sudo apt-get update
sudo apt-get install -y g++ cmake pkg-config libgl1-mesa-dev libx11-dev \
libdrm-dev libvulkan-dev glslc libclang-dev libssl-dev libplist-dev \
libavahi-compat-libdnssd-dev libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev libgupnp-1.6-dev \
meson ninja-build libavfilter-dev libavformat-dev libavcodec-dev libavutil-dev
- name: Check release version
env:
RELEASE_REF: ${{ github.ref }}
@@ -48,6 +54,34 @@ 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
- 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
@@ -60,11 +94,16 @@ 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
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"
@@ -93,8 +132,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
@@ -102,5 +141,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"
+8
View File
@@ -15,3 +15,11 @@
# Generated UI review screenshots
/docs/previews/
/templates/plugin/payload/
/media/capture/target/
/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.6.0"
version = "0.8.0-preview.1"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.6.0"
version = "0.8.0-preview.1"
edition = "2021"
license = "AGPL-3.0-only"
+74
View File
@@ -0,0 +1,74 @@
# Framely 内置投屏
管理网页顶部的 **投屏** 面板使用侧边菜单分为 **网页观看**、**画面与声音**、**接收投屏** 和 **投到设备**。手机通过页面选择器切换,底部六个管理入口保持单行。设置保存在核心数据库。接收协议默认关闭;采集默认 **屏幕采集**,也可手动选择 **SteamVR**。没有自动采集模式,也不会因失败自行切换来源。
## 采集与网页观看
**屏幕采集**读取头显实际扫描输出,包括透视、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”,避免把参考实现的应用名当作采集方式。
- **单眼画面**:校正镜头畸变,读取 SteamVR 的主力眼或手动选左/右眼。使用实时完整视角预览,拖动取景框移动中心、拖动四角缩放,支持方向键和视野滑杆。开始调整会临时切换共享画面到完整参考视角,应用或取消后恢复之前的串流状态。画幅、分辨率和常见帧率使用预设按钮。
- **双眼原始画面**:保留扫描方向、双眼布局及镜头畸变。高度由原始比例和输出宽度计算,不使用主力眼或取景设置。
- **SteamVR**:读取 `/dev/video99`,由头显现有 FFmpeg 缩放、限帧及软件编码。实测输入固定为 RGB3 1920×1080;更改 V4L2 格式不能改变输入尺寸。设置帧率会改变驱动的报告值,但实际独立输入帧仍约 90 fps,因此设置调整的是串流输出。
启动一次采集,多个网页观看者共享同一条所选 H.264 或 H.265 + Opus 串流。管理页预览和 `/cast/watch` 使用同源 WHEP 信令代理和 WebRTC;沿用网络面板的登录与 Origin 检查。观看网页的视频占满视口,播放、音量、重连和全屏控件叠加在画面上,并在静止三秒后收起。始终等比显示完整视频,不裁切或拉伸;默认静音便于浏览器自动播放。手机适配安全区域和横竖屏。
网页优先协商用户选择的 H.264 或 H.265。当前官方 CEF 运行包不能解码所选编码时,头显自带 FFmpeg 以 libvpx 提供 VP8 + Opus 兼容输出;所有需要 VP8 的观看者共享一个额外的软件编码进程。采集来源不变。支持所选编码的观看者直接接收原串流,DLNA 仍使用 H.264。VP8 增加 CPU 消耗;兼容观看结束后收回该进程。Framely 不安装、调用或依赖外部 Chrome。
声音使用小片段和独立的有界输入队列,避免音频阻塞拖慢视频;编码使用无 B 帧和低延迟参数。SteamVR 的 H.265 是软件编码,会增加 CPU 负载;屏幕采集使用 Iris 硬件编码。
## 接收与发送
AirPlay 由固定版本 UxPlay 处理 RAOP 音频和屏幕镜像;只允许一个发送设备,不实现 AirPlay 发送。自定义 GStreamer sink 在用户确认前丢弃音频和画面。断开、拒绝或停止后清除同意状态,新连接重新询问。
DLNA 使用 GUPnP 的标准发现、SOAP 和事件订阅,接收 HTTP(S) 媒体地址。每个新请求先显示通知,接受后才下载、解码和播放。接收的音频打开内置控制窗口;视频采用旋转及像素比例校正后的实际画幅,控件浮在视频上并自动收起,不改变窗口画幅。不同 DLNA 请求可以保持多个空间窗口。播放器支持暂停、音量、停止以及媒体支持时的跳转;关闭空间窗口会停止该投屏。未处理请求 60 秒后过期。
GStreamer 解码结果经用户私人运行目录中的带锁 BGRA frame 文件交给原生 OpenGL 空间窗口,CEF 负责播放器控制,不承担视频解码。播放结束、错误及接收进程退出会关闭对应窗口并清理状态。
DLNA 发送需要开启网络管理面板并启动串流。GUPnP 发现设备后,使用该设备所在网卡的地址发送带随机会话令牌的 HLS 地址。FFmpeg 复制 H.264 视频,将 H.265 转为兼容的 H.264,并在需要时将 Opus 转为 AAC。令牌只允许读取该串流的播放列表和 MPEG-TS 分段,停止串流后失效。设备需要支持 HLS;接收端支持的具体媒体格式取决于头显已安装的 GStreamer 解码器。DRM 保护媒体不在此实现范围。
## 构建与打包
头显运行依赖现有 FFmpeg(libx264、libvpx、libopus、AAC、V4L2、PulseAudio)、GStreamer 1.24+、FFmpeg 7 共享库与随包的 gst-libav 插件、GUPnP 1.6、Avahi、OpenVR、Vulkan/DRM、Qualcomm Iris。设备上已确认 FFmpeg、GStreamer 1.24.2、GUPnP 1.6.6 和上述编码器可用。
构建需要 Rust、C/C++ 编译器、CMake、Meson、Ninja、pkg-config、glslc,以及 DRM/Vulkan、GStreamer app/video、GUPnP 1.6、Avahi、OpenSSL/libplist 开发文件。`tools/build-media.sh` 构建采集助手、接收器、gst-libav 插件和 UxPlay,并下载校验固定版本的 MediaMTX。可用 `FRAMELY_SYSROOT` 为采集 crate 指定 ARM64 sysroot;gst-libav 链接 FFmpeg 7 ABI,其余组件使用目标系统的开发库。
`tools/package-release.sh <CEF目录>` 同时构建媒体能力;助手放入 `bin`,其他媒体程序放入 `lib/media`。第三方许可证和媒体改动源码随包提供,版本和 SHA-256 位于 `media/dependencies.json`。
## 验证
```bash
CARGO_HOME=/tmp/framely-cast-cargo cargo test --locked
npm run typecheck
node tools/test-localization.mjs
bash tools/test-native.sh
python3 tools/test-casting-media.py --binaries media/bin --upnp media/upnp
```
`test-casting-media.py` 使用生成的测试媒体与临时音频 sink,不改变系统音频设置;验证 UxPlay 启动、确认门控、DLNA SOAP、已知色条的解码像素、暂停、音量、精确跳转、并发请求及超过会话上限次数的释放。测试结束回收进程和临时设备。
实机采集与实际 CEF WebRTC 测试:
```bash
FRAMELY_MEDIA_BIN=/绝对路径/media/bin cargo test --locked steamvr_stream_has_requested_output_and_stops_all_workers -- --ignored --nocapture
DISPLAY=:0 FRAMELY_MEDIA_BIN=/绝对路径/media/bin \
FRAMELY_TEST_ASSETS=/绝对路径/ui/dist FRAMELY_CAST_WEB_PORT=28189 \
FRAMELY_CEF_ROOT=/绝对路径/CEF运行目录 \
FRAMELY_CAST_BROWSER_PROBE=/绝对路径/cast-browser-probe \
cargo test --locked casting_browser_fixture -- --ignored --nocapture
```
`tests/casting_browser_probe.cpp` 可使用现有 CEF 开发头和 libcef 编译。运行时需要 ICU、资源和 locales 与测试可执行文件一起可用。测试使用独立的临时数据库、网络端口及浏览器缓存,检查网页解码尺寸、连续播放时间和实际帧数,不修改设备上已安装的 Framely。设置 `FRAMELY_CAST_TEST_SOURCE=screen` 可以通过已安装核心的私有 IPC 请求屏幕助手;设置 `FRAMELY_CAST_TEST_CODEC=h265` 验证 H.265;启动请求走实际 HTTP 接口,以验证媒体进程不随请求线程结束而退出。
需用户在头显上完成的验收:
1. 戴上/唤醒头显,以 root 执行 `tools/test-casting-panel.py --binaries /绝对路径/media/bin`,验证普通、取景及原始双眼三种屏幕输出。
2. 打开 **投屏**,验证默认屏幕串流、视野与主力眼设置、应用后输出变化、网页画面和声音。
3. 用真实 AirPlay 发送端分别发送音频和屏幕镜像,确认拒绝前没有声音或画面;接受后可控,断开后重连重新询问。
4. 用真实 DLNA 发送端打开两个媒体窗口,核对各自比例、独立暂停/停止,关闭一个不影响另一个。
5. 选择真实 DLNA 播放设备,验证头显串流播放和停止;比较 H.264 与 CEF 的 VP8 兼容输出在目标分辨率下的流畅度和延迟。
自动通过的协议/编解码测试不能替代真实发送端兼容性、头显内视觉比例和用户感知延迟验收。
`tests/cast_frame.cpp` 验证原生视频与透明控件的预乘 Alpha 合成、控件纹理保留和收起后的完整画面,需要 GLX 环境。
+1 -1
View File
@@ -76,7 +76,7 @@ Optional `quickPage` points to a payload bundle. `windows` defaults to an empty
| Field | Meaning |
| --- | --- |
| `entry` / `title` | Required payload entry and nonempty title ≤120 bytes |
| `dockIcon` | false by default; true creates a Dock window |
| `dockIcon` | false by default; controls Dock icon visibility only. Both values use the same SteamVR window controls |
| `width` / `height` | Default 1600×900; width 640–2560, height 360–1440 |
| `widthMeters` | Default 3.0; finite 0.4–4.0 meters. Source null is accepted but omitted during packaging, then reads back as default 3.0 |
| `localWeb` | false by default; specialized localhost windows obtain a URL from backend `window.get` |
+6 -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.6.0`), `.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.6.0
git push origin v0.6.0
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.
@@ -66,6 +66,8 @@ The entry fetches the release engine. Default is latest stable; `--version TAG`
Installation initializes the official update descriptor URL only if no source is configured, preserving existing sources for future in-app updates.
When run from Framely's built-in terminal, the package installer moves into an independent systemd service before stopping Framely. This prevents the terminal shutdown from killing the installer. Its output remains in the journal under the `framely-install-*` unit printed by the command; the UI closes during installation and starts again on completion.
## Unified uninstallation
Scripts and desktop UI expose one uninstall operation. It stops session/downloads, then calls root-only `prepare-uninstall --approve`: persistently disable plugins, stop them in dependency order, run uninstall hooks, remove payloads. Only complete success removes services/core/runtime. Failure retains Framely, restores session access and leaves plugins disabled for troubleshooting/retry. There is no skip-failed-plugin manager uninstall.
+25 -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
@@ -123,6 +125,28 @@ Steam Input loads SDL3 from Steam’s bundled ARM64 runtime and reads Steam’s
桥接以 Steam 会话用户运行,使用 Steam 随附的 ARM64 SDL3,仅读取 Steam 虚拟手柄。Framely 输出设备使用独立标识并排除自身,避免反馈循环。仅把专用事件节点及只读按键布局挂载进容器,不修改系统或 Steam 安装文件。Frame 直连使用独立的 SteamVR 身份;Steam Input 不注册 OpenVR 输入动作,避免影响 SDL 的虚拟手柄枚举。SDL3 不可用时明确报错,可手动选择 Frame 直连。日志位于 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键。APK 必须从管理面板或启动台启动,单次 CLI 不能持有持续桥接。
### Steam Input passthrough
**APK → Settings → Controller input source → Steam Input passthrough** mounts one Steam-generated event device directly into Android with the correct Steam key layout. It does not create another virtual gamepad or use SDL/OpenVR to remap input. The existing Steam Input bridge remains the default; Frame direct input remains available.
Passthrough chooses the lowest valid Steam controller slot, excluding Framely's own bridge output. It preserves both sticks, analog triggers, buttons and hats. Steam's native node does not supply digital L2/R2: use the bridge if a game needs these buttons or automatic switching between several controllers. The trigger threshold applies only to bridge modes.
A small Android input guard neutralizes background events and gates game-requested force feedback using the same controller-input and rumble switches. Expiring control leases stop input and feedback if the session helper disappears. Native force feedback goes directly to Steam's device; games must request controller vibration. Bridge-specific strength and duration limits do not apply to passthrough.
Only the selected device is mounted, not the entire host input directory. No SteamOS or Lepton installation files are changed. If the device node or Steam controller ownership changes, input stops; close and restart that container after reconnecting the controller. Framely never silently remounts another device or restarts a running game. Diagnostics are written to `~/.local/share/framely/apk-manager/logs/gamepad.log` without recording button events.
### Steam Input 直通
在 **APK → 设置 → 手柄输入来源 → Steam Input 直通** 中选择。仅将 Steam 生成的一只手柄事件设备及正确按键布局挂载进 Android,不创建第二只虚拟手柄,也不通过 SDL/OpenVR 重映射。现有 Steam Input 桥接仍为默认,Frame 直连保持可用。
直通选择编号最小的有效 Steam 手柄槽位,排除 Framely 自己生成的桥接输出。支持双摇杆、线性扳机、按键及十字键。Steam 原节点没有数字 L2/R2;需要这两个按键或多手柄自动切换的游戏可继续使用桥接。扳机阈值仅对桥接模式生效。
Android 输入保护层清零后台输入,并使用现有输入及震动开关控制游戏请求的力反馈。控制租约到期后停止输入和震动,避免会话辅助进程退出后继续响应。直通震动直接交给 Steam 原设备;游戏本身必须发出手柄震动请求。桥接专用的强度及持续时间限制不适用于直通。
只挂载选中的设备,不暴露整个主机输入目录,不修改 SteamOS 或 Lepton 安装文件。设备节点重建或 Steam 手柄归属变化后停止输入;重新连接后需关闭并重启该容器,不会静默切换到其他设备或重启游戏。日志位于 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键事件。
## Container lifecycle
The APK page provides two persistent switches. **Stop container after app exits** defaults off: after a confirmed launch, all target app processes must remain absent for 15 seconds before the container stops. Background apps and services keep it alive. **Stop container when closing its window** defaults on: a confirmed system window-close signal stops the associated container (normally within a few seconds). Switching windows, minimizing, and opening the launcher do not count as closing.
+1 -1
View File
@@ -19,7 +19,7 @@ The manager opens a separate large window with five sections: Plugin, APK, Termi
Enable, disable or favorite plugins in the list. Enabled means available for use; backends normally start on demand. Only plugins declaring `autostart` remain resident after startup. Closing a plugin window generally does not disable its backend. Dependency or conflict problems are explained, and affected plugins are shown before changes.
Dock windows use SteamVR close controls. Temporary windows may be destroyed when switching views. Individual plugin interfaces and behavior are maintained by their developers.
Plugin windows use the same SteamVR move, resize, keyboard and close controls, with or without a Dock icon. Individual plugin interfaces and behavior are maintained by their developers.
## Settings and diagnostics
+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
+21 -3
View File
@@ -6,10 +6,18 @@
## Manager navigation
The top switcher contains **Plugin / APK / Terminal / Files / Settings**. Opening the device IP without a fragment starts in Plugin. Each section remembers its last subpage in the browser. Existing `#settings`, `#launcher-settings`, `#about` and `#updates` links continue to work.
On desktop, the top switcher contains **Plugin / APK / Terminal / Files / Settings**. Opening the device IP without a fragment starts in Plugin. Each section remembers its last subpage in the browser. Existing `#settings`, `#launcher-settings`, `#about` and `#updates` links continue to work.
Plugin contains installed plugins, the plugin library, sources and notification settings, including Framely's notifications. Plugin-specific settings remain in each plugin's management dialog. APK keeps its apps, containers and retained-data cleanup. Settings contains General, Launcher, Updates and About.
## Mobile controls
On phones, Plugin, APK, Terminal, Files and Settings use a bottom navigation bar. Tap the current page name in the header to choose a subpage. Dialogs open as bottom sheets with touch-sized controls. The layout reserves device safe areas and adjusts to the on-screen keyboard; the bottom navigation hides while the keyboard is open.
In Files, **Browse folders** opens Quick access and the folder tree in a drawer. Selecting a folder closes the drawer. **Create and upload** and **Filters and view** open task sheets. Tap **Select**, select files, then use **Actions** in the selection bar for copying, moving, downloading or deleting. No right-click is needed. The upload queue continues while browsing or changing sections.
Terminal provides Ctrl+C, Esc, Tab and arrow buttons above the keyboard. Ctrl+C interrupts the running program. **Paste** uses the browser clipboard when permitted; otherwise it opens a text field for the phone's native paste action and requires **Send to terminal**. The bar disables input while disconnected or read-only.
## Interaction feedback
In **Settings → General → Interaction feedback**, Haptic feedback is enabled by default; Sound feedback is disabled by default. Both preferences are saved across restarts. Haptic feedback controls laser hover and launcher gaze-focus vibration across Framely and plugin pages; it does not change APK game rumble. Sound feedback uses Steam’s installed keyboard sound at reduced volume for focus and clicks. It requires the local Steam sound files and audio runtime; no sound files are bundled or downloaded.
@@ -38,7 +46,7 @@ The file list provides hidden-file visibility, sorting, checkboxes for multiple
**Actions** provides copy, cut, paste, rename, delete, properties, permission editing, compression, extraction and opening files. Right-click a file or folder to open a menu at the pointer; right-clicking a selected item preserves the multi-selection. Right-click the empty list area for new file/folder, paste, upload and refresh actions. Escape or clicking elsewhere closes the menu. Symbolic links have an arrow marker and their target in Properties. Copy preserves links; deleting a link does not delete its target. Special filesystem objects cannot be copied or archived.
Copy, move, upload and extraction let you skip conflicts, keep both names or confirm replacement for the batch. Replacement of a same-name folder replaces that whole folder; it does not merge its contents. Long tasks show actual bytes or completed entries, can be cancelled, and report individual failures. Completed items remain completed when a later item fails or the task is cancelled. Switching manager sections keeps active file work and editor state in the current page.
Copy, move and extraction let you skip conflicts, keep both names or confirm replacement for the batch. Replacement of a same-name folder replaces that whole folder; it does not merge its contents. Long tasks show actual bytes or completed entries, can be cancelled, and report individual failures. Completed items remain completed when a later item fails or the task is cancelled. Switching manager sections keeps active file work and editor state in the current page.
## Recycle bin
@@ -48,7 +56,17 @@ The recycle bin supports restoring with an unused name or keeping both copies, p
## Transfer and edit
Upload multiple files or a folder from your browser. Transfers use 1 MiB chunks, byte progress and private temporary files; only a completed upload receives its final filename. Cancelled uploads are removed and hourly maintenance removes transfers idle for at least an hour. A stopped service may leave clearly named `.framely-upload-*` temporary files, never a falsely complete final file. Uploads are limited to 64 GiB per file and 64 simultaneous transfer records.
Drag files, folders or a mixed selection from your computer onto a folder in the list, icon grid, sidebar or breadcrumbs. Drop onto empty file-list space to upload into the current folder. The highlighted target shows its path. Files, the recycle-bin content and preview overlays are not upload destinations.
Before any writing, a confirmation shows the canonical destination path, counts and total size. Choose **Skip**, **Overwrite** or **Keep both** (default). Existing folders are merged; the policy applies only to same-name files and other destination files are retained. Skip is checked before transfer. Overwrite replaces the original only after successful completion; keep-both results show the new filename. Type conflicts fail individually. Destination changes and nested symbolic-link traversal are rejected.
Click **Upload queue** to open a compact floating panel without reducing the file-list area. Each batch shows uploaded files and total files; folders are excluded and skipped files are not counted as uploaded. The queue groups selections by destination and provides individual/batch cancellation, retry of failed tasks and removal of finished records. **Simultaneous file uploads** defaults to 3, accepts 1–8, and is saved for the device user independently of view and folder sorting. Increasing it starts more work immediately; reducing it lets active files finish. Each file uses at most two chunk requests; the queue has at most eight chunk requests in flight. Browsing and changing manager sections do not stop uploads or change their destination.
Folder drag-and-drop preserves empty folders. The Upload folder button uses the browser directory API when available; its legacy file-input fallback can only return files, so use drag-and-drop for empty folders. Large directories are enumerated asynchronously; folder readers are drained until empty. A selection may contain at most 100,000 entries.
The queue lasts only for this browser page. Refreshing or closing warns about unfinished work and attempts cancellation; server inactivity cleanup is the fallback. Completed files are retained. Failed transfers retry from the beginning; there is no cross-refresh resume.
Upload multiple files or a folder from your browser. Transfers use 16 MiB chunks, byte progress and private temporary files; only a completed upload receives its final filename. Cancelled uploads are removed and hourly maintenance removes transfers idle for at least an hour. A stopped service may leave clearly named `.framely-upload-*` temporary files, never a falsely complete final file. Uploads are limited to 64 GiB per file and 64 simultaneous transfer records.
Download a single file directly, or select multiple files/folders to download a ZIP. Download links expire after an hour. Large files stream without loading the entire file into memory; single-byte ranges support video seeking and resuming downloads.
+1 -1
View File
@@ -76,7 +76,7 @@
| 字段 | 说明 |
| --- | --- |
| `entry` / `title` | 必填;入口需在 payload;标题非空且最多 120 字节 |
| `dockIcon` | 默认 false,true 为独立 Dock 窗口 |
| `dockIcon` | 默认 false,仅控制是否显示 Dock 图标;两种窗口均使用相同的 SteamVR 窗口控件 |
| `width` / `height` | 默认 1600×900;宽 640–2560,高 360–1440 |
| `widthMeters` | 默认 3.0;有效值 0.4–4.0 米。源码可读取 null,但打包会省略,再读取采用默认 3.0 |
| `localWeb` | 默认 false;特殊本机网页窗口由后端 `window.get` 提供 localhost URL |
+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.6.0`);`.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.6.0
git push origin v0.6.0
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 重定向,不允许降级。
+15 -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 快捷方式;不支持的包格式会明确拒绝。
### 平面窗口方向
@@ -154,3 +158,13 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
## 单应用图形兼容
在**管理应用 → 高级设置**中,“帧缓冲兼容修复”默认关闭。仅为有声音但黑屏的游戏开启(已在当前 Mesa/Zink 驱动下验证洛克王国),修改后需重启应用。GLES 修复层只在所选应用中,于绑定纹理后检查帧缓冲状态,不改变其他应用、系统驱动、游戏文件或存档。关闭后,下次启动恢复正常渲染路径。这是针对特定问题的兼容措施,并非通用画质或性能优化。
### Steam Input 直通
在 **APK → 设置 → 手柄输入来源 → Steam Input 直通** 中选择。仅将 Steam 生成的一只手柄事件设备及正确按键布局挂载进 Android,不创建第二只虚拟手柄,也不通过 SDL/OpenVR 重映射。现有 Steam Input 桥接仍为默认,Frame 直连保持可用。
直通选择编号最小的有效 Steam 手柄槽位,排除 Framely 自己生成的桥接输出。支持双摇杆、线性扳机、按键及十字键。Steam 原节点没有数字 L2/R2;需要这两个按键或多手柄自动切换的游戏可继续使用桥接。扳机阈值仅对桥接模式生效。
Android 输入保护层清零后台输入,并使用现有输入及震动开关控制游戏请求的力反馈。控制租约到期后停止输入和震动,避免会话辅助进程退出后继续响应。直通震动直接交给 Steam 原设备;游戏本身必须发出手柄震动请求。桥接专用的强度及持续时间限制不适用于直通。
只挂载选中的设备,不暴露整个主机输入目录,不修改 SteamOS 或 Lepton 安装文件。设备节点重建或 Steam 手柄归属变化后停止输入;重新连接后需关闭并重启该容器,不会静默切换到其他设备或重启游戏。日志位于 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键事件。
+1 -1
View File
@@ -19,7 +19,7 @@
在插件列表启用、停用或收藏插件。启用表示允许使用;默认后端按需启动,只有声明 `autostart` 的插件会常驻。关闭插件窗口通常不会停用后台功能。依赖不满足或有冲突时,界面会给出原因,并在变更前显示受影响插件。
带 Dock 图标的独立窗口由 SteamVR 提供关闭控件;临时窗口可能在切换界面时销毁。不同插件的 UI 和功能由各自开发者决定。
无论是否带 Dock 图标,插件窗口都使用相同的 SteamVR 移动、缩放、键盘和关闭控件。不同插件的 UI 和功能由各自开发者决定。
## 设置与诊断
+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
+20 -2
View File
@@ -10,6 +10,14 @@
插件区域包含已安装插件、插件库、来源和通知设置,通知仍包含 Framely 本体;插件自身设置位于对应插件的管理弹窗。APK 保留应用、容器及数据清理。设置集中放置通用、启动台、更新和关于。
## 手机操作
手机端底部导航包含插件、APK、终端、文件和设置。点击顶部当前页面名称选择子页面,确认和安装弹窗以底部面板展示。界面为安全区和软键盘保留空间,键盘展开时隐藏底部导航。
文件页点击“浏览文件夹”打开快速访问与目录树抽屉,选择目录后自动收起。“新建与上传”和“筛选与显示”打开操作面板;点击“选择”勾选文件,再从底部选择栏的“操作”进入复制、移动、下载或删除,无需右键。浏览目录和切换区域不停止上传队列。
终端提供 Ctrl+C、Esc、Tab 和方向快捷键,其中 Ctrl+C 用于中断程序。“粘贴”在浏览器允许时读取剪贴板,否则打开文本框,使用手机原生粘贴后点击“发送到终端”。断连或只读时禁用输入快捷键。
## 交互反馈
在 **设置 → 通用 → 交互反馈** 中,振动反馈默认开启,提示音反馈默认关闭,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机键盘音效,以较低音量反馈聚焦和点击,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
@@ -36,7 +44,7 @@
“操作”提供复制、剪切、粘贴、重命名、删除、属性、权限修改、压缩、解压及打开。右键文件会在指针处打开操作菜单。符号链接显示箭头,属性显示目标;复制保留链接,删除链接不会删除目标。特殊文件不能复制或压缩。
复制、移动、上传、解压可按批次选择跳过、保留两份或明确确认覆盖。同名文件夹覆盖会替换整个文件夹,不合并内容。长任务显示真实字节或已完成条目,可取消并逐项报告错误;后续失败或取消不撤销已完成的项目。在当前网页内切换区域会保留文件任务及编辑状态。
复制、移动、解压可按批次选择跳过、保留两份或明确确认覆盖。同名文件夹覆盖会替换整个文件夹,不合并内容。长任务显示真实字节或已完成条目,可取消并逐项报告错误;后续失败或取消不撤销已完成的项目。在当前网页内切换区域会保留文件任务及编辑状态。
拖动侧栏与文件列表之间的分隔线可调整宽度,并在当前浏览器中记住;窄窗口会自动限制宽度。快速访问固定在顶部一定高度,目录树占据侧栏剩余高度;两个列表分别在内部滚动,标题保持可见,从右侧列表或地址栏进入文件夹时,目录树会展开并标记对应位置。
@@ -50,7 +58,17 @@
## 传输与编辑
网页可上传多个文件或文件夹,使用 1 MiB 分块和字节进度,先写私有临时文件,完成后才获得最终文件名。取消会移除临时文件,约每小时清理闲置至少一小时的上传。服务被强行停止可能留下明确命名的 `.framely-upload-*` 临时文件,不会冒充完整结果。每个文件最大 64 GiB,同时最多保存 64 条上传记录。
从电脑拖入文件、文件夹或混合选择。列表、图标视图、侧边目录树、快速访问及面包屑中的文件夹均为上传目标;列表空白区域对应当前目录。拖动时高亮目标并显示路径,文件条目、回收站内容及预览遮罩不接受上传。
写入前弹窗确认真实目标路径、文件与文件夹数量及总大小,并选择跳过、覆盖或保留两者,默认保留两者。同名文件夹合并,策略只处理同名文件,已有其他文件保留。跳过在传输前判断;覆盖在上传完成后原子替换;保留两者会显示新文件名。类型冲突逐项报错,目标目录变化及嵌套符号链接逃逸会被拒绝。
点击“上传任务队列”按钮打开紧凑悬浮面板,不挤占文件列表。队列按批次和目标分组,显示已上传文件数与总文件数(不包含文件夹,跳过的文件不计为已上传),可取消单项或整批、重试失败项及清除已结束记录。“同时上传文件数”默认 3,可设置 1–8,按设备用户全局保存,不改变展示方式和目录排序。增加立即补充任务,减少时让正在上传的文件完成。每文件最多两个分块请求,整个队列最多八个分块请求。上传不锁住文件页面,切换目录或管理区域后继续,目标保持确认时的路径。
拖入文件夹会保留空文件夹。上传文件夹按钮优先使用浏览器目录 API;传统文件输入框只能返回文件,空文件夹请通过拖放上传。大目录异步枚举,完整读取浏览器分批返回的条目,每次最多 100,000 项。
队列仅保留在当前浏览器页面,刷新或关闭前提醒,并尽力取消活动上传;服务端按闲置时间兜底清理。已完成文件保留,失败项从头重试,不提供跨刷新续传。
网页可上传多个文件或文件夹,使用 16 MiB 分块和字节进度,先写私有临时文件,完成后才获得最终文件名。取消会移除临时文件,约每小时清理闲置至少一小时的上传。服务被强行停止可能留下明确命名的 `.framely-upload-*` 临时文件,不会冒充完整结果。每个文件最大 64 GiB,同时最多保存 64 条上传记录。
单文件可直接下载,多选文件或文件夹以 ZIP 下载;下载链接一小时后失效。大文件流式传输,单段 Range 请求支持视频拖动和下载续传。
+32
View File
@@ -0,0 +1,32 @@
# Media components
- Panel capture code in `capture/src/{encoder,gpu,gpu_copy,kms,lut,grab,openvr}.rs`
and `capture/shaders/convert.comp` derives from [coah80/framecorder](https://github.com/coah80/framecorder), MIT.
The original license is preserved in `capture/LICENSE.framecorder`.
Framely adds streaming output, field-of-view offsets, browser-compatible H.264
encoding and a core-managed framebuffer helper. It does not invoke or require
the Framecorder application or plugin.
- [MediaMTX](https://github.com/bluenviron/mediamtx), MIT, supplies RTSP, WHEP/WebRTC
and HLS transport. Its version, source URL and archive checksum are pinned in
`dependencies.json`; the release includes its license.
- [UxPlay](https://github.com/FDH2/UxPlay), GPL-3.0-or-later, supplies AirPlay/RAOP
reception. Its version and source checksum are pinned in `dependencies.json`.
`patch-uxplay.py` adds session-end events and enforces one sending device.
Releases include the patched source archive, build recipe and UxPlay/llhttp licenses.
- [gst-libav](https://gstreamer.freedesktop.org/modules/gst-libav.html), LGPL-2.1-or-later,
is bundled as a GStreamer plugin linked to the installed system FFmpeg libraries.
Its version and checksum are pinned; releases include its source and license.
CI links against the FFmpeg 7 development API to match SteamOS; that build
dependency is pinned but its FFmpeg libraries are not distributed.
- GStreamer and GUPnP are linked as system libraries (LGPL). GStreamer performs
decoding, audio playback, rotation and pixel aspect correction; GUPnP provides
UPnP discovery, SOAP and event subscriptions. FFmpeg is invoked as the installed
system executable for SteamVR capture, software encoding and audio muxing.
These system components are not copied into Framely packages.
- `native/json.hpp` is the existing nlohmann/json dependency (MIT), included with
the main Framely release notices.
Framely's added code uses the repository's AGPL-3.0-only license. See
`docs/casting.md` for build dependencies and verification commands. Source and
licenses for the bundled media additions ship under `share/source/media` and
`share/licenses` in a release.
+750
View File
@@ -0,0 +1,750 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "ash"
version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
dependencies = [
"libloading",
]
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.13.2",
"cexpr",
"clang-sys",
"itertools",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn 2.0.119",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "clang-sys"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "drm"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a41816e58f47f49acfd956651055ddcf137c4882c2098c30c448817af21183a"
dependencies = [
"bitflags 2.13.2",
"bytemuck",
"bytemuck_derive",
"drm-ffi",
"drm-fourcc",
"libc",
"rustix",
]
[[package]]
name = "drm-ffi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690"
dependencies = [
"drm-sys",
"rustix",
]
[[package]]
name = "drm-fourcc"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4"
[[package]]
name = "drm-sys"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8"
dependencies = [
"libc",
"linux-raw-sys 0.9.4",
]
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "enumn"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "env_filter"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "framely-capture"
version = "0.1.0"
dependencies = [
"anyhow",
"ash",
"clap",
"drm",
"env_logger",
"libc",
"log",
"serde_json",
"v4l2r",
]
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-core"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt",
"log",
]
[[package]]
name = "jiff-static"
version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "linux-raw-sys"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "portable-atomic-util"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
dependencies = [
"portable-atomic",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags 2.13.2",
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl 2.0.21",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "v4l2r"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67d06043afd259b7e3f0b70bb8cc590b35c99006abc595399956392b4ebbe717"
dependencies = [
"anyhow",
"bindgen",
"bitflags 2.13.2",
"enumn",
"log",
"nix",
"pastey",
"thiserror 1.0.69",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "framely-capture"
version = "0.1.0"
edition = "2021"
license = "AGPL-3.0-only"
[dependencies]
anyhow = "1"
ash = { version = "0.38", default-features = false, features = ["loaded", "debug"] }
clap = { version = "4", features = ["derive"] }
drm = "0.15"
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
libc = "0.2"
log = "0.4"
serde_json = "1"
v4l2r = "0.0.8"
[profile.release]
lto = "thin"
strip = true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 coah80
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16
View File
@@ -0,0 +1,16 @@
use std::path::PathBuf;
use std::process::Command;
fn main() {
let out = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let src = "shaders/convert.comp";
println!("cargo::rerun-if-changed={src}");
let status = Command::new("glslc")
.args(["-O", "--target-env=vulkan1.1", "-o"])
.arg(out.join("convert.spv"))
.arg(src)
.status()
.expect("glslc not found, install shaderc");
assert!(status.success(), "failed to compile {src}");
}
+227
View File
@@ -0,0 +1,227 @@
#version 450
// Turns the panel scanout into NV12 for the hardware encoder in one pass.
//
// Every output pixel gets its source position from a coarse lookup grid. The
// grid either maps the output to a straight crop of the panel (raw view) or
// undoes the lens distortion for one eye. Each grid point stores a position per
// color channel, because the compositor pre-corrects chromatic aberration and
// we need to undo that too. A negative position means "outside the lens", which
// gets painted black.
//
// One invocation writes a 4x2 block: two words of luma and one word of
// interleaved chroma, so there are no byte stores and no atomics. A workgroup
// covers 32x16 pixels, which is exactly 4x2 grid cells, so the workgroup pulls
// the grid points it needs into shared memory once and every invocation reads
// its cell from there.
#define STEP 8u
#define WG_X 8u
#define WG_Y 8u
#define CELLS_X (WG_X * 4u / STEP)
#define CELLS_Y (WG_Y * 2u / STEP)
#define POINTS_X (CELLS_X + 1u)
#define POINTS_Y (CELLS_Y + 1u)
#define POINTS (POINTS_X * POINTS_Y)
layout(local_size_x = WG_X, local_size_y = WG_Y, local_size_z = 1) in;
layout(set = 0, binding = 0) uniform sampler2D src_tex;
layout(set = 0, binding = 1, std430) readonly buffer Lut { vec2 lut[]; };
layout(set = 0, binding = 2, std430) writeonly buffer Nv12 { uint nv12[]; };
layout(push_constant) uniform Params {
uvec2 out_size;
uvec2 lut_size;
uint y_stride;
uint uv_offset;
float lut_step;
uint flags;
} p;
const uint FLAG_PER_CHANNEL = 1u;
const uint FLAG_SUPERSAMPLE = 2u;
const uint FLAG_ROTATED_EYES = 4u;
const uint FLAG_SHARP = 8u;
// Fringes narrower than this, in output pixels, aren't worth extra reads.
const float FRINGE_MIN = 0.3;
shared vec2 s_lut[3u * POINTS];
// One grid cell for one channel: a corner plus how the source moves per
// output pixel along x and y, and the bilinear twist term.
struct Cell {
vec2 origin;
vec2 dx;
vec2 dy;
vec2 dxy;
bool valid;
};
Cell load_cell(uint i, uint j, uint c) {
uint base = c * POINTS + j * POINTS_X + i;
vec2 a = s_lut[base];
vec2 b = s_lut[base + 1u];
vec2 d = s_lut[base + POINTS_X];
vec2 e = s_lut[base + POINTS_X + 1u];
Cell cell;
cell.valid = min(min(a.x, b.x), min(d.x, e.x)) >= 0.0;
const float inv = 1.0 / float(STEP);
cell.origin = a;
cell.dx = (b - a) * inv;
cell.dy = (d - a) * inv;
cell.dxy = (e - d - b + a) * (inv * inv);
return cell;
}
// Largest distance between two channels' positions at the cell's corners.
float cell_split(Cell a, Cell b) {
const float s = float(STEP);
vec2 o = a.origin - b.origin;
vec2 x = (a.dx - b.dx) * s;
vec2 y = (a.dy - b.dy) * s;
vec2 xy = (a.dxy - b.dxy) * (s * s);
return max(max(length(o), length(o + x)), max(length(o + y), length(o + x + y + xy)));
}
vec2 cell_uv(Cell cell, vec2 local) {
return cell.origin + cell.dx * local.x + cell.dy * local.y + cell.dxy * (local.x * local.y);
}
// The grid is in the eyes' own panel space, as SteamVR describes it. The
// Frame scans each eye's half out turned 180° in place, so turn it back
// here, per sample, which keeps the seam between the eyes clean.
// Catmull-Rom in 5 bilinear taps (the four far corners dropped, they
// barely matter). Crisper than plain bilinear, which matters here because
// the panel image has already been resampled once by the compositor.
vec4 catmull_rom(vec2 uv) {
vec2 size = vec2(textureSize(src_tex, 0));
vec2 pos = uv * size;
vec2 center = floor(pos - 0.5) + 0.5;
vec2 f = pos - center;
vec2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
vec2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
vec2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
vec2 w3 = f * f * (-0.5 + 0.5 * f);
vec2 w12 = w1 + w2;
vec2 tc0 = (center - 1.0) / size;
vec2 tc3 = (center + 2.0) / size;
vec2 tc12 = (center + w2 / w12) / size;
vec4 c = textureLod(src_tex, vec2(tc12.x, tc0.y), 0.0) * (w12.x * w0.y)
+ textureLod(src_tex, vec2(tc0.x, tc12.y), 0.0) * (w0.x * w12.y)
+ textureLod(src_tex, tc12, 0.0) * (w12.x * w12.y)
+ textureLod(src_tex, vec2(tc3.x, tc12.y), 0.0) * (w3.x * w12.y)
+ textureLod(src_tex, vec2(tc12.x, tc3.y), 0.0) * (w12.x * w3.y);
float norm = w12.x * w0.y + w0.x * w12.y + w12.x * w12.y + w3.x * w12.y + w12.x * w3.y;
return max(c / norm, 0.0);
}
vec4 fetch(vec2 uv) {
if ((p.flags & FLAG_ROTATED_EYES) != 0u) {
uv = vec2(uv.x < 0.5 ? 0.5 - uv.x : 1.5 - uv.x, 1.0 - uv.y);
}
if ((p.flags & FLAG_SHARP) != 0u) {
return catmull_rom(uv);
}
return textureLod(src_tex, uv, 0.0);
}
vec4 sample_at(Cell cell, vec2 local) {
vec2 uv = cell_uv(cell, local);
if ((p.flags & FLAG_SUPERSAMPLE) == 0u) {
return fetch(uv);
}
// 4 taps on a rotated grid, a cheap box filter for when we shrink.
vec2 fx = cell.dx + cell.dxy * local.y;
vec2 fy = cell.dy + cell.dxy * local.x;
return 0.25 * (fetch(uv + fx * -0.375 + fy * -0.125) +
fetch(uv + fx * 0.125 + fy * -0.375) +
fetch(uv + fx * 0.375 + fy * 0.125) +
fetch(uv + fx * -0.125 + fy * 0.375));
}
// BT.709, limited range. The scanout is already gamma encoded, which is what
// the video matrix expects, so no transfer conversion here.
uint luma(vec3 c) {
float y = dot(c, vec3(0.2126, 0.7152, 0.0722));
return uint(clamp(16.0 + 219.0 * y + 0.5, 0.0, 255.0));
}
uint chroma(vec3 c) {
float y = dot(c, vec3(0.2126, 0.7152, 0.0722));
float cb = (c.b - y) / 1.8556;
float cr = (c.r - y) / 1.5748;
uint u = uint(clamp(128.0 + 224.0 * cb + 0.5, 0.0, 255.0));
uint v = uint(clamp(128.0 + 224.0 * cr + 0.5, 0.0, 255.0));
return u | (v << 8);
}
void load_grid() {
uint li = gl_LocalInvocationIndex;
uint channels = (p.flags & FLAG_PER_CHANNEL) != 0u ? 3u : 1u;
if (li < channels * POINTS) {
// Green goes in slot 1 either way, so single channel mode only loads that.
uint c = channels == 3u ? li / POINTS : 1u;
uint r = li % POINTS;
uint gx = min(gl_WorkGroupID.x * CELLS_X + r % POINTS_X, p.lut_size.x - 1u);
uint gy = min(gl_WorkGroupID.y * CELLS_Y + r / POINTS_X, p.lut_size.y - 1u);
s_lut[c * POINTS + r] = lut[(gy * p.lut_size.x + gx) * 3u + c];
}
barrier();
}
void main() {
load_grid();
uint x0 = gl_GlobalInvocationID.x * 4u;
uint y0 = gl_GlobalInvocationID.y * 2u;
if (x0 >= p.out_size.x || y0 >= p.out_size.y) {
return;
}
uint ci = gl_LocalInvocationID.x * 4u / STEP;
uint cj = gl_LocalInvocationID.y * 2u / STEP;
vec2 base = vec2(float(x0 % STEP), float(y0 % STEP)) + 0.5;
Cell green = load_cell(ci, cj, 1u);
Cell red = green;
Cell blue = green;
bool per_channel = false;
if ((p.flags & FLAG_PER_CHANNEL) != 0u) {
red = load_cell(ci, cj, 0u);
blue = load_cell(ci, cj, 2u);
// Near the middle of the lens the channels line up; one read per
// pixel is enough there, and that's most of the picture.
float limit = FRINGE_MIN * length(green.dx);
per_channel = cell_split(red, green) > limit || cell_split(blue, green) > limit;
}
vec3 px[8];
for (uint k = 0u; k < 8u; k++) {
vec2 local = base + vec2(float(k & 3u), float(k >> 2));
if (!green.valid) {
px[k] = vec3(0.0);
} else if (!per_channel) {
px[k] = sample_at(green, local).rgb;
} else {
px[k] = vec3(red.valid ? sample_at(red, local).r : 0.0,
sample_at(green, local).g,
blue.valid ? sample_at(blue, local).b : 0.0);
}
}
uint row0 = 0u;
uint row1 = 0u;
for (uint dx = 0u; dx < 4u; dx++) {
row0 |= luma(px[dx]) << (8u * dx);
row1 |= luma(px[4u + dx]) << (8u * dx);
}
nv12[(y0 * p.y_stride + x0) >> 2] = row0;
nv12[((y0 + 1u) * p.y_stride + x0) >> 2] = row1;
vec3 left = (px[0] + px[1] + px[4] + px[5]) * 0.25;
vec3 right = (px[2] + px[3] + px[6] + px[7]) * 0.25;
nv12[(p.uv_offset + (y0 >> 1) * p.y_stride + x0) >> 2] = chroma(left) | (chroma(right) << 16);
}
@@ -0,0 +1 @@
fn main() { if let Err(e) = framely_capture::grab::serve(&std::path::PathBuf::from("/dev/dri/card0")) { eprintln!("{e:#}"); std::process::exit(1); } }
+8
View File
@@ -0,0 +1,8 @@
use std::time::Duration;
/// CLOCK_MONOTONIC, the same clock DRM vblank stamps and PipeWire use.
pub fn now() -> Duration {
let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)
}
+572
View File
@@ -0,0 +1,572 @@
//! Drives the Snapdragon's hardware video encoder (qcom iris) through the
//! V4L2 stateful encoder interface. Frames go in as dmabufs straight from the
//! GPU, compressed packets come out of mmapped buffers.
use std::fs::{File, OpenOptions};
use std::io;
use std::os::fd::{AsRawFd, RawFd};
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use v4l2r::bindings as v4l2;
use crate::gpu::Nv12Layout;
const BUF_TYPE_CAPTURE: u32 = 9; // VIDEO_CAPTURE_MPLANE
const BUF_TYPE_OUTPUT: u32 = 10; // VIDEO_OUTPUT_MPLANE
const MEMORY_MMAP: u32 = 1;
const MEMORY_DMABUF: u32 = 4;
const FIELD_NONE: u32 = 1;
const BUF_FLAG_KEYFRAME: u32 = 0x8;
const BUF_FLAG_ERROR: u32 = 0x40;
const BUF_FLAG_LAST: u32 = 0x0010_0000;
const ENC_CMD_STOP: u32 = 1;
const COLORSPACE_REC709: u32 = 3;
const YCBCR_ENC_709: u8 = 2;
const QUANTIZATION_LIM_RANGE: u8 = 2;
const XFER_FUNC_709: u8 = 1;
const CID_CODEC_BASE: u32 = 0x0099_0900;
const CID_B_FRAMES: u32 = CID_CODEC_BASE + 202;
const CID_GOP_SIZE: u32 = CID_CODEC_BASE + 203;
const CID_BITRATE_MODE: u32 = CID_CODEC_BASE + 206;
const CID_BITRATE: u32 = CID_CODEC_BASE + 207;
const CID_BITRATE_PEAK: u32 = CID_CODEC_BASE + 208;
const CID_FRAME_RC_ENABLE: u32 = CID_CODEC_BASE + 215;
const CID_HEADER_MODE: u32 = CID_CODEC_BASE + 216;
const CID_FORCE_KEY_FRAME: u32 = CID_CODEC_BASE + 229;
const CID_H264_I_FRAME_QP: u32 = CID_CODEC_BASE + 350;
const CID_H264_P_FRAME_QP: u32 = CID_CODEC_BASE + 351;
const CID_H264_LEVEL: u32 = CID_CODEC_BASE + 359;
const CID_H264_PROFILE: u32 = CID_CODEC_BASE + 363;
const CID_HEVC_I_FRAME_QP: u32 = CID_CODEC_BASE + 602;
const CID_HEVC_P_FRAME_QP: u32 = CID_CODEC_BASE + 603;
const CID_HEVC_PROFILE: u32 = CID_CODEC_BASE + 615;
const CID_HEVC_LEVEL: u32 = CID_CODEC_BASE + 616;
const CID_HEVC_TIER: u32 = CID_CODEC_BASE + 618;
const CAPTURE_BUFFERS: u32 = 4;
const STOP_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum Codec {
#[value(name = "h265", alias = "hevc")]
Hevc,
H264,
}
impl Codec {
fn fourcc(self) -> u32 {
u32::from_le_bytes(match self {
Codec::Hevc => *b"HEVC",
Codec::H264 => *b"H264",
})
}
}
#[derive(Clone, Copy, Debug)]
pub struct Config {
pub codec: Codec,
pub width: u32,
pub height: u32,
pub fps: u32,
pub bitrate: u32,
pub qp: Option<u32>,
}
pub struct Packet<'a> {
pub data: &'a [u8],
pub pts_us: u64,
pub key: bool,
}
struct Mapping {
ptr: *mut u8,
len: usize,
}
impl Drop for Mapping {
fn drop(&mut self) {
unsafe { libc::munmap(self.ptr.cast(), self.len) };
}
}
pub struct Encoder {
file: File,
layout: Nv12Layout,
outputs: Vec<bool>,
captures: Vec<Mapping>,
streaming: bool,
}
// The mmapped capture buffers are only touched from the thread that owns the encoder.
unsafe impl Send for Encoder {}
// asm-generic ioctl encoding, which is what arm64 uses.
const fn ioc(dir: u32, nr: u32, size: usize) -> libc::c_ulong {
((dir << 30) | ((size as u32) << 16) | ((b'V' as u32) << 8) | nr) as libc::c_ulong
}
const RW: u32 = 3;
const W: u32 = 1;
const VIDIOC_S_FMT: libc::c_ulong = ioc(RW, 5, std::mem::size_of::<v4l2::v4l2_format>());
const VIDIOC_REQBUFS: libc::c_ulong = ioc(RW, 8, std::mem::size_of::<v4l2::v4l2_requestbuffers>());
const VIDIOC_QUERYBUF: libc::c_ulong = ioc(RW, 9, std::mem::size_of::<v4l2::v4l2_buffer>());
const VIDIOC_QBUF: libc::c_ulong = ioc(RW, 15, std::mem::size_of::<v4l2::v4l2_buffer>());
const VIDIOC_DQBUF: libc::c_ulong = ioc(RW, 17, std::mem::size_of::<v4l2::v4l2_buffer>());
const VIDIOC_STREAMON: libc::c_ulong = ioc(W, 18, std::mem::size_of::<libc::c_int>());
const VIDIOC_STREAMOFF: libc::c_ulong = ioc(W, 19, std::mem::size_of::<libc::c_int>());
const VIDIOC_S_PARM: libc::c_ulong = ioc(RW, 22, std::mem::size_of::<v4l2::v4l2_streamparm>());
const VIDIOC_S_CTRL: libc::c_ulong = ioc(RW, 28, std::mem::size_of::<v4l2::v4l2_control>());
const VIDIOC_ENCODER_CMD: libc::c_ulong = ioc(RW, 77, std::mem::size_of::<EncoderCmd>());
const VIDIOC_S_SELECTION: libc::c_ulong = ioc(RW, 95, std::mem::size_of::<v4l2::v4l2_selection>());
const BUF_TYPE_OUTPUT_SINGLE: u32 = 2;
const SEL_TGT_CROP: u32 = 0;
#[repr(C)]
#[derive(Default)]
struct EncoderCmd {
cmd: u32,
flags: u32,
data: [u32; 8],
}
unsafe fn xioctl<T>(fd: RawFd, req: libc::c_ulong, arg: *mut T) -> io::Result<()> {
loop {
if libc::ioctl(fd, req, arg) == 0 {
return Ok(());
}
let err = io::Error::last_os_error();
if err.kind() != io::ErrorKind::Interrupted {
return Err(err);
}
}
}
fn set_crop(fd: RawFd, width: u32, height: u32) -> Result<()> {
// Some drivers want the single planar type here even on mplane queues.
let mut last = None;
for ty in [BUF_TYPE_OUTPUT, BUF_TYPE_OUTPUT_SINGLE] {
let mut sel: v4l2::v4l2_selection = unsafe { std::mem::zeroed() };
sel.type_ = ty;
sel.target = SEL_TGT_CROP;
sel.r.width = width;
sel.r.height = height;
match unsafe { xioctl(fd, VIDIOC_S_SELECTION, &mut sel) } {
Ok(()) if sel.r.width == width && sel.r.height == height => return Ok(()),
Ok(()) => bail!("the encoder cropped to {}x{} instead of {width}x{height}", sel.r.width, sel.r.height),
Err(e) => last = Some(e),
}
}
Err(last.unwrap()).context("setting the encoder crop")
}
fn peak_bitrate(bitrate: u32) -> u32 {
(bitrate as u64 * 3 / 2).min(245_000_000) as u32
}
/// Lowest HEVC level (V4L2 menu index) and tier that fit the stream.
/// Limits from H.265 Annex A: luma picture size, luma samples per second,
/// and Main / High tier bitrate in kbit/s.
fn hevc_level(width: u32, height: u32, fps: u32, peak_kbps: u32) -> (i32, bool) {
const LEVELS: [(i32, u64, u64, u32, u32); 7] = [
(4, 2_228_224, 66_846_720, 12_000, 30_000), // 4
(6, 2_228_224, 133_693_440, 20_000, 50_000), // 4.1
(7, 8_912_896, 267_386_880, 25_000, 100_000), // 5
(8, 8_912_896, 534_773_760, 40_000, 160_000), // 5.1
(9, 8_912_896, 1_069_547_520, 60_000, 240_000), // 5.2
(10, 35_651_584, 1_069_547_520, 60_000, 240_000), // 6
(11, 35_651_584, 2_139_095_040, 120_000, 480_000), // 6.1
];
let size = width as u64 * height as u64;
let rate = size * fps as u64;
for &(level, max_size, max_rate, main, high) in &LEVELS {
if size <= max_size && rate <= max_rate {
if peak_kbps <= main {
return (level, false);
}
if peak_kbps <= high {
return (level, true);
}
}
}
(11, true)
}
/// Lowest H.264 level (V4L2 menu index) that fits the stream. Limits from
/// H.264 Annex A in macroblocks, with High profile's 1.25x bitrate allowance.
fn h264_level(width: u32, height: u32, fps: u32, peak_kbps: u32) -> i32 {
const LEVELS: [(i32, u64, u64, u32); 5] = [
(12, 8_192, 245_760, 62_500), // 4.1
(13, 8_704, 522_240, 62_500), // 4.2
(14, 22_080, 589_824, 168_750), // 5
(15, 36_864, 983_040, 300_000), // 5.1
(16, 36_864, 2_073_600, 300_000), // 5.2
];
let mbs = width.div_ceil(16) as u64 * height.div_ceil(16) as u64;
let rate = mbs * fps as u64;
LEVELS
.iter()
.find(|&&(_, max_fs, max_rate, max_kbps)| mbs <= max_fs && rate <= max_rate && peak_kbps <= max_kbps)
.map_or(16, |l| l.0)
}
/// Finds the hardware encoder node.
pub fn find_device() -> Result<PathBuf> {
let link = Path::new("/dev/video-enc0");
if link.exists() {
return Ok(link.into());
}
for entry in std::fs::read_dir("/sys/class/video4linux")? {
let entry = entry?;
let name = std::fs::read_to_string(entry.path().join("name")).unwrap_or_default();
if name.contains("encoder") {
return Ok(Path::new("/dev").join(entry.file_name()));
}
}
bail!("no hardware video encoder found")
}
impl Encoder {
pub fn open(path: &Path, cfg: Config, output_buffers: u32) -> Result<Self> {
let file = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC)
.open(path)
.with_context(|| format!("opening encoder {}", path.display()))?;
let fd = file.as_raw_fd();
unsafe {
// Coded format first, the raw side's constraints depend on it.
let mut fmt: v4l2::v4l2_format = std::mem::zeroed();
fmt.type_ = BUF_TYPE_CAPTURE;
fmt.fmt.pix_mp.width = cfg.width;
fmt.fmt.pix_mp.height = cfg.height;
fmt.fmt.pix_mp.pixelformat = cfg.codec.fourcc();
fmt.fmt.pix_mp.field = FIELD_NONE;
fmt.fmt.pix_mp.num_planes = 1;
xioctl(fd, VIDIOC_S_FMT, &mut fmt).context("setting coded format")?;
if fmt.fmt.pix_mp.pixelformat != cfg.codec.fourcc() {
bail!("the encoder doesn't do {:?}", cfg.codec);
}
let mut fmt: v4l2::v4l2_format = std::mem::zeroed();
fmt.type_ = BUF_TYPE_OUTPUT;
let pix = &mut fmt.fmt.pix_mp;
pix.width = cfg.width;
pix.height = cfg.height;
pix.pixelformat = u32::from_le_bytes(*b"NV12");
pix.field = FIELD_NONE;
pix.num_planes = 1;
pix.colorspace = COLORSPACE_REC709;
pix.__bindgen_anon_1.ycbcr_enc = YCBCR_ENC_709;
pix.quantization = QUANTIZATION_LIM_RANGE;
pix.xfer_func = XFER_FUNC_709;
xioctl(fd, VIDIOC_S_FMT, &mut fmt).context("setting raw format")?;
let pix = fmt.fmt.pix_mp;
let (got_w, got_h) = (pix.width, pix.height);
if got_w < cfg.width || got_h < cfg.height {
bail!("the encoder wants {got_w}x{got_h} instead of {}x{}", cfg.width, cfg.height);
}
if (got_w, got_h) != (cfg.width, cfg.height) {
// Buffers are padded to the macroblock size, the crop says what's real.
set_crop(fd, cfg.width, cfg.height)?;
}
let plane = pix.plane_fmt[0];
let (y_stride, size) = (plane.bytesperline, plane.sizeimage);
// Venus/iris NV12: luma rows padded to 32, chroma follows right after.
let uv_offset = y_stride * got_h.next_multiple_of(32);
let expected = uv_offset + y_stride * (got_h / 2).next_multiple_of(16);
if y_stride % 4 != 0 || uv_offset % 4 != 0 || expected > size {
bail!("unexpected NV12 layout from the encoder: stride {y_stride}, size {size}");
}
log::info!("encoder input: NV12 stride {y_stride}, chroma at {uv_offset}, {size} bytes");
let layout = Nv12Layout { width: cfg.width, height: cfg.height, y_stride, uv_offset, size };
for ty in [BUF_TYPE_OUTPUT, BUF_TYPE_CAPTURE] {
let mut parm: v4l2::v4l2_streamparm = std::mem::zeroed();
parm.type_ = ty;
// Same offset for capture and output.
parm.parm.output.timeperframe.numerator = 1;
parm.parm.output.timeperframe.denominator = cfg.fps;
if let Err(e) = xioctl(fd, VIDIOC_S_PARM, &mut parm) {
log::warn!("couldn't set frame rate on queue {ty}: {e}");
}
}
let mut enc = Self { file, layout, outputs: Vec::new(), captures: Vec::new(), streaming: false };
enc.set_controls(&cfg);
enc.setup_buffers(output_buffers)?;
Ok(enc)
}
}
fn set_ctrl(&self, name: &str, id: u32, value: i32) {
let mut ctrl = v4l2::v4l2_control { id, value };
if let Err(e) = unsafe { xioctl(self.file.as_raw_fd(), VIDIOC_S_CTRL, &mut ctrl) } {
log::warn!("encoder ignored {name}={value}: {e}");
}
}
fn set_controls(&self, cfg: &Config) {
let gop = cfg.fps as i32;
self.set_ctrl("b_frames", CID_B_FRAMES, 0);
self.set_ctrl("gop_size", CID_GOP_SIZE, gop);
self.set_ctrl("header_mode", CID_HEADER_MODE, 0);
// With a fixed quantizer the bitrate is whatever it turns out to be,
// so leave room for the top of the range.
let peak_kbps = if cfg.qp.is_some() { 160_000 } else { peak_bitrate(cfg.bitrate) / 1000 };
match cfg.codec {
Codec::Hevc => {
let (level, high_tier) = hevc_level(cfg.width, cfg.height, cfg.fps, peak_kbps);
self.set_ctrl("hevc_profile", CID_HEVC_PROFILE, 0); // Main
self.set_ctrl("hevc_tier", CID_HEVC_TIER, high_tier as i32);
self.set_ctrl("hevc_level", CID_HEVC_LEVEL, level);
}
Codec::H264 => {
self.set_ctrl("h264_profile", CID_H264_PROFILE, 0); // Baseline for browser interoperability
self.set_ctrl("h264_level", CID_H264_LEVEL, h264_level(cfg.width, cfg.height, cfg.fps, peak_kbps));
}
}
match cfg.qp {
Some(qp) => {
self.set_ctrl("frame_rc", CID_FRAME_RC_ENABLE, 0);
let (i, p) = match cfg.codec {
Codec::Hevc => (CID_HEVC_I_FRAME_QP, CID_HEVC_P_FRAME_QP),
Codec::H264 => (CID_H264_I_FRAME_QP, CID_H264_P_FRAME_QP),
};
self.set_ctrl("i_qp", i, qp as i32);
self.set_ctrl("p_qp", p, qp as i32);
}
None => {
self.set_ctrl("frame_rc", CID_FRAME_RC_ENABLE, 1);
self.set_ctrl("bitrate_mode", CID_BITRATE_MODE, 0); // VBR
self.set_ctrl("bitrate", CID_BITRATE, cfg.bitrate as i32);
self.set_ctrl("bitrate_peak", CID_BITRATE_PEAK, peak_bitrate(cfg.bitrate) as i32);
}
}
}
unsafe fn setup_buffers(&mut self, output_buffers: u32) -> Result<()> {
let fd = self.file.as_raw_fd();
let mut req: v4l2::v4l2_requestbuffers = std::mem::zeroed();
req.count = output_buffers;
req.type_ = BUF_TYPE_OUTPUT;
req.memory = MEMORY_DMABUF;
xioctl(fd, VIDIOC_REQBUFS, &mut req).context("allocating encoder input slots")?;
if req.count < output_buffers {
bail!("encoder only gave us {} input slots", req.count);
}
self.outputs = vec![false; output_buffers as usize];
let mut req: v4l2::v4l2_requestbuffers = std::mem::zeroed();
req.count = CAPTURE_BUFFERS;
req.type_ = BUF_TYPE_CAPTURE;
req.memory = MEMORY_MMAP;
xioctl(fd, VIDIOC_REQBUFS, &mut req).context("allocating encoder output buffers")?;
for index in 0..req.count {
let mut plane: v4l2::v4l2_plane = std::mem::zeroed();
let mut buf: v4l2::v4l2_buffer = std::mem::zeroed();
buf.index = index;
buf.type_ = BUF_TYPE_CAPTURE;
buf.memory = MEMORY_MMAP;
buf.length = 1;
buf.m.planes = &mut plane;
xioctl(fd, VIDIOC_QUERYBUF, &mut buf).context("querying encoder output buffer")?;
let len = plane.length as usize;
let ptr = libc::mmap(
std::ptr::null_mut(),
len,
libc::PROT_READ,
libc::MAP_SHARED,
fd,
plane.m.mem_offset as libc::off_t,
);
if ptr == libc::MAP_FAILED {
return Err(io::Error::last_os_error()).context("mapping encoder output buffer");
}
self.captures.push(Mapping { ptr: ptr.cast(), len });
self.queue_capture(index)?;
}
log::debug!("encoder: {} input slots, {} output buffers", self.outputs.len(), self.captures.len());
for ty in [BUF_TYPE_OUTPUT, BUF_TYPE_CAPTURE] {
let mut t = ty as libc::c_int;
xioctl(fd, VIDIOC_STREAMON, &mut t).context("starting the encoder")?;
}
self.streaming = true;
Ok(())
}
fn queue_capture(&self, index: u32) -> Result<()> {
unsafe {
let mut plane: v4l2::v4l2_plane = std::mem::zeroed();
plane.length = self.captures[index as usize].len as u32;
let mut buf: v4l2::v4l2_buffer = std::mem::zeroed();
buf.index = index;
buf.type_ = BUF_TYPE_CAPTURE;
buf.memory = MEMORY_MMAP;
buf.length = 1;
buf.m.planes = &mut plane;
xioctl(self.file.as_raw_fd(), VIDIOC_QBUF, &mut buf).context("queueing encoder output buffer")
}
}
/// Makes the next frame a keyframe, so a recording started mid-session
/// doesn't wait for the next one.
pub fn force_keyframe(&self) {
self.set_ctrl("force_key_frame", CID_FORCE_KEY_FRAME, 1);
}
pub fn layout(&self) -> Nv12Layout {
self.layout
}
/// An input slot the GPU can write into, or None if the encoder is
/// still chewing on all of them.
pub fn free_slot(&mut self) -> Result<Option<usize>> {
self.reclaim_outputs()?;
Ok(self.outputs.iter().position(|busy| !busy))
}
fn reclaim_outputs(&mut self) -> Result<()> {
loop {
let mut plane: v4l2::v4l2_plane = unsafe { std::mem::zeroed() };
let mut buf: v4l2::v4l2_buffer = unsafe { std::mem::zeroed() };
buf.type_ = BUF_TYPE_OUTPUT;
buf.memory = MEMORY_DMABUF;
buf.length = 1;
buf.m.planes = &mut plane;
match unsafe { xioctl(self.file.as_raw_fd(), VIDIOC_DQBUF, &mut buf) } {
Ok(()) => self.outputs[buf.index as usize] = false,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(()),
Err(e) if e.raw_os_error() == Some(libc::EPIPE) => return Ok(()),
Err(e) => return Err(e).context("reclaiming encoder input"),
}
}
}
pub fn queue_frame(&mut self, slot: usize, dmabuf: RawFd, pts_us: u64) -> Result<()> {
unsafe {
let mut plane: v4l2::v4l2_plane = std::mem::zeroed();
plane.bytesused = self.layout.size;
plane.length = self.layout.size;
plane.m.fd = dmabuf;
let mut buf: v4l2::v4l2_buffer = std::mem::zeroed();
buf.index = slot as u32;
buf.type_ = BUF_TYPE_OUTPUT;
buf.memory = MEMORY_DMABUF;
buf.field = FIELD_NONE;
buf.length = 1;
buf.m.planes = &mut plane;
buf.timestamp.tv_sec = (pts_us / 1_000_000) as _;
buf.timestamp.tv_usec = (pts_us % 1_000_000) as _;
xioctl(self.file.as_raw_fd(), VIDIOC_QBUF, &mut buf).context("feeding the encoder")?;
}
self.outputs[slot] = true;
Ok(())
}
/// Hands every finished packet to `sink`. Returns true once the encoder
/// has sent its last packet after `finish`.
pub fn poll_packets(&mut self, mut sink: impl FnMut(Packet)) -> Result<bool> {
loop {
let mut plane: v4l2::v4l2_plane = unsafe { std::mem::zeroed() };
let mut buf: v4l2::v4l2_buffer = unsafe { std::mem::zeroed() };
buf.type_ = BUF_TYPE_CAPTURE;
buf.memory = MEMORY_MMAP;
buf.length = 1;
buf.m.planes = &mut plane;
match unsafe { xioctl(self.file.as_raw_fd(), VIDIOC_DQBUF, &mut buf) } {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(false),
Err(e) if e.raw_os_error() == Some(libc::EPIPE) => return Ok(true),
Err(e) => return Err(e).context("reading from the encoder"),
}
let map = &self.captures[buf.index as usize];
let start = (plane.data_offset as usize).min(map.len);
let end = (plane.bytesused as usize).min(map.len);
if buf.flags & BUF_FLAG_ERROR != 0 {
log::warn!("encoder flagged a corrupt packet, dropping it");
} else if end > start {
let data = unsafe { std::slice::from_raw_parts(map.ptr.add(start), end - start) };
sink(Packet {
data,
pts_us: buf.timestamp.tv_sec as u64 * 1_000_000 + buf.timestamp.tv_usec as u64,
key: buf.flags & BUF_FLAG_KEYFRAME != 0,
});
}
if buf.flags & BUF_FLAG_LAST != 0 {
return Ok(true);
}
self.queue_capture(buf.index)?;
}
}
/// Flushes everything still inside the encoder.
pub fn finish(&mut self, mut sink: impl FnMut(Packet)) -> Result<()> {
let mut cmd = EncoderCmd { cmd: ENC_CMD_STOP, ..Default::default() };
if let Err(e) = unsafe { xioctl(self.file.as_raw_fd(), VIDIOC_ENCODER_CMD, &mut cmd) } {
log::warn!("encoder refused to drain: {e}");
return Ok(());
}
let deadline = Instant::now() + STOP_TIMEOUT;
while Instant::now() < deadline {
if self.poll_packets(&mut sink)? {
return Ok(());
}
let mut pfd = libc::pollfd { fd: self.file.as_raw_fd(), events: libc::POLLIN, revents: 0 };
unsafe { libc::poll(&mut pfd, 1, 50) };
}
log::warn!("encoder didn't finish draining in time, the last frames may be missing");
Ok(())
}
}
impl Drop for Encoder {
fn drop(&mut self) {
if self.streaming {
for ty in [BUF_TYPE_OUTPUT, BUF_TYPE_CAPTURE] {
let mut t = ty as libc::c_int;
let _ = unsafe { xioctl(self.file.as_raw_fd(), VIDIOC_STREAMOFF, &mut t) };
}
}
self.captures.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hevc_levels() {
// A 60 Mbit/s peak is past 4.1 High's 50, so level 5 High. 30 fits 4.1 High.
assert_eq!(hevc_level(1920, 1080, 60, 60_000), (7, true));
assert_eq!(hevc_level(1920, 1080, 60, 30_000), (6, true));
assert_eq!(hevc_level(1920, 1080, 60, 15_000), (6, false));
// 1080p72 needs level 5 for the sample rate alone.
assert_eq!(hevc_level(1920, 1080, 72, 20_000), (7, false));
// Square 1440 at 30 fps is light enough for level 4.
assert_eq!(hevc_level(1440, 1440, 30, 10_000), (4, false));
}
#[test]
fn h264_levels() {
assert_eq!(h264_level(1920, 1080, 60, 60_000), 13);
assert_eq!(h264_level(1920, 1080, 72, 60_000), 14);
assert_eq!(h264_level(1920, 1080, 30, 120_000), 14);
}
}
+829
View File
@@ -0,0 +1,829 @@
//! The only GPU work framely-capture does: one small compute dispatch per recorded
//! frame. It reads the compositor's scanout buffer in place and writes NV12
//! into dmabufs that the hardware encoder reads directly. No CPU copies.
//!
//! Its queue priority is picked by the caller, see `Priority`.
use std::ffi::{c_char, CStr, CString};
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd};
use anyhow::{bail, Context, Result};
use ash::vk;
use crate::kms::ScanoutBuffer;
#[path = "gpu_copy.rs"]
mod copy;
use crate::lut::Lut;
const SHADER: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/convert.spv"));
const MAX_SOURCES: usize = 8;
const DRM_FORMAT_XRGB8888: u32 = u32::from_le_bytes(*b"XR24");
const DRM_FORMAT_ARGB8888: u32 = u32::from_le_bytes(*b"AR24");
const DRM_FORMAT_XBGR8888: u32 = u32::from_le_bytes(*b"XB24");
const DRM_FORMAT_ABGR8888: u32 = u32::from_le_bytes(*b"AB24");
#[repr(C)]
#[derive(Clone, Copy)]
struct Params {
out_size: [u32; 2],
lut_size: [u32; 2],
y_stride: u32,
uv_offset: u32,
lut_step: f32,
flags: u32,
}
/// Layout of the NV12 buffers, dictated by the encoder.
#[derive(Clone, Copy, Debug)]
pub struct Nv12Layout {
pub width: u32,
pub height: u32,
pub y_stride: u32,
pub uv_offset: u32,
pub size: u32,
}
struct Source {
fb_id: u32,
/// Identity of the buffer behind the framebuffer id, see `kms::buffer_id`.
buffer: u64,
last_used: u64,
image: vk::Image,
memory: vk::DeviceMemory,
view: vk::ImageView,
/// One descriptor set and pre-recorded command buffer per target.
sets: Vec<vk::DescriptorSet>,
cmds: Vec<vk::CommandBuffer>,
}
pub struct Target {
buffer: vk::Buffer,
memory: vk::DeviceMemory,
pub fd: OwnedFd,
}
pub struct Gpu {
_entry: ash::Entry,
instance: ash::Instance,
device: ash::Device,
ext_fd: ash::khr::external_memory_fd::Device,
mem_props: vk::PhysicalDeviceMemoryProperties,
queue: vk::Queue,
queue_family: u32,
cmd_pool: vk::CommandPool,
desc_pool: vk::DescriptorPool,
set_layout: vk::DescriptorSetLayout,
pipeline_layout: vk::PipelineLayout,
pipeline: vk::Pipeline,
shader: vk::ShaderModule,
sampler: vk::Sampler,
fence: vk::Fence,
/// Two timestamps around the dispatch, to report what the shader costs.
queries: vk::QueryPool,
timestamp_ns: f64,
lut_buffer: vk::Buffer,
lut_memory: vk::DeviceMemory,
params: Params,
layout: Nv12Layout,
targets: Vec<Target>,
sources: Vec<Source>,
tick: u64,
/// Where images handed to us by someone else get copied before converting.
copy: Option<CopyTarget>,
physical_device: vk::PhysicalDevice,
}
/// How the shader reads the source.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Filter {
/// One bilinear read per pixel.
Bilinear,
/// Catmull-Rom: crisper, five reads per pixel.
Sharp,
/// Four averaged reads, for when the output shrinks the source a lot.
Supersample,
}
/// How our GPU work ranks against the game's. The scanout buffer gets reused
/// a frame later, so work that waits for the game to leave the GPU idle can
/// miss frames in heavy games.
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum Priority {
/// Only runs when the game leaves the GPU idle.
Low,
/// Takes turns with the game.
Medium,
/// Cuts in ahead of the game, briefly.
High,
}
impl Priority {
fn vk(self) -> vk::QueueGlobalPriorityKHR {
match self {
Priority::Low => vk::QueueGlobalPriorityKHR::LOW,
Priority::Medium => vk::QueueGlobalPriorityKHR::MEDIUM,
Priority::High => vk::QueueGlobalPriorityKHR::HIGH,
}
}
}
/// Extensions to enable beyond our own: fixed ones for the instance, and a
/// function giving the device ones for the physical device we pick.
pub struct Extensions {
pub instance: Vec<CString>,
pub device: Box<dyn Fn(vk::PhysicalDevice) -> Vec<CString>>,
}
/// Raw handles for handing our device to other Vulkan users.
pub struct NativeHandles {
pub instance: vk::Instance,
pub device: vk::Device,
pub physical_device: vk::PhysicalDevice,
pub queue: vk::Queue,
pub queue_family: u32,
}
struct CopyTarget {
pool: vk::CommandPool,
cmd: vk::CommandBuffer,
image: vk::Image,
width: u32,
height: u32,
}
/// Framebuffer id used for the copy source.
const COPY_SOURCE: u32 = u32::MAX;
impl Gpu {
/// `extra` adds extensions someone else needs on our instance and device
/// (SteamVR, to hand us its headset view).
pub fn new(
layout: Nv12Layout,
target_count: usize,
lut: &Lut,
filter: Filter,
priority: Priority,
extra: Option<&Extensions>,
) -> Result<Self> {
let entry = unsafe { ash::Entry::load() }.context("loading Vulkan")?;
let app = vk::ApplicationInfo::default()
.application_name(c"framely-capture")
.api_version(vk::API_VERSION_1_1);
let instance_exts: Vec<*const c_char> = extra.map_or_else(Vec::new, |e| e.instance.iter().map(|x| x.as_ptr()).collect());
let instance = unsafe {
entry.create_instance(
&vk::InstanceCreateInfo::default().application_info(&app).enabled_extension_names(&instance_exts),
None,
)
}
.context("creating Vulkan instance")?;
Self::with_instance(entry, instance, layout, target_count, lut, filter, priority, extra)
}
#[allow(clippy::too_many_arguments)]
fn with_instance(
entry: ash::Entry,
instance: ash::Instance,
layout: Nv12Layout,
target_count: usize,
lut: &Lut,
filter: Filter,
priority: Priority,
extra: Option<&Extensions>,
) -> Result<Self> {
let pdev = unsafe { instance.enumerate_physical_devices() }?
.into_iter()
.next()
.context("no Vulkan device")?;
let props = unsafe { instance.get_physical_device_properties(pdev) };
let name = unsafe { CStr::from_ptr(props.device_name.as_ptr()) };
log::info!("gpu: {}", name.to_string_lossy());
let queue_family = unsafe { instance.get_physical_device_queue_family_properties(pdev) }
.iter()
.position(|q| q.queue_flags.contains(vk::QueueFlags::COMPUTE))
.context("no compute queue")? as u32;
let mut exts: Vec<*const c_char> = vec![
ash::khr::external_memory_fd::NAME.as_ptr(),
ash::ext::external_memory_dma_buf::NAME.as_ptr(),
ash::ext::image_drm_format_modifier::NAME.as_ptr(),
// Required by the modifier extension on Vulkan 1.1.
ash::khr::image_format_list::NAME.as_ptr(),
ash::ext::queue_family_foreign::NAME.as_ptr(),
];
let wanted = extra.map(|e| (e.device)(pdev)).unwrap_or_default();
for name in &wanted {
if !exts.iter().any(|&e| unsafe { CStr::from_ptr(e) } == name.as_c_str()) {
exts.push(name.as_ptr());
}
}
// Last, so it can be dropped if the driver refuses the priority.
exts.push(ash::ext::global_priority::NAME.as_ptr());
let priorities = [0.0f32];
let device = [true, false]
.into_iter()
.find_map(|with_priority| {
let mut prio = vk::DeviceQueueGlobalPriorityCreateInfoKHR::default().global_priority(priority.vk());
let mut qinfo = vk::DeviceQueueCreateInfo::default()
.queue_family_index(queue_family)
.queue_priorities(&priorities);
if with_priority {
qinfo = qinfo.push_next(&mut prio);
}
let qinfos = [qinfo];
let n = if with_priority { exts.len() } else { exts.len() - 1 };
let info = vk::DeviceCreateInfo::default()
.queue_create_infos(&qinfos)
.enabled_extension_names(&exts[..n]);
match unsafe { instance.create_device(pdev, &info, None) } {
Ok(d) => Some(d),
Err(e) => {
log::warn!("device creation failed ({priority:?} priority queue: {with_priority}): {e}");
None
}
}
})
.context("creating Vulkan device")?;
let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device);
let mem_props = unsafe { instance.get_physical_device_memory_properties(pdev) };
let queue = unsafe { device.get_device_queue(queue_family, 0) };
let mut gpu = Self {
_entry: entry,
instance,
device,
ext_fd,
mem_props,
timestamp_ns: props.limits.timestamp_period as f64,
queue,
queue_family,
cmd_pool: vk::CommandPool::null(),
desc_pool: vk::DescriptorPool::null(),
set_layout: vk::DescriptorSetLayout::null(),
pipeline_layout: vk::PipelineLayout::null(),
pipeline: vk::Pipeline::null(),
shader: vk::ShaderModule::null(),
sampler: vk::Sampler::null(),
fence: vk::Fence::null(),
queries: vk::QueryPool::null(),
lut_buffer: vk::Buffer::null(),
lut_memory: vk::DeviceMemory::null(),
params: Params {
out_size: [layout.width, layout.height],
lut_size: [lut.width, lut.height],
y_stride: layout.y_stride,
uv_offset: layout.uv_offset,
lut_step: lut.step,
flags: (lut.per_channel as u32)
| (((filter == Filter::Supersample) as u32) << 1)
| ((lut.rotated_eyes as u32) << 2)
| (((filter == Filter::Sharp) as u32) << 3),
},
layout,
targets: Vec::new(),
sources: Vec::new(),
tick: 0,
copy: None,
physical_device: pdev,
};
gpu.create_pipeline(target_count)?;
gpu.upload_lut(lut)?;
for _ in 0..target_count {
let t = gpu.create_target()?;
gpu.targets.push(t);
}
Ok(gpu)
}
pub fn targets(&self) -> &[Target] {
&self.targets
}
fn create_pipeline(&mut self, target_count: usize) -> Result<()> {
let d = &self.device;
unsafe {
let code = ash::util::read_spv(&mut std::io::Cursor::new(SHADER))?;
self.shader = d.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&code), None)?;
let bindings = [
vk::DescriptorSetLayoutBinding::default()
.binding(0)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::COMPUTE),
vk::DescriptorSetLayoutBinding::default()
.binding(1)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::COMPUTE),
vk::DescriptorSetLayoutBinding::default()
.binding(2)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::COMPUTE),
];
self.set_layout = d.create_descriptor_set_layout(
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
None,
)?;
let ranges = [vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::COMPUTE)
.size(std::mem::size_of::<Params>() as u32)];
let set_layouts = [self.set_layout];
self.pipeline_layout = d.create_pipeline_layout(
&vk::PipelineLayoutCreateInfo::default()
.set_layouts(&set_layouts)
.push_constant_ranges(&ranges),
None,
)?;
let stage = vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::COMPUTE)
.module(self.shader)
.name(c"main");
let info = vk::ComputePipelineCreateInfo::default()
.stage(stage)
.layout(self.pipeline_layout);
self.pipeline = d
.create_compute_pipelines(vk::PipelineCache::null(), &[info], None)
.map_err(|(_, e)| e)?[0];
self.sampler = d.create_sampler(
&vk::SamplerCreateInfo::default()
.mag_filter(vk::Filter::LINEAR)
.min_filter(vk::Filter::LINEAR)
.mipmap_mode(vk::SamplerMipmapMode::NEAREST)
.address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE)
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE)
.address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE),
None,
)?;
let max_sets = (MAX_SOURCES * target_count) as u32;
let sizes = [
vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(max_sets),
vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::STORAGE_BUFFER)
.descriptor_count(max_sets * 2),
];
self.desc_pool = d.create_descriptor_pool(
&vk::DescriptorPoolCreateInfo::default()
.flags(vk::DescriptorPoolCreateFlags::FREE_DESCRIPTOR_SET)
.max_sets(max_sets)
.pool_sizes(&sizes),
None,
)?;
self.cmd_pool = d.create_command_pool(
&vk::CommandPoolCreateInfo::default().queue_family_index(self.queue_family),
None,
)?;
self.fence = d.create_fence(&vk::FenceCreateInfo::default(), None)?;
self.queries = d.create_query_pool(
&vk::QueryPoolCreateInfo::default().query_type(vk::QueryType::TIMESTAMP).query_count(2),
None,
)?;
}
Ok(())
}
fn find_memory_type(&self, bits: u32, want: vk::MemoryPropertyFlags) -> Option<u32> {
(0..self.mem_props.memory_type_count).find(|&i| {
bits & (1 << i) != 0
&& self.mem_props.memory_types[i as usize].property_flags.contains(want)
})
}
fn upload_lut(&mut self, lut: &Lut) -> Result<()> {
let bytes: &[u8] = unsafe {
std::slice::from_raw_parts(lut.points.as_ptr().cast(), std::mem::size_of_val(lut.points.as_slice()))
};
let d = &self.device;
unsafe {
self.lut_buffer = d.create_buffer(
&vk::BufferCreateInfo::default()
.size(bytes.len() as u64)
.usage(vk::BufferUsageFlags::STORAGE_BUFFER),
None,
)?;
let req = d.get_buffer_memory_requirements(self.lut_buffer);
let ty = self
.find_memory_type(
req.memory_type_bits,
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
)
.context("no host visible memory for the lookup grid")?;
self.lut_memory = d.allocate_memory(
&vk::MemoryAllocateInfo::default().allocation_size(req.size).memory_type_index(ty),
None,
)?;
d.bind_buffer_memory(self.lut_buffer, self.lut_memory, 0)?;
let ptr = d.map_memory(self.lut_memory, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())?;
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr.cast(), bytes.len());
d.unmap_memory(self.lut_memory);
}
Ok(())
}
fn create_target(&self) -> Result<Target> {
let d = &self.device;
let handle = vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT;
unsafe {
let mut ext = vk::ExternalMemoryBufferCreateInfo::default().handle_types(handle);
let buffer = d.create_buffer(
&vk::BufferCreateInfo::default()
.size(self.layout.size as u64)
.usage(vk::BufferUsageFlags::STORAGE_BUFFER)
.push_next(&mut ext),
None,
)?;
let req = d.get_buffer_memory_requirements(buffer);
let ty = self
.find_memory_type(req.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL)
.context("no device memory for encoder buffers")?;
let mut export = vk::ExportMemoryAllocateInfo::default().handle_types(handle);
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
let memory = d.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(ty)
.push_next(&mut export)
.push_next(&mut dedicated),
None,
)?;
d.bind_buffer_memory(buffer, memory, 0)?;
let fd = self.ext_fd.get_memory_fd(
&vk::MemoryGetFdInfoKHR::default().memory(memory).handle_type(handle),
)?;
Ok(Target { buffer, memory, fd: OwnedFd::from_raw_fd(fd) })
}
}
pub fn has_source(&self, fb_id: u32, buffer: u64) -> bool {
self.sources.iter().any(|s| s.fb_id == fb_id && s.buffer == buffer)
}
/// Forgets every imported scanout buffer.
pub fn clear_sources(&mut self) {
let (keep, gone): (Vec<_>, Vec<_>) = std::mem::take(&mut self.sources).into_iter().partition(|s| s.fb_id == COPY_SOURCE);
self.sources = keep;
for src in gone {
self.destroy_source(src);
}
}
/// Imports a scanout buffer. The compositor flips between a few buffers,
/// so this happens a couple of times at startup and then never again.
pub fn add_source(&mut self, buf: ScanoutBuffer, buffer: u64) -> Result<()> {
// A recycled framebuffer id now points at a different buffer.
if let Some(i) = self.sources.iter().position(|s| s.fb_id == buf.fb_id) {
let src = self.sources.swap_remove(i);
self.destroy_source(src);
}
if self.sources.len() >= MAX_SOURCES {
let oldest = (0..self.sources.len())
.min_by_key(|&i| self.sources[i].last_used)
.unwrap();
let src = self.sources.swap_remove(oldest);
self.destroy_source(src);
}
let mut src = Source {
fb_id: buf.fb_id,
buffer,
last_used: 0,
image: vk::Image::null(),
memory: vk::DeviceMemory::null(),
view: vk::ImageView::null(),
sets: Vec::new(),
cmds: Vec::new(),
};
match self.import(buf, &mut src) {
Ok(()) => {
self.sources.push(src);
Ok(())
}
Err(e) => {
self.destroy_source(src);
Err(e)
}
}
}
/// Fills in `src`; whatever got created is in there even on failure.
fn import(&self, buf: ScanoutBuffer, src: &mut Source) -> Result<()> {
let format = match buf.fourcc {
DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => vk::Format::B8G8R8A8_UNORM,
DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => vk::Format::R8G8B8A8_UNORM,
other => bail!("unsupported scanout format {:?}", other.to_le_bytes().map(char::from)),
};
log::info!(
"importing scanout fb {} ({}x{}, modifier {:#x}, pitch {})",
buf.fb_id,
buf.width,
buf.height,
buf.modifier,
buf.pitch
);
let d = &self.device;
let handle = vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT;
unsafe {
let planes = [vk::SubresourceLayout {
offset: buf.offset as u64,
size: 0,
row_pitch: buf.pitch as u64,
array_pitch: 0,
depth_pitch: 0,
}];
let mut modifier = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
.drm_format_modifier(buf.modifier)
.plane_layouts(&planes);
let mut ext = vk::ExternalMemoryImageCreateInfo::default().handle_types(handle);
src.image = d
.create_image(
&vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(format)
.extent(vk::Extent3D { width: buf.width, height: buf.height, depth: 1 })
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
.usage(vk::ImageUsageFlags::SAMPLED)
.sharing_mode(vk::SharingMode::EXCLUSIVE)
.initial_layout(vk::ImageLayout::UNDEFINED)
.push_next(&mut modifier)
.push_next(&mut ext),
None,
)
.context("creating image for the scanout buffer (modifier not supported?)")?;
let image = src.image;
let mut fd_props = vk::MemoryFdPropertiesKHR::default();
self.ext_fd
.get_memory_fd_properties(handle, buf.fd.as_raw_fd(), &mut fd_props)?;
let req = d.get_image_memory_requirements(image);
let ty = self
.find_memory_type(req.memory_type_bits & fd_props.memory_type_bits, vk::MemoryPropertyFlags::empty())
.context("no memory type can import the scanout buffer")?;
// Vulkan takes ownership of the fd on success.
let fd = buf.fd.into_raw_fd();
let mut import = vk::ImportMemoryFdInfoKHR::default().handle_type(handle).fd(fd);
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().image(image);
src.memory = match d.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(ty)
.push_next(&mut import)
.push_next(&mut dedicated),
None,
) {
Ok(m) => m,
Err(e) => {
libc::close(fd);
return Err(e).context("importing scanout memory");
}
};
d.bind_image_memory(image, src.memory, 0)?;
src.view = d.create_image_view(
&vk::ImageViewCreateInfo::default()
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(format)
.subresource_range(color_range()),
None,
)?;
let layouts = vec![self.set_layout; self.targets.len()];
src.sets = d.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(self.desc_pool)
.set_layouts(&layouts),
)?;
src.cmds = d.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default()
.command_pool(self.cmd_pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(self.targets.len() as u32),
)?;
for (i, target) in self.targets.iter().enumerate() {
self.write_set(src.sets[i], src.view, target);
self.record(src.cmds[i], src.sets[i], image, target, true)?;
}
Ok(())
}
}
unsafe fn write_set(&self, set: vk::DescriptorSet, view: vk::ImageView, target: &Target) {
let image_info = [vk::DescriptorImageInfo::default()
.sampler(self.sampler)
.image_view(view)
.image_layout(vk::ImageLayout::GENERAL)];
let lut_info = [vk::DescriptorBufferInfo::default().buffer(self.lut_buffer).range(vk::WHOLE_SIZE)];
let out_info = [vk::DescriptorBufferInfo::default().buffer(target.buffer).range(vk::WHOLE_SIZE)];
let writes = [
vk::WriteDescriptorSet::default()
.dst_set(set)
.dst_binding(0)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.image_info(&image_info),
vk::WriteDescriptorSet::default()
.dst_set(set)
.dst_binding(1)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.buffer_info(&lut_info),
vk::WriteDescriptorSet::default()
.dst_set(set)
.dst_binding(2)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.buffer_info(&out_info),
];
self.device.update_descriptor_sets(&writes, &[]);
}
/// `external` images belong to someone else (the display) and get
/// borrowed and handed back around the dispatch; our own copy doesn't.
unsafe fn record(
&self,
cmd: vk::CommandBuffer,
set: vk::DescriptorSet,
image: vk::Image,
target: &Target,
external: bool,
) -> Result<()> {
let d = &self.device;
let foreign = vk::QUEUE_FAMILY_FOREIGN_EXT;
let ours = self.queue_family;
let (img_from, img_to) = if external { (foreign, ours) } else { (vk::QUEUE_FAMILY_IGNORED, vk::QUEUE_FAMILY_IGNORED) };
d.begin_command_buffer(cmd, &vk::CommandBufferBeginInfo::default())?;
d.cmd_reset_query_pool(cmd, self.queries, 0, 2);
// Borrow the scanout image from the display and the NV12 buffer from
// the encoder. GENERAL in and out, so nothing gets decompressed or
// discarded behind the compositor's back.
let acquire_image = vk::ImageMemoryBarrier::default()
.src_queue_family_index(img_from)
.dst_queue_family_index(img_to)
.old_layout(vk::ImageLayout::GENERAL)
.new_layout(vk::ImageLayout::GENERAL)
.dst_access_mask(vk::AccessFlags::SHADER_READ)
.image(image)
.subresource_range(color_range());
let acquire_buffer = vk::BufferMemoryBarrier::default()
.src_queue_family_index(foreign)
.dst_queue_family_index(ours)
.dst_access_mask(vk::AccessFlags::SHADER_WRITE)
.buffer(target.buffer)
.size(vk::WHOLE_SIZE);
d.cmd_pipeline_barrier(
cmd,
vk::PipelineStageFlags::TOP_OF_PIPE,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::DependencyFlags::empty(),
&[],
&[acquire_buffer],
&[acquire_image],
);
d.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::COMPUTE, self.pipeline);
d.cmd_bind_descriptor_sets(cmd, vk::PipelineBindPoint::COMPUTE, self.pipeline_layout, 0, &[set], &[]);
let params: [u8; std::mem::size_of::<Params>()] = std::mem::transmute(self.params);
d.cmd_push_constants(cmd, self.pipeline_layout, vk::ShaderStageFlags::COMPUTE, 0, &params);
let groups_x = (self.layout.width / 4).div_ceil(8);
let groups_y = (self.layout.height / 2).div_ceil(8);
d.cmd_write_timestamp(cmd, vk::PipelineStageFlags::TOP_OF_PIPE, self.queries, 0);
d.cmd_dispatch(cmd, groups_x, groups_y, 1);
d.cmd_write_timestamp(cmd, vk::PipelineStageFlags::BOTTOM_OF_PIPE, self.queries, 1);
let release_image = vk::ImageMemoryBarrier::default()
.src_queue_family_index(img_to)
.dst_queue_family_index(img_from)
.old_layout(vk::ImageLayout::GENERAL)
.new_layout(vk::ImageLayout::GENERAL)
.src_access_mask(vk::AccessFlags::SHADER_READ)
.image(image)
.subresource_range(color_range());
let release_buffer = vk::BufferMemoryBarrier::default()
.src_queue_family_index(ours)
.dst_queue_family_index(foreign)
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
.buffer(target.buffer)
.size(vk::WHOLE_SIZE);
d.cmd_pipeline_barrier(
cmd,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
vk::DependencyFlags::empty(),
&[],
&[release_buffer],
&[release_image],
);
d.end_command_buffer(cmd)?;
Ok(())
}
/// Converts the given scanout buffer into target `index` and waits for the
/// GPU to finish, so the encoder can take the buffer right away.
/// Returns how long the shader ran on the GPU.
pub fn convert(&mut self, fb_id: u32, buffer: u64, index: usize) -> Result<std::time::Duration> {
self.tick += 1;
let tick = self.tick;
let src = self
.sources
.iter_mut()
.find(|s| s.fb_id == fb_id && s.buffer == buffer)
.context("scanout buffer wasn't imported")?;
src.last_used = tick;
let cmd = src.cmds[index];
self.submit(&[cmd])
}
/// Runs command buffers, waits for them, and returns the conversion
/// shader's GPU time from the timestamps it wrote.
fn submit(&self, cmds: &[vk::CommandBuffer]) -> Result<std::time::Duration> {
unsafe {
self.device.queue_submit(
self.queue,
&[vk::SubmitInfo::default().command_buffers(cmds)],
self.fence,
)?;
self.device.wait_for_fences(&[self.fence], true, 1_000_000_000)?;
self.device.reset_fences(&[self.fence])?;
let mut stamps = [0u64; 2];
self.device
.get_query_pool_results(self.queries, 0, &mut stamps, vk::QueryResultFlags::TYPE_64)?;
let ns = stamps[1].saturating_sub(stamps[0]) as f64 * self.timestamp_ns;
Ok(std::time::Duration::from_nanos(ns as u64))
}
}
fn destroy_source(&self, src: Source) {
unsafe {
let d = &self.device;
let _ = d.device_wait_idle();
if !src.cmds.is_empty() {
d.free_command_buffers(self.cmd_pool, &src.cmds);
}
if !src.sets.is_empty() {
let _ = d.free_descriptor_sets(self.desc_pool, &src.sets);
}
d.destroy_image_view(src.view, None);
d.destroy_image(src.image, None);
d.free_memory(src.memory, None);
}
}
}
fn color_range() -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
}
}
impl Drop for Gpu {
fn drop(&mut self) {
unsafe {
let _ = self.device.device_wait_idle();
for src in std::mem::take(&mut self.sources) {
self.destroy_source(src);
}
let d = &self.device;
if let Some(copy) = self.copy.take() {
d.destroy_command_pool(copy.pool, None);
}
for t in self.targets.drain(..) {
d.destroy_buffer(t.buffer, None);
d.free_memory(t.memory, None);
}
d.destroy_buffer(self.lut_buffer, None);
d.free_memory(self.lut_memory, None);
d.destroy_fence(self.fence, None);
d.destroy_query_pool(self.queries, None);
d.destroy_command_pool(self.cmd_pool, None);
d.destroy_descriptor_pool(self.desc_pool, None);
d.destroy_sampler(self.sampler, None);
d.destroy_pipeline(self.pipeline, None);
d.destroy_pipeline_layout(self.pipeline_layout, None);
d.destroy_descriptor_set_layout(self.set_layout, None);
d.destroy_shader_module(self.shader, None);
d.destroy_device(None);
self.instance.destroy_instance(None);
}
}
}
+168
View File
@@ -0,0 +1,168 @@
//! Converting images someone else hands us on our own device (SteamVR's
//! headset view): each frame gets copied into an image of ours, then runs
//! through the same conversion as the panel scanout.
use anyhow::{Context, Result};
use ash::vk;
use super::{color_range, CopyTarget, Gpu, NativeHandles, Source, COPY_SOURCE};
impl Gpu {
pub fn native(&self) -> NativeHandles {
NativeHandles {
instance: self.instance.handle(),
device: self.device.handle(),
physical_device: self.physical_device,
queue: self.queue,
queue_family: self.queue_family,
}
}
/// Size of the image frames get copied into, if it's set up.
pub fn copy_size(&self) -> Option<(u32, u32)> {
self.copy.as_ref().map(|c| (c.width, c.height))
}
/// Sets up (or replaces) the image frames get copied into, `width`x`height` RGBA.
pub fn add_copy_source(&mut self, width: u32, height: u32) -> Result<()> {
if let Some(i) = self.sources.iter().position(|s| s.fb_id == COPY_SOURCE) {
let old = self.sources.swap_remove(i);
self.destroy_source(old);
}
if let Some(old) = self.copy.take() {
unsafe { self.device.destroy_command_pool(old.pool, None) };
}
log::info!("copying {width}x{height} frames from SteamVR");
let d = &self.device;
let mut src = Source {
fb_id: COPY_SOURCE,
buffer: 0,
last_used: 0,
image: vk::Image::null(),
memory: vk::DeviceMemory::null(),
view: vk::ImageView::null(),
sets: Vec::new(),
cmds: Vec::new(),
};
let format = vk::Format::R8G8B8A8_UNORM;
unsafe {
src.image = d.create_image(
&vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(format)
.extent(vk::Extent3D { width, height, depth: 1 })
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
.usage(vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST)
.initial_layout(vk::ImageLayout::UNDEFINED),
None,
)?;
let req = d.get_image_memory_requirements(src.image);
let ty = self
.find_memory_type(req.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL)
.context("no memory for the copy image")?;
src.memory = d.allocate_memory(&vk::MemoryAllocateInfo::default().allocation_size(req.size).memory_type_index(ty), None)?;
d.bind_image_memory(src.image, src.memory, 0)?;
src.view = d.create_image_view(
&vk::ImageViewCreateInfo::default()
.image(src.image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(format)
.subresource_range(color_range()),
None,
)?;
let layouts = vec![self.set_layout; self.targets.len()];
src.sets = d.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default().descriptor_pool(self.desc_pool).set_layouts(&layouts),
)?;
src.cmds = d.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default()
.command_pool(self.cmd_pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(self.targets.len() as u32),
)?;
for (i, target) in self.targets.iter().enumerate() {
self.write_set(src.sets[i], src.view, target);
self.record(src.cmds[i], src.sets[i], src.image, target, false)?;
}
let pool = d.create_command_pool(
&vk::CommandPoolCreateInfo::default()
.queue_family_index(self.queue_family)
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER),
None,
)?;
let cmd = d.allocate_command_buffers(&vk::CommandBufferAllocateInfo::default().command_pool(pool).command_buffer_count(1))?[0];
self.copy = Some(CopyTarget { pool, cmd, image: src.image, width, height });
}
self.sources.push(src);
Ok(())
}
/// Copies `from` (in TRANSFER_SRC_OPTIMAL, the region starting at
/// `origin`) into our image and converts it into target `index`.
/// Returns how long the conversion shader ran.
pub fn convert_copied(&mut self, from: vk::Image, origin: (i32, i32), index: usize) -> Result<std::time::Duration> {
let copy = self.copy.as_ref().context("no copy source set up")?;
let (cmd, to, width, height) = (copy.cmd, copy.image, copy.width, copy.height);
let d = &self.device;
unsafe {
d.reset_command_buffer(cmd, vk::CommandBufferResetFlags::empty())?;
d.begin_command_buffer(cmd, &vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT))?;
let to_dst = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
.src_access_mask(vk::AccessFlags::SHADER_READ)
.dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
.image(to)
.subresource_range(color_range());
d.cmd_pipeline_barrier(
cmd,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::PipelineStageFlags::TRANSFER,
vk::DependencyFlags::empty(),
&[],
&[],
&[to_dst],
);
let layers = vk::ImageSubresourceLayers {
aspect_mask: vk::ImageAspectFlags::COLOR,
mip_level: 0,
base_array_layer: 0,
layer_count: 1,
};
let region = vk::ImageCopy::default()
.src_subresource(layers)
.src_offset(vk::Offset3D { x: origin.0, y: origin.1, z: 0 })
.dst_subresource(layers)
.extent(vk::Extent3D { width, height, depth: 1 });
d.cmd_copy_image(cmd, from, vk::ImageLayout::TRANSFER_SRC_OPTIMAL, to, vk::ImageLayout::TRANSFER_DST_OPTIMAL, &[region]);
let to_general = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
.new_layout(vk::ImageLayout::GENERAL)
.src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
.dst_access_mask(vk::AccessFlags::SHADER_READ)
.image(to)
.subresource_range(color_range());
d.cmd_pipeline_barrier(
cmd,
vk::PipelineStageFlags::TRANSFER,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::DependencyFlags::empty(),
&[],
&[],
&[to_general],
);
d.end_command_buffer(cmd)?;
}
let convert = self
.sources
.iter()
.find(|s| s.fb_id == COPY_SOURCE)
.context("no copy source set up")?
.cmds[index];
self.submit(&[cmd, convert])
}
}
+243
View File
@@ -0,0 +1,243 @@
//! Framely's root framebuffer export helper and its private session socket.
//! The worker holds no capabilities. The core starts a version-matched helper,
//! which exports framebuffer DMA-BUF handles until the session socket closes.
use std::io::{Read, Write};
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::os::unix::net::UnixStream;
use std::path::Path;
use anyhow::{bail, Context, Result};
use drm::control::{framebuffer, Device as ControlDevice};
/// IPC protocol shared by the packaged helper and capture worker.
pub const VERSION: u32 = 1;
const REPLY_LEN: usize = 32;
/// One scanout buffer, as a dmabuf.
pub struct Exported {
pub fd: OwnedFd,
pub width: u32,
pub height: u32,
pub fourcc: u32,
pub modifier: u64,
pub pitch: u32,
pub offset: u32,
}
/// Exports a framebuffer, which only works with the permission. None when
/// the kernel kept the buffer to itself, i.e. without it.
pub fn export<D: ControlDevice>(card: &D, fb_id: u32) -> Result<Option<Exported>> {
let handle: framebuffer::Handle = drm::control::from_u32(fb_id).context("bad framebuffer id")?;
let info = card.get_planar_framebuffer(handle).with_context(|| format!("reading framebuffer {fb_id}"))?;
let buffers = info.buffers();
let Some(bo) = buffers[0] else { return Ok(None) };
if buffers[1..].iter().any(Option::is_some) {
bail!("multi-plane scanout buffers aren't supported yet");
}
let fd = card.buffer_to_prime_fd(bo, libc::O_CLOEXEC as u32).context("exporting scanout buffer as dmabuf");
// The export holds its own reference, the GEM handle is ours to drop.
let _ = card.close_buffer(bo);
let (width, height) = info.size();
Ok(Some(Exported {
fd: fd?,
width,
height,
fourcc: info.pixel_format() as u32,
modifier: info.modifier().map(u64::from).unwrap_or(0),
pitch: info.pitches()[0],
offset: info.offsets()[0],
}))
}
struct Card(std::fs::File);
impl AsFd for Card {
fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {
self.0.as_fd()
}
}
impl drm::Device for Card {}
impl ControlDevice for Card {}
/// The helper's side: says its version, then answers framebuffer ids on the
/// socket it was started with (its stdin) until the recorder hangs up.
pub fn serve(card_path: &Path) -> Result<()> {
// Only ever the display, nothing else gets opened with the permission.
use std::os::unix::fs::FileTypeExt;
let real = std::fs::canonicalize(card_path).with_context(|| format!("finding {}", card_path.display()))?;
let is_device = std::fs::metadata(&real).is_ok_and(|m| m.file_type().is_char_device());
if !real.to_string_lossy().starts_with("/dev/dri/card") || !is_device {
bail!("{} isn't a display", card_path.display());
}
let file = std::fs::OpenOptions::new().read(true).write(true).open(&real);
let card = Card(file.with_context(|| format!("opening {}", real.display()))?);
let mut sock = unsafe { UnixStream::from_raw_fd(0) };
sock.write_all(&VERSION.to_le_bytes())?;
let mut id = [0u8; 4];
loop {
match sock.read_exact(&mut id) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()),
Err(e) => return Err(e.into()),
}
let fb_id = u32::from_le_bytes(id);
match export(&card, fb_id) {
Ok(Some(buf)) => send(&sock, &reply(0, &buf), Some(buf.fd.as_raw_fd()))?,
Ok(None) => {
eprintln!("framely-panel-grab: the kernel hid framebuffer {fb_id}, is the permission gone?");
send(&sock, &[1; REPLY_LEN], None)?
}
Err(e) => {
eprintln!("framely-panel-grab: {e:#}");
send(&sock, &[1; REPLY_LEN], None)?
}
}
}
}
fn reply(status: u32, b: &Exported) -> [u8; REPLY_LEN] {
let mut out = [0u8; REPLY_LEN];
for (i, v) in [status, b.width, b.height, b.fourcc, b.pitch, b.offset].into_iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
out[24..32].copy_from_slice(&b.modifier.to_le_bytes());
out
}
/// The recorder's side: the helper, started and asked per frame.
pub struct Grabber {
sock: UnixStream,
}
impl Grabber {
/// A dedicated socket passed by Framely's root supervisor. It grants no
/// general capability to the Steam user process and is never public.
pub fn inherited() -> Result<Option<Self>> {
let Some(raw) = std::env::var_os("FRAMELY_GRAB_FD") else { return Ok(None) };
std::env::remove_var("FRAMELY_GRAB_FD");
let fd: RawFd = raw.to_string_lossy().parse().context("invalid helper fd")?;
if fd < 3 { bail!("invalid helper fd"); }
let mut sock = unsafe { UnixStream::from_raw_fd(fd) };
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC); }
sock.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
sock.set_write_timeout(Some(std::time::Duration::from_secs(5)))?;
let mut version = [0; 4];
sock.read_exact(&mut version).context("Framely panel helper did not answer")?;
if u32::from_le_bytes(version) != VERSION { bail!("panel helper version mismatch"); }
Ok(Some(Self { sock }))
}
pub fn export(&mut self, fb_id: u32) -> Result<Exported> {
self.sock.write_all(&fb_id.to_le_bytes()).context("asking the panel helper")?;
let (bytes, fd) = recv(&self.sock)?;
let word = |i: usize| u32::from_le_bytes(bytes[i * 4..i * 4 + 4].try_into().unwrap());
let (Some(fd), 0) = (fd, word(0)) else { bail!("the panel helper couldn't read framebuffer {fb_id}") };
Ok(Exported {
fd,
width: word(1),
height: word(2),
fourcc: word(3),
pitch: word(4),
offset: word(5),
modifier: u64::from_le_bytes(bytes[24..32].try_into().unwrap()),
})
}
}
fn send(sock: &UnixStream, bytes: &[u8; REPLY_LEN], fd: Option<RawFd>) -> Result<()> {
let mut iov = libc::iovec { iov_base: bytes.as_ptr() as *mut _, iov_len: bytes.len() };
let space = unsafe { libc::CMSG_SPACE(std::mem::size_of::<RawFd>() as u32) } as usize;
let mut control = vec![0u8; space];
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
if let Some(fd) = fd {
msg.msg_control = control.as_mut_ptr().cast();
msg.msg_controllen = space as _;
unsafe {
let cmsg = libc::CMSG_FIRSTHDR(&msg);
(*cmsg).cmsg_level = libc::SOL_SOCKET;
(*cmsg).cmsg_type = libc::SCM_RIGHTS;
(*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<RawFd>() as u32) as _;
std::ptr::write_unaligned(libc::CMSG_DATA(cmsg).cast::<RawFd>(), fd);
}
}
if unsafe { libc::sendmsg(sock.as_raw_fd(), &msg, libc::MSG_NOSIGNAL) } != REPLY_LEN as isize {
return Err(std::io::Error::last_os_error()).context("answering the recorder");
}
Ok(())
}
fn recv(sock: &UnixStream) -> Result<([u8; REPLY_LEN], Option<OwnedFd>)> {
let mut bytes = [0u8; REPLY_LEN];
let mut iov = libc::iovec { iov_base: bytes.as_mut_ptr().cast(), iov_len: bytes.len() };
let space = unsafe { libc::CMSG_SPACE(std::mem::size_of::<RawFd>() as u32) } as usize;
let mut control = vec![0u8; space];
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = control.as_mut_ptr().cast();
msg.msg_controllen = space as _;
let got = unsafe { libc::recvmsg(sock.as_raw_fd(), &mut msg, libc::MSG_CMSG_CLOEXEC | libc::MSG_WAITALL) };
if got != REPLY_LEN as isize {
bail!("the panel helper hung up");
}
let mut fd = None;
unsafe {
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if !cmsg.is_null() && (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS {
fd = Some(OwnedFd::from_raw_fd(std::ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast::<RawFd>())));
}
}
Ok((bytes, fd))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn supervisor_socket_uses_existing_helper_protocol() {
use std::os::fd::IntoRawFd;
let (ours, mut helper) = UnixStream::pair().unwrap();
let server = std::thread::spawn(move || {
helper.write_all(&VERSION.to_le_bytes()).unwrap();
let mut id = [0; 4]; helper.read_exact(&mut id).unwrap();
assert_eq!(u32::from_le_bytes(id), 42);
let file = std::fs::File::open("/dev/null").unwrap();
let buf = Exported {fd: OwnedFd::from(file),width:1920,height:1080,fourcc:0x34325258,modifier:0,pitch:7680,offset:0};
send(&helper, &reply(0, &buf), Some(buf.fd.as_raw_fd())).unwrap();
});
std::env::set_var("FRAMELY_GRAB_FD", ours.into_raw_fd().to_string());
let mut grabber = Grabber::inherited().unwrap().unwrap();
assert!(std::env::var_os("FRAMELY_GRAB_FD").is_none());
let buf = grabber.export(42).unwrap();
assert_eq!((buf.width, buf.height), (1920, 1080));
server.join().unwrap();
}
#[test]
fn replies_round_trip_with_a_file() {
let (a, b) = UnixStream::pair().unwrap();
let file = std::fs::File::open("/dev/null").unwrap();
let buf = Exported {
fd: OwnedFd::from(file),
width: 1920,
height: 1080,
fourcc: 0x34325258,
modifier: 0x0500000000000001,
pitch: 7680,
offset: 64,
};
send(&a, &reply(0, &buf), Some(buf.fd.as_raw_fd())).unwrap();
let (bytes, fd) = recv(&b).unwrap();
assert!(fd.is_some());
assert_eq!(bytes, reply(0, &buf));
send(&a, &[1; REPLY_LEN], None).unwrap();
let (_, fd) = recv(&b).unwrap();
assert!(fd.is_none());
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Finds the plane the VR compositor scans out and hands its buffers over as
//! dmabufs. The compositor keeps DRM master; we only look.
//!
//! Getting buffer handles for someone else's framebuffer needs CAP_SYS_ADMIN.
//! The recorder uses it if it has it itself, the panel helper otherwise (see
//! framely_capture::grab).
use std::fs::{File, OpenOptions};
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
use std::cell::{Cell, RefCell};
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use drm::control::{crtc, plane, Device as ControlDevice};
use framely_capture::grab;
use drm::{ClientCapability, Device, VblankWaitFlags, VblankWaitTarget};
struct Card(File);
impl AsFd for Card {
fn as_fd(&self) -> BorrowedFd<'_> {
self.0.as_fd()
}
}
impl Device for Card {}
impl ControlDevice for Card {}
/// One scanout buffer, exported as a dmabuf.
pub struct ScanoutBuffer {
pub fb_id: u32,
pub fd: OwnedFd,
pub width: u32,
pub height: u32,
pub fourcc: u32,
pub modifier: u64,
pub pitch: u32,
pub offset: u32,
}
pub struct Kms {
card: Card,
helper: RefCell<Option<grab::Grabber>>,
pipe: u32,
plane: Cell<plane::Handle>,
crtc: crtc::Handle,
next_plane_probe: Cell<Instant>,
pub refresh_hz: f64,
pub mode_size: (u32, u32),
}
impl Kms {
pub fn open(path: &Path) -> Result<Self> {
let file = OpenOptions::new()
.read(true)
.write(true)
.open(path)
.with_context(|| format!("opening {}", path.display()))?;
let card = Card(file);
card.set_client_capability(ClientCapability::UniversalPlanes, true)
.context("enabling universal planes")?;
let res = card.resource_handles().context("reading DRM resources")?;
let (pipe, crtc, mode) = res
.crtcs()
.iter()
.enumerate()
.find_map(|(i, &h)| {
let info = card.get_crtc(h).ok()?;
Some((i as u32, h, info.mode()?))
})
.context("no active display found, is the headset on?")?;
let plane = find_scanout_plane(&card, crtc)?;
let (w, h) = mode.size();
let refresh_hz = mode_refresh(&mode);
log::info!(
"display: {}x{} @ {:.2} Hz, crtc {:?}, plane {:?}",
w,
h,
refresh_hz,
crtc,
plane
);
Ok(Self {
card,
helper: RefCell::new(grab::Grabber::inherited()?),
pipe,
plane: Cell::new(plane),
crtc,
next_plane_probe: Cell::new(Instant::now()),
refresh_hz,
mode_size: (w as u32, h as u32),
})
}
/// Blocks until the next vblank and returns its CLOCK_MONOTONIC timestamp.
pub fn wait_vblank(&self) -> Result<Duration> {
let reply = loop {
match self.card.wait_vblank(VblankWaitTarget::Relative(1), VblankWaitFlags::empty(), self.pipe, 0) {
// A pause or stop signal landed mid-wait; just wait again.
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
other => break other.context("waiting for vblank")?,
}
};
Ok(reply.time().unwrap_or_else(crate::clock::now))
}
/// Id of the framebuffer being scanned out right now, if any.
pub fn current_fb(&self) -> Result<Option<u32>> {
let info = self.card.get_plane(self.plane.get()).context("reading scanout plane")?;
if info.crtc() == Some(self.crtc) && info.framebuffer().is_some() {
return Ok(info.framebuffer().map(u32::from));
}
// SteamVR can move scanout to another plane when waking or restarting.
// Keep normal capture cheap, and bound probes while the display is off.
if Instant::now() < self.next_plane_probe.get() { return Ok(None); }
self.next_plane_probe.set(Instant::now() + Duration::from_secs(1));
let Ok(plane) = find_scanout_plane(&self.card, self.crtc) else { return Ok(None) };
if plane != self.plane.get() {
log::info!("scanout moved to plane {plane:?}");
self.plane.set(plane);
}
Ok(self.card.get_plane(plane).context("reading replacement scanout plane")?.framebuffer().map(u32::from))
}
pub fn export(&self, fb_id: u32) -> Result<ScanoutBuffer> {
let buf = match grab::export(&self.card, fb_id)? {
Some(buf) => buf,
// No permission of our own: the helper has it, if it's installed.
None => self.export_through_helper(fb_id)?,
};
Ok(ScanoutBuffer {
fb_id,
fd: buf.fd,
width: buf.width,
height: buf.height,
fourcc: buf.fourcc,
modifier: buf.modifier,
pitch: buf.pitch,
offset: buf.offset,
})
}
fn export_through_helper(&self, fb_id: u32) -> Result<grab::Exported> {
self.helper.borrow_mut().as_mut()
.context("Framely panel helper is not connected; start capture from Framely's casting page")?
.export(fb_id)
}
}
fn find_scanout_plane(card: &Card, crtc: crtc::Handle) -> Result<plane::Handle> {
let mut best: Option<(plane::Handle, u64)> = None;
for handle in card.plane_handles().context("listing planes")? {
let Ok(info) = card.get_plane(handle) else { continue };
if info.crtc() != Some(crtc) {
continue;
}
let Some(fb) = info.framebuffer() else { continue };
// GETFB2 exposes dimensions even when the unprivileged recorder cannot
// see GEM handles. Legacy GETFB can fail on modern scanout formats.
// If it fails for every plane, choosing the first one can select a cursor.
let props = card.get_properties(handle).ok();
let mut cursor = false;
let (mut width, mut height) = (0, 0);
if let Some(props) = props {
for (&property, &value) in props.iter() {
let Ok(info) = card.get_property(property) else { continue };
match info.name().to_bytes() {
b"type" => cursor = value == 2, // DRM_PLANE_TYPE_CURSOR
b"CRTC_W" => width = value,
b"CRTC_H" => height = value,
_ => {}
}
}
}
let size = card.get_planar_framebuffer(fb).map(|f| f.size())
.or_else(|_| card.get_framebuffer(fb).map(|f| f.size()));
let area = size.map(|(w,h)| w as u64 * h as u64).unwrap_or(width.saturating_mul(height));
if cursor || area == 0 { continue; }
if best.is_none_or(|(_, a)| area > a) {
best = Some((handle, area));
}
}
best.map(|(h, _)| h).context("no plane is scanning out, is the VR compositor running?")
}
fn mode_refresh(mode: &drm::control::Mode) -> f64 {
let (htotal, vtotal) = (mode.hsync().2 as f64, mode.vsync().2 as f64);
if htotal > 0.0 && vtotal > 0.0 {
mode.clock() as f64 * 1000.0 / (htotal * vtotal)
} else {
mode.vrefresh() as f64
}
}
/// Identifies the buffer behind a dmabuf fd. Every export of the same buffer
/// shares one dma_buf file, so its inode is stable for the buffer's lifetime.
pub fn buffer_id(fd: &OwnedFd) -> Result<u64> {
let mut st: libc::stat = unsafe { std::mem::zeroed() };
if unsafe { libc::fstat(fd.as_raw_fd(), &mut st) } != 0 {
return Err(std::io::Error::last_os_error()).context("looking at a scanout buffer");
}
Ok(st.st_ino)
}
/// Waits (briefly) until nobody is still drawing into the buffer.
pub fn wait_idle(fd: &OwnedFd, timeout: Duration) {
let mut pfd = libc::pollfd { fd: fd.as_raw_fd(), events: libc::POLLIN, revents: 0 };
unsafe { libc::poll(&mut pfd, 1, timeout.as_millis() as libc::c_int) };
}
+2
View File
@@ -0,0 +1,2 @@
pub mod openvr;
pub mod grab;
+467
View File
@@ -0,0 +1,467 @@
//! The lookup grid the shader uses to find, for each output pixel, where on
//! the panel to read from. Built once at startup, so the per-frame GPU work is
//! just a few texture reads per pixel.
use anyhow::{bail, Result};
use framely_capture::openvr::OpenVr;
/// Output pixels between grid points. The mapping is smooth, 8 is plenty.
const STEP: u32 = 8;
/// Samples per side when reading SteamVR's distortion function.
const DISTORTION_GRID: usize = 97;
const INVALID: [f32; 2] = [-1.0, -1.0];
/// Color fringes narrower than this (in output pixels) are left alone.
const MIN_FRINGE_PX: f64 = 0.35;
/// Supersample once an output pixel covers more than this many panel pixels.
const SUPERSAMPLE_ABOVE: f64 = 1.5;
/// Panel pixels across one eye.
const EYE_PX: f64 = 2160.0;
/// The Frame's compositor scans each eye out turned 180° in its own half,
/// while SteamVR's distortion data describes the panels upright. Checked on
/// a headset: straight ahead lands where the in-place rotation puts it (not
/// where a whole-display rotation would), and recordings came out upside
/// down without this.
const PANELS_ROTATED: bool = true;
pub struct Lut {
pub view: Option<(f64, f64, f64)>,
pub width: u32,
pub height: u32,
pub step: f32,
/// Whether the red and blue channels have their own positions (they do
/// when the compositor corrects chromatic aberration).
pub per_channel: bool,
/// Whether the output shrinks the panel enough that single taps would
/// alias, so the shader should average a few.
pub supersample: bool,
/// Grid positions are in SteamVR's panel space; the scanout has each
/// eye's half turned 180° from that. See `PANELS_ROTATED`.
pub rotated_eyes: bool,
/// Scanout texture coordinates, 3 per grid point (r, g, b).
pub points: Vec<[f32; 2]>,
}
impl Lut {
fn new(out_w: u32, out_h: u32) -> Self {
let width = out_w.div_ceil(STEP) + 1;
let height = out_h.div_ceil(STEP) + 1;
Self {
view: None,
width,
height,
step: STEP as f32,
per_channel: false,
supersample: false,
rotated_eyes: PANELS_ROTATED,
points: vec![INVALID; (width * height * 3) as usize],
}
}
fn grid(&self) -> impl Iterator<Item = (usize, f64, f64)> + '_ {
(0..self.height).flat_map(move |j| {
(0..self.width).map(move |i| (((j * self.width + i) * 3) as usize, (i * STEP) as f64, (j * STEP) as f64))
})
}
}
/// The whole panel, both eyes, lens distortion and all, fitted into the
/// output with black bars.
pub fn raw(out_w: u32, out_h: u32, src_w: u32, src_h: u32) -> Lut {
let mut lut = Lut::new(out_w, out_h);
let scale = (out_w as f64 / src_w as f64).min(out_h as f64 / src_h as f64);
lut.supersample = 1.0 / scale > SUPERSAMPLE_ABOVE;
let (fit_w, fit_h) = (src_w as f64 * scale, src_h as f64 * scale);
let (ox, oy) = ((out_w as f64 - fit_w) / 2.0, (out_h as f64 - fit_h) / 2.0);
let cells: Vec<_> = lut.grid().collect();
for (idx, x, y) in cells {
let u = (x - ox) / fit_w;
let v = (y - oy) / fit_h;
// Let the grid run a bit past the picture so the edges interpolate
// cleanly, anything further out is a black bar.
let margin = STEP as f64 / fit_w.min(fit_h);
let p = if (-margin..=1.0 + margin).contains(&u) && (-margin..=1.0 + margin).contains(&v) {
[u.clamp(0.0, 1.0) as f32, v.clamp(0.0, 1.0) as f32]
} else {
INVALID
};
lut.points[idx..idx + 3].fill(p);
}
lut
}
/// Output edge points checked per side when looking for the widest clean view.
const EDGE_SAMPLES: usize = 64;
/// Pulls the automatic view in a little from the very edge of what's drawn,
/// where the lens is at its blurriest.
const AUTO_FOV_MARGIN: f64 = 0.97;
/// Resolution of the hidden area mask, per side.
const MASK_SIZE: usize = 512;
/// How far off straight ahead the automatic view may center itself. Zero:
/// a few extra degrees aren't worth the middle of the video not being where
/// you looked.
const MAX_CENTER_SHIFT_DEG: f64 = 0.0;
/// Candidate centers per direction on each side of straight ahead.
const CENTER_STEPS: i32 = 5;
/// Everything we know about how one eye maps to the panel.
struct EyeModel {
eye: usize,
l: f64,
r: f64,
t: f64,
b: f64,
roll: f64,
/// Panel -> render target, sampled on a grid, per channel.
forward: Vec<[[f64; 2]; 3]>,
/// Render target spots the compositor never draws.
hidden: Vec<bool>,
/// Render target spots that land somewhere on the panel.
covered: Vec<bool>,
}
impl EyeModel {
fn new(vr: &OpenVr, eye: usize) -> Result<Self> {
let proj = vr.projection(eye);
let roll = vr.eye_roll(eye);
log::info!(
"eye {eye} projection: {proj:?}, render target {:?}, roll {:.2}°",
vr.render_target_size(),
roll.to_degrees()
);
if proj.left >= 0.0 || proj.right <= 0.0 || proj.top >= 0.0 || proj.bottom <= 0.0 {
bail!("SteamVR reported a weird projection: {proj:?}");
}
let n = DISTORTION_GRID;
let mut forward = vec![[[f64::NAN; 2]; 3]; n * n];
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 triangles = vr.hidden_area(eye);
let hidden = rasterize(&triangles);
if log::log_enabled!(log::Level::Debug) {
let rows: Vec<String> = (0..32)
.map(|y| (0..64).map(|x| if hidden[(y * 16) * MASK_SIZE + x * 8] { '#' } else { '.' }).collect())
.collect();
log::debug!("hidden area of eye {eye}:\n{}", rows.join("\n"));
}
log::info!(
"hidden area: {} triangles, {:.1}% of the render target",
triangles.len(),
hidden.iter().filter(|h| **h).count() as f64 * 100.0 / hidden.len() as f64
);
let covered = rasterize(&panel_coverage(&forward, n));
Ok(Self {
eye,
l: proj.left as f64,
r: proj.right as f64,
t: proj.top as f64,
b: proj.bottom as f64,
roll,
forward,
hidden,
covered,
})
}
/// 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)]
}
/// Whether a render target spot is on the panel and actually drawn.
fn shown(&self, uv: [f64; 2]) -> bool {
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]
}
/// Panel position (eye local) that shows this render target spot in channel `c`.
fn solve(&self, c: usize, target: [f64; 2], guess: [f64; 2]) -> Option<[f64; 2]> {
let n = DISTORTION_GRID;
invert(&self.forward, n, c, target, guess).or_else(|| {
let start = nearest(&self.forward, n, c, target)?;
invert(&self.forward, n, c, target, start)
})
}
/// Widest view at this aspect ratio that's entirely drawn, as the
/// 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| {
let tan_v = tan_h * aspect;
(0..=EDGE_SAMPLES).all(|k| {
let f = k as f64 / EDGE_SAMPLES as f64 * 2.0 - 1.0;
[
(cx + f * tan_h, cy - tan_v),
(cx + f * tan_h, cy + tan_v),
(cx - tan_h, cy + f * tan_v),
(cx + tan_h, cy + f * tan_v),
]
.iter()
.all(|&(x, y)| self.shown(self.target(x, y)))
})
};
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);
if !fits(1e-3, cx, cy) {
return 0.0;
}
for _ in 0..30 {
let mid = (lo + hi) / 2.0;
if fits(mid, cx, cy) {
lo = mid;
} else {
hi = mid;
}
}
lo
};
let centered = search(0.0, 0.0);
let max_shift = MAX_CENTER_SHIFT_DEG.to_radians().tan();
let mut best = (centered, 0.0, 0.0);
for i in -CENTER_STEPS..=CENTER_STEPS {
for j in -CENTER_STEPS..=CENTER_STEPS {
let cx = max_shift * i as f64 / CENTER_STEPS as f64;
let cy = max_shift * j as f64 / CENTER_STEPS as f64;
let w = search(cx, cy);
if w > best.0 {
best = (w, cx, cy);
}
}
}
// Only move off center when it's worth it.
if best.0 < centered * 1.02 {
best = (centered, 0.0, 0.0);
}
best
}
}
/// The panel's outline in render target space, as triangles.
fn panel_coverage(forward: &[[[f64; 2]; 3]], n: usize) -> Vec<[[f32; 2]; 3]> {
let mut triangles = Vec::new();
let at = |i: usize, j: usize| forward[j * n + i][1];
for j in 0..n - 1 {
for i in 0..n - 1 {
let q = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)];
if q.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) {
continue;
}
let q = q.map(|p| [p[0] as f32, p[1] as f32]);
triangles.push([q[0], q[1], q[2]]);
triangles.push([q[0], q[2], q[3]]);
}
}
triangles
}
/// Turns the hidden area triangles into a lookup mask.
fn rasterize(triangles: &[[[f32; 2]; 3]]) -> Vec<bool> {
let mut mask = vec![false; MASK_SIZE * MASK_SIZE];
let size = MASK_SIZE as f64;
for tri in triangles {
let p = tri.map(|v| [v[0] as f64 * size, v[1] as f64 * size]);
let min_x = p.iter().map(|v| v[0]).fold(f64::INFINITY, f64::min).floor().max(0.0) as usize;
let max_x = p.iter().map(|v| v[0]).fold(f64::NEG_INFINITY, f64::max).ceil().min(size) as usize;
let min_y = p.iter().map(|v| v[1]).fold(f64::INFINITY, f64::min).floor().max(0.0) as usize;
let max_y = p.iter().map(|v| v[1]).fold(f64::NEG_INFINITY, f64::max).ceil().min(size) as usize;
let edge = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
let area = edge(p[0], p[1], p[2]);
if area.abs() < 1e-12 {
continue;
}
for y in min_y..max_y {
for x in min_x..max_x {
let c = [x as f64 + 0.5, y as f64 + 0.5];
let w = [edge(p[1], p[2], c), edge(p[2], p[0], c), edge(p[0], p[1], c)];
if w.iter().all(|w| w * area.signum() >= 0.0) {
mask[y * MASK_SIZE + x] = true;
}
}
}
}
mask
}
/// Fits an upright flat source (SteamVR's headset view) into the output,
/// letterboxed if the shapes differ.
pub fn flat(out_w: u32, out_h: u32, src_w: u32, src_h: u32) -> Lut {
let mut lut = raw(out_w, out_h, src_w, src_h);
lut.rotated_eyes = false;
lut
}
/// One eye as a flat 2D picture: the lens distortion SteamVR applied gets
/// undone, cropped to the output's aspect ratio around straight ahead. With
/// no field of view given, it picks the widest one that has no black edges.
pub fn undistorted(vr: &OpenVr, eye: usize, fov_deg: Option<f64>, out_w: u32, out_h: u32, center_x: f64, center_y: f64) -> Result<Lut> {
let model = EyeModel::new(vr, eye)?;
let aspect = out_h as f64 / out_w as f64;
let (widest, wide_cx, wide_cy) = model.widest(aspect);
if widest <= 0.0 {
bail!("couldn't find any clean part of the view, SteamVR's lens data looks off");
}
log::info!(
"widest clean view at this aspect ratio: {:.1}° x {:.1}°, centered {:.1}° right and {:.1}° down",
2.0 * widest.atan().to_degrees(),
2.0 * (widest * aspect).atan().to_degrees(),
wide_cx.atan().to_degrees(),
wide_cy.atan().to_degrees()
);
let (tan_h, cx, cy) = match fov_deg {
Some(fov) => {
let wanted = (fov.to_radians() / 2.0).tan();
if wanted > widest {
log::warn!("a {fov}° view reaches past what the lens shows, the edges will be black");
}
(wanted, 0.0, 0.0)
}
None => (widest * AUTO_FOV_MARGIN, wide_cx, wide_cy),
};
let cx = cx + center_x.to_radians().tan();
let cy = cy + center_y.to_radians().tan();
let tan_v = tan_h * aspect;
log::info!(
"recording {:.1}° x {:.1}° of the eye's view",
2.0 * tan_h.atan().to_degrees(),
2.0 * tan_v.atan().to_degrees()
);
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));
let mut guess = [[0.5f64, 0.5]; 3];
// Widest color fringe, measured in output pixels.
let mut max_split = 0.0f64;
let mut max_minify = 0.0f64;
let mut prev_green: Option<[f64; 2]> = None;
let cells: Vec<_> = lut.grid().collect();
for (idx, x, y) in cells {
if x == 0.0 {
prev_green = None;
}
let tx = cx + (x / out_w as f64 * 2.0 - 1.0) * tan_h;
let ty = cy + (y / out_h as f64 * 2.0 - 1.0) * tan_v;
let target = model.target(tx, ty);
let mut solved = [None; 3];
for c in 0..3 {
if let Some(d) = model.solve(c, target, guess[c]) {
guess[c] = d;
solved[c] = Some(d);
lut.points[idx + c] = [(eye_offset + d[0] * 0.5) as f32, d[1] as f32];
}
}
if let (Some(rd), Some(gd), Some(bd), Some(prev)) = (solved[0], solved[1], solved[2], prev_green) {
// How far the panel moves per output pixel, to express the split in output pixels.
let panel_per_out = dist(gd, prev) / STEP as f64;
if panel_per_out > 0.0 {
max_split = max_split.max(dist(rd, gd).max(dist(bd, gd)) / panel_per_out);
max_minify = max_minify.max(panel_per_out * EYE_PX);
}
}
prev_green = solved[1];
}
// A fringe well under a pixel in the video isn't worth tripling the
// texture reads for.
lut.per_channel = max_split > MIN_FRINGE_PX;
lut.supersample = max_minify > SUPERSAMPLE_ABOVE;
log::info!(
"distortion grid ready: color fringe up to {:.2} px (correcting: {}), up to {:.2} panel px per output px (supersampling: {})",
max_split,
lut.per_channel,
max_minify,
lut.supersample
);
Ok(lut)
}
fn dist(a: [f64; 2], b: [f64; 2]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt()
}
/// Bilinear read of the sampled forward mapping. None outside the panel.
fn eval(forward: &[[[f64; 2]; 3]], n: usize, c: usize, d: [f64; 2]) -> Option<[f64; 2]> {
if !(0.0..=1.0).contains(&d[0]) || !(0.0..=1.0).contains(&d[1]) {
return None;
}
let gx = d[0] * (n - 1) as f64;
let gy = d[1] * (n - 1) as f64;
let (i, j) = ((gx.floor() as usize).min(n - 2), (gy.floor() as usize).min(n - 2));
let (fx, fy) = (gx - i as f64, gy - j as f64);
let p = |ii: usize, jj: usize| forward[jj * n + ii][c];
let (a, b, e, f) = (p(i, j), p(i + 1, j), p(i, j + 1), p(i + 1, j + 1));
let out = [0, 1].map(|k| {
let top = a[k] + (b[k] - a[k]) * fx;
let bottom = e[k] + (f[k] - e[k]) * fx;
top + (bottom - top) * fy
});
out.iter().all(|v| v.is_finite()).then_some(out)
}
/// Newton's method on the forward mapping.
fn invert(forward: &[[[f64; 2]; 3]], n: usize, c: usize, target: [f64; 2], start: [f64; 2]) -> Option<[f64; 2]> {
const H: f64 = 1e-4;
let mut d = start;
for _ in 0..24 {
let f = eval(forward, n, c, d)?;
let err = [f[0] - target[0], f[1] - target[1]];
if err[0].abs() < 1e-7 && err[1].abs() < 1e-7 {
return Some(d);
}
let dx0 = eval(forward, n, c, [(d[0] - H).max(0.0), d[1]])?;
let dx1 = eval(forward, n, c, [(d[0] + H).min(1.0), d[1]])?;
let dy0 = eval(forward, n, c, [d[0], (d[1] - H).max(0.0)])?;
let dy1 = eval(forward, n, c, [d[0], (d[1] + H).min(1.0)])?;
let hx = (d[0] + H).min(1.0) - (d[0] - H).max(0.0);
let hy = (d[1] + H).min(1.0) - (d[1] - H).max(0.0);
let j = [
[(dx1[0] - dx0[0]) / hx, (dy1[0] - dy0[0]) / hy],
[(dx1[1] - dx0[1]) / hx, (dy1[1] - dy0[1]) / hy],
];
let det = j[0][0] * j[1][1] - j[0][1] * j[1][0];
if det.abs() < 1e-12 {
return None;
}
let step = [
(j[1][1] * err[0] - j[0][1] * err[1]) / det,
(-j[1][0] * err[0] + j[0][0] * err[1]) / det,
];
d = [(d[0] - step[0]).clamp(0.0, 1.0), (d[1] - step[1]).clamp(0.0, 1.0)];
}
let f = eval(forward, n, c, d)?;
(dist(f, target) < 1e-5).then_some(d)
}
fn nearest(forward: &[[[f64; 2]; 3]], n: usize, c: usize, target: [f64; 2]) -> Option<[f64; 2]> {
let (best, _) = forward
.iter()
.enumerate()
.filter(|(_, p)| p[c][0].is_finite())
.map(|(k, p)| (k, dist(p[c], target)))
.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])
}
+86
View File
@@ -0,0 +1,86 @@
//! Framely's panel capture worker. stdout is Annex-B H.264, stderr is logging.
mod clock;
mod encoder;
mod gpu;
mod kms;
mod lut;
use anyhow::{Context, Result};
use clap::Parser;
use framely_capture::openvr::OpenVr;
use std::{io::{self, Write}, os::fd::AsRawFd, path::Path, time::{Duration, Instant}};
#[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 = 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, 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,
#[arg(long, default_value_t = 0.)] center_y: f64,
}
fn run(a: Args) -> Result<()> {
anyhow::ensure!(a.width >= 160 && a.width <= 4096 && a.width % 2 == 0
&& a.height >= 160 && a.height <= 4096 && a.height % 2 == 0
&& a.width as u64 * a.height as u64 <= 8_847_360
&& (1..=120).contains(&a.fps) && (1..=80).contains(&a.bitrate) && a.eye <= 1,
"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!(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) }
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 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,
}, 3)?;
let filter = if map.supersample { gpu::Filter::Supersample } else { gpu::Filter::Sharp };
let mut gpu = gpu::Gpu::new(enc.layout(), 3, &map, filter, gpu::Priority::Low, None)?;
let mut output = io::BufWriter::new(io::stdout().lock());
let mut origin = None;
let mut next = Duration::ZERO;
let interval = Duration::from_secs_f64(1. / a.fps as f64);
let tolerance = Duration::from_secs_f64(0.5 / kms.refresh_hz);
let mut frames = 0u64;
let mut last_status = Instant::now();
loop {
let time = kms.wait_vblank().context("Headset display is unavailable")?;
let first = *origin.get_or_insert(time);
let elapsed = time.saturating_sub(first);
if elapsed + tolerance < next { continue; }
// Keep a stable cadence when output FPS is not a divisor of display refresh.
while next <= elapsed + tolerance { next += interval; }
let Some(fb) = kms.current_fb()? else { continue; };
let buf = kms.export(fb)?;
kms::wait_idle(&buf.fd, Duration::from_millis(4));
let id = kms::buffer_id(&buf.fd)?;
if !gpu.has_source(fb, id) { gpu.add_source(buf, id)?; }
if let Some(slot) = enc.free_slot()? {
gpu.convert(fb, id, slot)?;
enc.queue_frame(slot, gpu.targets()[slot].fd.as_raw_fd(), elapsed.as_micros() as u64)?;
frames += 1;
}
let mut error = None;
enc.poll_packets(|packet| {
if let Err(e) = output.write_all(packet.data).and_then(|_| output.flush()) { error = Some(e); }
})?;
if let Some(e) = error { return Err(e.into()); }
if frames == 1 || last_status.elapsed() > Duration::from_secs(5) {
eprintln!("{}", serde_json::json!({"event":"capture","width":a.width,"height":height,"frames":frames}));
last_status = Instant::now();
}
}
}
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
if let Err(e) = run(Args::parse()) { eprintln!("{e:#}"); std::process::exit(1); }
}
+264
View File
@@ -0,0 +1,264 @@
//! Just enough OpenVR to ask SteamVR what its lens distortion looks like.
//! We connect as a background app, grab the numbers and disconnect again, so
//! nothing stays attached to the compositor while recording.
use std::ffi::{c_char, c_void, CStr, CString};
use anyhow::{bail, Result};
const DEFAULT_LIB: &str = "/opt/steamvr/bin/linuxarm64/libopenvr_api.so";
/// How we present ourselves to SteamVR.
#[derive(Clone, Copy)]
pub enum AppType {
/// Doesn't show anything and doesn't start SteamVR.
Background = 3,
/// Draws overlays (the dashboard tab). Careful: connecting like this
/// starts SteamVR's server if it isn't running, and a server started
/// from outside SteamVR's own launcher leaves the headset without a
/// working compositor. Check `server_running` first.
Overlay = 2,
}
/// Interface versions we know the function table layout of. The first four
/// slots haven't moved in any of them; the rest only get used on the newest.
const SYSTEM_VERSIONS: &[&str] = &[
"IVRSystem_026",
"IVRSystem_023",
"IVRSystem_022",
"IVRSystem_021",
"IVRSystem_020",
"IVRSystem_019",
];
const FULL_LAYOUT: &str = "IVRSystem_026";
// Slots in VR_IVRSystem_FnTable (openvr_capi.h).
const SLOT_RENDER_TARGET_SIZE: usize = 0;
const SLOT_PROJECTION_RAW: usize = 2;
const SLOT_COMPUTE_DISTORTION: usize = 3;
const SLOT_EYE_TO_HEAD: usize = 5;
const SLOT_HIDDEN_AREA_MESH: usize = 34;
const HIDDEN_AREA_STANDARD: i32 = 0;
#[repr(C)]
#[derive(Default, Clone, Copy)]
struct DistortionCoordinates {
red: [f32; 2],
green: [f32; 2],
blue: [f32; 2],
}
#[repr(C)]
#[derive(Clone, Copy)]
struct HmdMatrix34 {
m: [[f32; 4]; 3],
}
#[repr(C)]
#[derive(Clone, Copy)]
struct HiddenAreaMesh {
vertices: *const [f32; 2],
triangles: u32,
}
type InitFn = unsafe extern "C" fn(*mut i32, i32, *const c_char) -> isize;
type ShutdownFn = unsafe extern "C" fn();
type GetInterfaceFn = unsafe extern "C" fn(*const c_char, *mut i32) -> *mut c_void;
type ErrorNameFn = unsafe extern "C" fn(i32) -> *const c_char;
/// Tangents of the half angles an eye's render target covers. Top is
/// negative, since OpenVR's y points down in texture space.
#[derive(Clone, Copy, Debug)]
pub struct Projection {
pub left: f32,
pub right: f32,
pub top: f32,
pub bottom: f32,
}
pub struct OpenVr {
lib: *mut c_void,
table: *const *const c_void,
full_layout: bool,
shutdown: ShutdownFn,
get_interface: GetInterfaceFn,
}
impl OpenVr {
pub fn connect() -> Result<Self> {
Self::connect_as(AppType::Background)
}
/// Whether SteamVR's server is up. Connects as a background app, which
/// SteamVR never starts a server for, so this can't launch one by accident.
pub fn server_running() -> bool {
Self::connect_as(AppType::Background).is_ok()
}
pub fn connect_as(app: AppType) -> Result<Self> {
let path = std::env::var("FRAMECORDER_OPENVR_LIB").unwrap_or_else(|_| DEFAULT_LIB.into());
let cpath = CString::new(path.clone())?;
let lib = unsafe { libc::dlopen(cpath.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL) };
if lib.is_null() {
bail!("couldn't load {path}: {}", dl_error());
}
let init: InitFn = unsafe { sym(lib, c"VR_InitInternal2")? };
let shutdown: ShutdownFn = unsafe { sym(lib, c"VR_ShutdownInternal")? };
let get_interface: GetInterfaceFn = unsafe { sym(lib, c"VR_GetGenericInterface")? };
let error_name: Option<ErrorNameFn> =
unsafe { sym(lib, c"VR_GetVRInitErrorAsEnglishDescription").ok() };
let mut err = 0i32;
unsafe { init(&mut err, app as i32, std::ptr::null()) };
if err != 0 {
let msg = error_name
.map(|f| {
unsafe { CStr::from_ptr(f(err)) }
.to_string_lossy()
.into_owned()
})
.unwrap_or_default();
unsafe { libc::dlclose(lib) };
bail!("SteamVR isn't available ({err}: {msg})");
}
let found = SYSTEM_VERSIONS.iter().find_map(|v| {
let name = CString::new(format!("FnTable:{v}")).ok()?;
let mut err = 0i32;
let table = unsafe { get_interface(name.as_ptr(), &mut err) };
(err == 0 && !table.is_null()).then_some((*v, table as *const *const c_void))
});
let Some((version, table)) = found else {
unsafe {
shutdown();
libc::dlclose(lib);
}
bail!("this SteamVR doesn't offer a known IVRSystem version");
};
log::debug!("using {version}");
if version != FULL_LAYOUT {
log::warn!(
"SteamVR only offers {version}, so the hidden area and eye roll can't be read"
);
}
Ok(Self {
lib,
table,
full_layout: version == FULL_LAYOUT,
shutdown,
get_interface,
})
}
/// Function table of another OpenVR interface, e.g. `IVROverlay_028`.
pub fn interface(&self, version: &str) -> Option<*const *const c_void> {
let name = CString::new(format!("FnTable:{version}")).ok()?;
let mut err = 0i32;
let table = unsafe { (self.get_interface)(name.as_ptr(), &mut err) };
(err == 0 && !table.is_null()).then_some(table as *const *const c_void)
}
unsafe fn slot<T: Copy>(&self, index: usize) -> T {
std::mem::transmute_copy(&*self.table.add(index))
}
pub fn render_target_size(&self) -> (u32, u32) {
let (mut w, mut h) = (0, 0);
unsafe {
let f: unsafe extern "C" fn(*mut u32, *mut u32) = self.slot(SLOT_RENDER_TARGET_SIZE);
f(&mut w, &mut h);
}
(w, h)
}
pub fn projection(&self, eye: usize) -> Projection {
let (mut l, mut r, mut t, mut b) = (0.0, 0.0, 0.0, 0.0);
unsafe {
let f: unsafe extern "C" fn(i32, *mut f32, *mut f32, *mut f32, *mut f32) =
self.slot(SLOT_PROJECTION_RAW);
f(eye as i32, &mut l, &mut r, &mut t, &mut b);
}
Projection {
left: l,
right: r,
top: t,
bottom: b,
}
}
/// For a point on the panel (eye local, 0..1), where in the undistorted
/// render target each color channel comes from.
pub fn distortion(&self, eye: usize, u: f32, v: f32) -> Option<[[f32; 2]; 3]> {
let mut out = DistortionCoordinates::default();
let ok = unsafe {
let f: unsafe extern "C" fn(i32, f32, f32, *mut DistortionCoordinates) -> bool =
self.slot(SLOT_COMPUTE_DISTORTION);
f(eye as i32, u, v, &mut out)
};
ok.then_some([out.red, out.green, out.blue])
}
/// 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 {
if !self.full_layout {
return 0.0;
}
let t = unsafe {
let f: unsafe extern "C" fn(i32) -> HmdMatrix34 = self.slot(SLOT_EYE_TO_HEAD);
f(eye as i32)
};
log::debug!("eye {eye} to head: {:?}", t.m);
(t.m[1][0] as f64).atan2(t.m[0][0] as f64)
}
/// Triangles, in render target UV, covering what the compositor never
/// draws for this eye.
pub fn hidden_area(&self, eye: usize) -> Vec<[[f32; 2]; 3]> {
if !self.full_layout {
return Vec::new();
}
unsafe {
let f: unsafe extern "C" fn(i32, i32) -> HiddenAreaMesh =
self.slot(SLOT_HIDDEN_AREA_MESH);
let mesh = f(eye as i32, HIDDEN_AREA_STANDARD);
if mesh.vertices.is_null() || mesh.triangles == 0 || mesh.triangles > 1 << 20 {
return Vec::new();
}
std::slice::from_raw_parts(mesh.vertices, mesh.triangles as usize * 3)
.as_chunks::<3>()
.0
.to_vec()
}
}
}
impl Drop for OpenVr {
fn drop(&mut self) {
unsafe {
(self.shutdown)();
libc::dlclose(self.lib);
}
}
}
unsafe fn sym<T: Copy>(lib: *mut c_void, name: &CStr) -> Result<T> {
let ptr = libc::dlsym(lib, name.as_ptr());
if ptr.is_null() {
bail!("{} is missing from libopenvr_api", name.to_string_lossy());
}
Ok(std::mem::transmute_copy(&ptr))
}
fn dl_error() -> String {
let err = unsafe { libc::dlerror() };
if err.is_null() {
return "unknown error".into();
}
unsafe { CStr::from_ptr(err) }
.to_string_lossy()
.into_owned()
}
+22
View File
@@ -0,0 +1,22 @@
{
"mediamtx": {
"version": "1.21.2",
"url": "https://github.com/bluenviron/mediamtx/releases/download/v1.21.2/mediamtx_v1.21.2_linux_arm64.tar.gz",
"sha256": "180a235374a548d9133cdd5e20f5dbd8b01317be1c86e0d7e66df585dc42ed6f"
},
"uxplay": {
"version": "1.73.7",
"url": "https://github.com/FDH2/UxPlay/archive/refs/tags/v1.73.7.tar.gz",
"sha256": "65feb8732de666a7161a9e562aa603a7f5fe0ebb890c2a30c5e1a9c85c76309f"
},
"gst-libav": {
"version": "1.24.13",
"url": "https://gstreamer.freedesktop.org/src/gst-libav/gst-libav-1.24.13.tar.xz",
"sha256": "150e2b70588fa32a1294f42665756f2175417ce4b5988e2c2081b683719aa6c1"
},
"ffmpeg-build-api": {
"version": "7.0",
"url": "https://ffmpeg.org/releases/ffmpeg-7.0.tar.xz",
"sha256": "4426a94dd2c814945456600c8adfc402bee65ec14a70e8c531ec9a2cd651da7b"
}
}
+38
View File
@@ -0,0 +1,38 @@
#include "frame_sink.h"
#include <cstdlib>
#include <fstream>
struct FramelySink {GstBin parent;FrameSink* frame;GstElement* gate;GstElement* gain;GstElement* output;guint timer;bool audio;};
struct FramelySinkClass {GstBinClass parent;};
typedef FramelySink FramelyAudio;typedef FramelySinkClass FramelyAudioClass;
G_DEFINE_TYPE(FramelySink,framely_sink,GST_TYPE_BIN)
G_DEFINE_TYPE(FramelyAudio,framely_audio,GST_TYPE_BIN)
static gboolean tick(gpointer p){auto* s=(FramelySink*)p;bool allowed=s->frame->accepted();g_object_set(s->gate,"drop",!allowed,nullptr);
std::ifstream volume(s->frame->directory+"/"+s->frame->id+".volume");double gain=1.;if(volume>>gain)g_object_set(s->gain,"volume",std::clamp(gain,0.,1.),nullptr);return G_SOURCE_CONTINUE;}
static void audio_frame(GstElement*,GstBuffer*,gpointer p){auto* s=(FramelySink*)p;tick(s);if(!s->frame->announced.exchange(true))s->frame->event("audio");}
static void setup(FramelySink* s,bool audio){
s->audio=audio;GError* error=nullptr;
auto* bin=gst_parse_bin_from_description(audio?"identity name=tap signal-handoffs=true ! valve name=gate drop=true drop-mode=transform-to-gap ! volume name=gain ! pulsesink name=output async=false":"videoflip video-direction=auto ! videoconvert ! videoscale ! video/x-raw,pixel-aspect-ratio=1/1,format=BGRA ! appsink name=frames emit-signals=true sync=true max-buffers=1 drop=true",true,&error);
if(!bin){g_warning("Framely sink: %s",error?error->message:"pipeline error");g_clear_error(&error);return;}
gst_bin_add(GST_BIN(s),bin);auto* pad=gst_element_get_static_pad(bin,"sink");gst_element_add_pad(GST_ELEMENT(s),gst_ghost_pad_new("sink",pad));gst_object_unref(pad);
s->output=gst_bin_get_by_name(GST_BIN(bin),audio?"output":"frames");
if(audio){s->gate=gst_bin_get_by_name(GST_BIN(bin),"gate");s->gain=gst_bin_get_by_name(GST_BIN(bin),"gain");auto* tap=gst_bin_get_by_name(GST_BIN(bin),"tap");g_signal_connect(tap,"handoff",G_CALLBACK(audio_frame),s);gst_object_unref(tap);}
else {auto* sink=gst_bin_get_by_name(GST_BIN(bin),"frames");g_signal_connect(sink,"new-sample",G_CALLBACK(frame_sample),s->frame);g_signal_connect(sink,"new-preroll",G_CALLBACK(frame_preroll),s->frame);gst_object_unref(sink);}
}
static void finalize(GObject* p){auto* s=(FramelySink*)p;if(s->timer)g_source_remove(s->timer);if(s->gate)gst_object_unref(s->gate);if(s->gain)gst_object_unref(s->gain);if(s->output)gst_object_unref(s->output);delete s->frame;G_OBJECT_CLASS(framely_sink_parent_class)->finalize(p);}
static void set_property(GObject* p,guint property,const GValue* value,GParamSpec* spec){
auto* s=(FramelySink*)p;if(property==1 && s->output)g_object_set(s->output,"sync",g_value_get_boolean(value),nullptr);else G_OBJECT_WARN_INVALID_PROPERTY_ID(p,property,spec);
}
static void get_property(GObject* p,guint property,GValue* value,GParamSpec* spec){
auto* s=(FramelySink*)p;if(property==1 && s->output){gboolean sync=true;g_object_get(s->output,"sync",&sync,nullptr);g_value_set_boolean(value,sync);}else G_OBJECT_WARN_INVALID_PROPERTY_ID(p,property,spec);
}
static void install_properties(GObjectClass* c){c->set_property=set_property;c->get_property=get_property;g_object_class_install_property(c,1,g_param_spec_boolean("sync","Sync","Synchronize playback to the pipeline clock",true,G_PARAM_READWRITE));}
static void framely_sink_class_init(FramelySinkClass* c){G_OBJECT_CLASS(c)->finalize=finalize;install_properties(G_OBJECT_CLASS(c));gst_element_class_set_static_metadata(GST_ELEMENT_CLASS(c),"Framely video consent sink","Sink/Video","Gated AirPlay receiver","Framely");}
static void initialize(FramelySink* s,bool audio){const char* d=getenv("FRAMELY_CAST_DIR"),*i=getenv("FRAMELY_CAST_ID");s->frame=new FrameSink(d?d:"/tmp",i?i:"airplay","AirPlay");GST_OBJECT_FLAG_SET(s,GST_ELEMENT_FLAG_SINK);setup(s,audio);}
static void framely_sink_init(FramelySink* s){initialize(s,false);}
static void framely_audio_class_init(FramelyAudioClass* c){G_OBJECT_CLASS(c)->finalize=finalize;install_properties(G_OBJECT_CLASS(c));gst_element_class_set_static_metadata(GST_ELEMENT_CLASS(c),"Framely audio consent sink","Sink/Audio","Gated AirPlay receiver","Framely");}
static void framely_audio_init(FramelyAudio* s){initialize(s,true);}
static gboolean plugin_init(GstPlugin* p){return gst_element_register(p,"framelyvideosink",GST_RANK_NONE,framely_sink_get_type()) && gst_element_register(p,"framelyaudiosink",GST_RANK_NONE,framely_audio_get_type());}
#ifndef PACKAGE
#define PACKAGE "framely"
#endif
GST_PLUGIN_DEFINE(GST_VERSION_MAJOR,GST_VERSION_MINOR,framely,"Framely casting",plugin_init,"1.0","AGPL","Framely","https://github.com/SteamFramelyHomebrew/framely")
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <gst/gst.h>
#include <gst/app/gstappsink.h>
#include <gst/video/video.h>
#include <sys/file.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include <string>
#include <algorithm>
#include <vector>
#include <atomic>
// A frame file is written under an advisory lock. Readers never see a partial frame.
struct FrameSink {
std::string directory,id,protocol; std::atomic<bool> announced{false}; std::atomic<unsigned> width{0},height{0};
FrameSink(std::string d,std::string i,std::string p):directory(d),id(i),protocol(p){}
void event(const std::string& type,unsigned w=0,unsigned h=0,const std::string& media=""){
int fd=socket(AF_UNIX,SOCK_DGRAM|SOCK_CLOEXEC,0); if(fd<0)return;
sockaddr_un a{};a.sun_family=AF_UNIX; auto path=directory+"/events.sock";if(path.size()>=sizeof(a.sun_path)){close(fd);return;}strcpy(a.sun_path,path.c_str());
auto msg="{\"event\":\""+type+"\",\"id\":\""+id+"\",\"protocol\":\""+protocol+"\",\"width\":"+std::to_string(w)+",\"height\":"+std::to_string(h)+",\"mediaType\":\""+media+"\"}";
sendto(fd,msg.data(),msg.size(),MSG_DONTWAIT,(sockaddr*)&a,sizeof(a));close(fd);
}
bool accepted() const {return access((directory+"/"+id+".accept").c_str(),F_OK)==0;}
GstFlowReturn sample(GstAppSink* sink,bool preroll=false){
auto* sample=preroll?gst_app_sink_pull_preroll(sink):gst_app_sink_pull_sample(sink);if(!sample)return GST_FLOW_EOS;
GstVideoInfo info{};GstVideoFrame frame{};
if(gst_video_info_from_caps(&info,gst_sample_get_caps(sample))&&GST_VIDEO_INFO_FORMAT(&info)==GST_VIDEO_FORMAT_BGRA&&gst_video_frame_map(&frame,&info,gst_sample_get_buffer(sample),GST_MAP_READ)){
unsigned w=GST_VIDEO_INFO_WIDTH(&info),h=GST_VIDEO_INFO_HEIGHT(&info);
if(w&&h&&w<=4096&&h<=4096&&uint64_t(w)*h<=16777216){
bool resized=width.exchange(w)!=w;resized=(height.exchange(h)!=h)||resized;bool first=!announced.exchange(true);if(resized||first)event("video",w,h);
if(accepted()){
std::string path=directory+"/"+id+".frame";int fd=open(path.c_str(),O_CREAT|O_WRONLY|O_CLOEXEC|O_NOFOLLOW,0600);
if(fd>=0){flock(fd,LOCK_EX);uint32_t header[]={0x46434153,w,h,w*4};bool ok=pwrite(fd,header,sizeof(header),0)==sizeof(header);
for(unsigned y=0;ok&&y<h;y++){auto* row=(uint8_t*)GST_VIDEO_FRAME_PLANE_DATA(&frame,0)+y*GST_VIDEO_FRAME_PLANE_STRIDE(&frame,0);ok=pwrite(fd,row,w*4,16+size_t(y)*w*4)==ssize_t(w*4);}
if(ok)ftruncate(fd,16+size_t(w)*h*4);flock(fd,LOCK_UN);close(fd);
}
}
}
gst_video_frame_unmap(&frame);
}
gst_sample_unref(sample);return GST_FLOW_OK;
}
};
inline GstFlowReturn frame_sample(GstAppSink* sink,gpointer data){return static_cast<FrameSink*>(data)->sample(sink);}
inline GstFlowReturn frame_preroll(GstAppSink* sink,gpointer data){return static_cast<FrameSink*>(data)->sample(sink,true);}
+289
View File
@@ -0,0 +1,289 @@
#include "frame_sink.h"
#include <libgupnp/gupnp.h>
#include "json.hpp"
#include <map>
#include <memory>
#include <iostream>
#include <fstream>
#include <cstdio>
#include <mutex>
#include <ifaddrs.h>
#include <net/if.h>
using json = nlohmann::json;
static std::string directory, current, own_udn, xml_directory;
static GMainLoop* loop;
static std::mutex output_mutex;
static bool receive_enabled = false;
static constexpr const char* protocols = "http-get:*:video/mp4:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp4:*,http-get:*:audio/flac:*,http-get:*:audio/wav:*,http-get:*:application/vnd.apple.mpegurl:*,http-get:*:application/x-mpegURL:*,http-get:*:video/mpeg:*";
static void output(json j) { std::lock_guard<std::mutex> lock(output_mutex); std::cout << j.dump() << std::endl; }
struct Media {
FrameSink sink;
GstElement* pipeline = nullptr;
std::string uri, metadata;
bool accepted = false, paused = false, muted = false;
double volume = 1.;
guint watch = 0;
Media(std::string id, std::string u, std::string m): sink(directory,id,"DLNA"), uri(u), metadata(m) {}
~Media() {
if (watch) g_source_remove(watch);
if (pipeline) { gst_element_set_state(pipeline,GST_STATE_NULL); gst_object_unref(pipeline); }
}
};
static std::map<std::string,std::unique_ptr<Media>> sessions;
static std::map<std::string,GUPnPServiceProxy*> devices;
static std::vector<GUPnPService*> transports, renderers;
static Media* active() { auto i = sessions.find(current); return i == sessions.end() ? nullptr : i->second.get(); }
static std::string state() { auto* m = active(); return !m ? "NO_MEDIA_PRESENT" : !m->accepted ? "STOPPED" : m->paused ? "PAUSED_PLAYBACK" : "PLAYING"; }
static gint64 position(Media* m, bool duration) {
gint64 value = 0;
if (m && m->pipeline) {
if (duration) gst_element_query_duration(m->pipeline,GST_FORMAT_TIME,&value);
else gst_element_query_position(m->pipeline,GST_FORMAT_TIME,&value);
}
return std::max(gint64(0),value);
}
static std::string time_string(gint64 ns) {
auto seconds = ns / GST_SECOND;
char text[48]; snprintf(text,sizeof(text),"%02lld:%02lld:%02lld",(long long)(seconds/3600),(long long)(seconds/60%60),(long long)(seconds%60));
return text;
}
static std::string last_change(bool rendering) {
std::string values;
if (rendering) {
auto* m = active();
values = "<Volume channel=\"Master\" val=\"" + std::to_string(m ? unsigned(m->volume*100) : 100) + "\"/><Mute channel=\"Master\" val=\"" + std::string(m && m->muted ? "1" : "0") + "\"/>";
} else values = "<TransportState val=\"" + state() + "\"/><TransportStatus val=\"OK\"/><TransportPlaySpeed val=\"1\"/>";
return "<Event xmlns=\"urn:schemas-upnp-org:metadata-1-0/" + std::string(rendering ? "RCS" : "AVT") + "/\"><InstanceID val=\"0\">" + values + "</InstanceID></Event>";
}
static void notify() {
for (auto* service : transports) { auto value = last_change(false); gupnp_service_notify(service,"LastChange",G_TYPE_STRING,value.c_str(),nullptr); }
for (auto* service : renderers) { auto value = last_change(true); gupnp_service_notify(service,"LastChange",G_TYPE_STRING,value.c_str(),nullptr); }
}
static void erase_media(const std::string& id) {
sessions.erase(id); unlink((directory+"/"+id+".accept").c_str());
if (current == id) current.clear();
notify();
}
static gboolean erase_later(gpointer data) { std::unique_ptr<std::string> id((std::string*)data); erase_media(*id); return G_SOURCE_REMOVE; }
static gboolean bus(GstBus*, GstMessage* message, gpointer data) {
auto* media = (Media*)data;
if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR || GST_MESSAGE_TYPE(message) == GST_MESSAGE_EOS) {
if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) {
GError* error=nullptr; gchar* debug=nullptr; gst_message_parse_error(message,&error,&debug);
output({{"event","error"},{"id",media->sink.id},{"error",error ? error->message : "Media playback failed"}});
g_clear_error(&error); g_free(debug);
} else output({{"event","ended"},{"id",media->sink.id}});
// Destroy on the main loop after this callback has returned. NULL state joins
// streaming threads before their FrameSink and Media pointers are released.
media->watch=0;
g_idle_add(erase_later,new std::string(media->sink.id));
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
static void streams_changed(GstElement* p, gpointer data) {
auto* m=(Media*)data; gint video=0,audio=0; g_object_get(p,"n-video",&video,"n-audio",&audio,nullptr);
if (audio>0 && video==0 && !m->sink.announced.exchange(true)) m->sink.event("audio");
}
static void play(Media& media) {
if (!media.pipeline) {
media.pipeline=gst_element_factory_make("playbin",nullptr);
if (!media.pipeline) throw std::runtime_error("GStreamer playbin unavailable");
gst_object_ref_sink(media.pipeline);
g_signal_connect(media.pipeline,"audio-changed",G_CALLBACK(streams_changed),&media);
g_signal_connect(media.pipeline,"video-changed",G_CALLBACK(streams_changed),&media);
GError* error=nullptr;
auto* video=gst_parse_bin_from_description("videoflip video-direction=auto ! videoconvert ! videoscale ! video/x-raw,pixel-aspect-ratio=1/1,format=BGRA ! appsink name=frames emit-signals=true max-buffers=1 drop=true sync=true",true,&error);
if (!video) { std::string reason=error ? error->message : "GStreamer video sink unavailable"; g_clear_error(&error); throw std::runtime_error(reason); }
gst_object_ref_sink(video);
auto* sink=gst_bin_get_by_name(GST_BIN(video),"frames");
g_signal_connect(sink,"new-sample",G_CALLBACK(frame_sample),&media.sink);
g_signal_connect(sink,"new-preroll",G_CALLBACK(frame_preroll),&media.sink); gst_object_unref(sink);
auto* audio=gst_element_factory_make("pulsesink",nullptr);
if (!audio) { gst_object_unref(video); throw std::runtime_error("GStreamer PulseAudio sink unavailable"); }
gst_object_ref_sink(audio);
g_object_set(media.pipeline,"uri",media.uri.c_str(),"video-sink",video,"audio-sink",audio,"volume",media.volume,"mute",media.muted,nullptr);
gst_object_unref(audio); gst_object_unref(video);
auto* b=gst_element_get_bus(media.pipeline); media.watch=gst_bus_add_watch(b,bus,&media); gst_object_unref(b);
}
if (gst_element_set_state(media.pipeline,media.paused ? GST_STATE_PAUSED : GST_STATE_PLAYING)==GST_STATE_CHANGE_FAILURE)
throw std::runtime_error("Media could not start playback");
}
static bool seekable(Media& m) {
if(!m.pipeline)return false;
auto* query=gst_query_new_seeking(GST_FORMAT_TIME);gboolean allowed=false;
if(gst_element_query(m.pipeline,query))gst_query_parse_seeking(query,nullptr,&allowed,nullptr,nullptr);
gst_query_unref(query);return allowed;
}
static void seek(Media& m, double seconds) {
if (!seekable(m) || !std::isfinite(seconds) || seconds<0 || seconds>86400*365 ||
!gst_element_seek_simple(m.pipeline,GST_FORMAT_TIME,(GstSeekFlags)(GST_SEEK_FLAG_FLUSH|GST_SEEK_FLAG_ACCURATE),gint64(seconds*GST_SECOND)))
throw std::runtime_error("This media cannot seek to the requested position");
}
static void set_volume(Media& m, double volume) {
if (!std::isfinite(volume) || volume<0 || volume>1) throw std::runtime_error("Invalid volume");
m.volume=volume; if (m.pipeline) g_object_set(m.pipeline,"volume",volume,nullptr); notify();
}
static void invoke(GUPnPServiceProxy* proxy,GUPnPServiceProxyAction* action) {
GError* error=nullptr;
if (!gupnp_service_proxy_call_action(proxy,action,nullptr,&error)) {
std::string message=error ? error->message : "UPnP action failed"; g_clear_error(&error); gupnp_service_proxy_action_unref(action); throw std::runtime_error(message);
}
gupnp_service_proxy_action_unref(action);
}
static gboolean input(GIOChannel* channel,GIOCondition condition,gpointer) {
if (condition&G_IO_HUP) { g_main_loop_quit(loop); return G_SOURCE_REMOVE; }
gchar* line=nullptr; gsize length=0;
if (g_io_channel_read_line(channel,&line,&length,nullptr,nullptr)!=G_IO_STATUS_NORMAL) return G_SOURCE_CONTINUE;
std::unique_ptr<char,decltype(&g_free)> owner(line,g_free); json request;
try {
request=json::parse(line); std::string kind=request.value("kind",""), id=request.value("id",""); json result=true;
if (kind=="devices") {
result=json::array();
for (auto& [id,p]:devices) {
auto* info=GUPNP_DEVICE_INFO(g_object_get_data(G_OBJECT(p),"device")); auto* name=gupnp_device_info_get_friendly_name(info);
auto* context=gupnp_service_info_get_context(GUPNP_SERVICE_INFO(p));
result.push_back({{"id",id},{"name",name ? name : id},{"localAddress",gssdp_client_get_host_ip(GSSDP_CLIENT(context))}}); g_free(name);
}
} else if (kind=="send" || kind=="send.stop") {
auto it=devices.find(id); if (it==devices.end()) throw std::runtime_error("Device is no longer available");
if (kind=="send") {
invoke(it->second,gupnp_service_proxy_action_new("SetAVTransportURI","InstanceID",G_TYPE_UINT,0,"CurrentURI",G_TYPE_STRING,request.at("uri").get<std::string>().c_str(),"CurrentURIMetaData",G_TYPE_STRING,"",nullptr));
invoke(it->second,gupnp_service_proxy_action_new("Play","InstanceID",G_TYPE_UINT,0,"Speed",G_TYPE_STRING,"1",nullptr));
} else invoke(it->second,gupnp_service_proxy_action_new("Stop","InstanceID",G_TYPE_UINT,0,nullptr));
} else {
auto it=sessions.find(id); if (it==sessions.end()) throw std::runtime_error("Media request expired"); auto& m=*it->second;
if (kind=="accept") {
if (m.accepted) throw std::runtime_error("Media already accepted");
std::ofstream gate(directory+"/"+id+".accept"); if (!gate) throw std::runtime_error("Cannot accept media"); gate.close();
m.accepted=true; play(m); notify();
} else if (kind=="stop" || kind=="reject") { erase_media(id); output({{"event","ended"},{"id",id}}); }
else {
if (!m.accepted) throw std::runtime_error("Media has not been accepted");
if (kind=="pause") { m.paused=request.value("paused",true); gst_element_set_state(m.pipeline,m.paused ? GST_STATE_PAUSED : GST_STATE_PLAYING); notify(); }
else if (kind=="volume") set_volume(m,request.value("volume",1.));
else if (kind=="seek") seek(m,request.value("seconds",0.));
else if (kind=="status") result={{"position",position(&m,false)/double(GST_SECOND)},{"duration",position(&m,true)/double(GST_SECOND)},{"paused",m.paused},{"volume",m.volume},{"seekable",seekable(m)}};
else throw std::runtime_error("Unknown media action");
}
}
output({{"request",request.value("request",0)},{"result",result}});
} catch (const std::exception& e) { output({{"request",request.value("request",0)},{"error",e.what()}}); }
return G_SOURCE_CONTINUE;
}
static void available(GUPnPControlPoint*,GUPnPDeviceProxy* device,gpointer) {
auto* service=gupnp_device_info_get_service(GUPNP_DEVICE_INFO(device),"urn:schemas-upnp-org:service:AVTransport:1"); if (!service) return;
std::string id=gupnp_device_info_get_udn(GUPNP_DEVICE_INFO(device));
if (id==own_udn) { g_object_unref(service); return; }
if (devices.count(id)) g_object_unref(devices[id]);
g_object_set_data_full(G_OBJECT(service),"device",g_object_ref(device),g_object_unref); devices[id]=GUPNP_SERVICE_PROXY(service);
}
static void unavailable(GUPnPControlPoint*,GUPnPDeviceProxy* device,gpointer) {
std::string id=gupnp_device_info_get_udn(GUPNP_DEVICE_INFO(device)); auto it=devices.find(id);
if (it!=devices.end() && g_object_get_data(G_OBJECT(it->second),"device")==device) { g_object_unref(it->second); devices.erase(it); }
}
static void action(GUPnPService*,GUPnPServiceAction* a,gpointer) {
const char* name=gupnp_service_action_get_name(a);
try {
if (strcmp(name,"GetProtocolInfo") && strncmp(name,"GetCurrentConnection",20)) {
guint instance=0; gupnp_service_action_get(a,"InstanceID",G_TYPE_UINT,&instance,nullptr);
if (instance!=0) { gupnp_service_action_return_error(a,718,"Invalid InstanceID"); return; }
}
if (!strcmp(name,"SetAVTransportURI")) {
gchar *uri=nullptr,*metadata=nullptr; gupnp_service_action_get(a,"CurrentURI",G_TYPE_STRING,&uri,"CurrentURIMetaData",G_TYPE_STRING,&metadata,nullptr);
std::string u=uri ? uri : "", meta=metadata ? metadata : ""; g_free(uri); g_free(metadata);
if (u.size()>8192 || meta.size()>65536 || (u.rfind("http://",0)!=0 && u.rfind("https://",0)!=0)) throw std::runtime_error("Only HTTP media URLs are supported");
if (sessions.size()>=16) throw std::runtime_error("Window limit reached");
gchar* id=g_uuid_string_random(); current=id; g_free(id);
sessions[current]=std::make_unique<Media>(current,u,meta); notify();
output({{"event","request"},{"id",current},{"protocol","DLNA"},{"uri",u}});
} else if (!strcmp(name,"GetTransportInfo")) gupnp_service_action_set(a,"CurrentTransportState",G_TYPE_STRING,state().c_str(),"CurrentTransportStatus",G_TYPE_STRING,"OK","CurrentSpeed",G_TYPE_STRING,"1",nullptr);
else if (!strcmp(name,"GetMediaInfo")) { auto* m=active(); auto duration=time_string(position(m,true)); gupnp_service_action_set(a,"NrTracks",G_TYPE_UINT,m ? 1u : 0u,"MediaDuration",G_TYPE_STRING,duration.c_str(),"CurrentURI",G_TYPE_STRING,m ? m->uri.c_str() : "","CurrentURIMetaData",G_TYPE_STRING,m ? m->metadata.c_str() : "","NextURI",G_TYPE_STRING,"","NextURIMetaData",G_TYPE_STRING,"","PlayMedium",G_TYPE_STRING,"NETWORK","RecordMedium",G_TYPE_STRING,"NOT_IMPLEMENTED","WriteStatus",G_TYPE_STRING,"NOT_IMPLEMENTED",nullptr); }
else if (!strcmp(name,"GetPositionInfo")) { auto* m=active(); auto duration=time_string(position(m,true)), pos=time_string(position(m,false)); gupnp_service_action_set(a,"Track",G_TYPE_UINT,m ? 1u : 0u,"TrackDuration",G_TYPE_STRING,duration.c_str(),"TrackMetaData",G_TYPE_STRING,m ? m->metadata.c_str() : "","TrackURI",G_TYPE_STRING,m ? m->uri.c_str() : "","RelTime",G_TYPE_STRING,pos.c_str(),"AbsTime",G_TYPE_STRING,pos.c_str(),"RelCount",G_TYPE_INT,0,"AbsCount",G_TYPE_INT,0,nullptr); }
else if (!strcmp(name,"GetDeviceCapabilities")) gupnp_service_action_set(a,"PlayMedia",G_TYPE_STRING,"NETWORK","RecMedia",G_TYPE_STRING,"NOT_IMPLEMENTED","RecQualityModes",G_TYPE_STRING,"NOT_IMPLEMENTED",nullptr);
else if (!strcmp(name,"GetTransportSettings")) gupnp_service_action_set(a,"PlayMode",G_TYPE_STRING,"NORMAL","RecQualityMode",G_TYPE_STRING,"NOT_IMPLEMENTED",nullptr);
else if (!strcmp(name,"GetCurrentTransportActions")) gupnp_service_action_set(a,"Actions",G_TYPE_STRING,active() && active()->accepted ? "Play,Pause,Stop,Seek" : "Play,Stop",nullptr);
else if (!strcmp(name,"GetProtocolInfo")) gupnp_service_action_set(a,"Source",G_TYPE_STRING,"","Sink",G_TYPE_STRING,protocols,nullptr);
else if (!strcmp(name,"GetCurrentConnectionIDs")) gupnp_service_action_set(a,"ConnectionIDs",G_TYPE_STRING,"0",nullptr);
else if (!strcmp(name,"GetCurrentConnectionInfo")) gupnp_service_action_set(a,"RcsID",G_TYPE_INT,0,"AVTransportID",G_TYPE_INT,0,"ProtocolInfo",G_TYPE_STRING,"http-get:*:*:*","PeerConnectionManager",G_TYPE_STRING,"","PeerConnectionID",G_TYPE_INT,-1,"Direction",G_TYPE_STRING,"Input","Status",G_TYPE_STRING,"OK",nullptr);
else if (!strcmp(name,"GetVolume")) gupnp_service_action_set(a,"CurrentVolume",G_TYPE_UINT,active() ? unsigned(active()->volume*100) : 100u,nullptr);
else if (!strcmp(name,"GetMute")) gupnp_service_action_set(a,"CurrentMute",G_TYPE_BOOLEAN,active() && active()->muted,nullptr);
else {
auto* m=active(); if (!m) { gupnp_service_action_return_error(a,701,"No media present"); return; }
if (!strcmp(name,"Stop")) { auto id=current; erase_media(id); output({{"event","ended"},{"id",id}}); }
else if (!strcmp(name,"Pause")) { m->paused=true; if (m->pipeline) gst_element_set_state(m->pipeline,GST_STATE_PAUSED); notify(); }
else if (!strcmp(name,"Play")) { gchar* speed=nullptr; gupnp_service_action_get(a,"Speed",G_TYPE_STRING,&speed,nullptr); bool supported=speed && !strcmp(speed,"1"); g_free(speed); if (!supported) { gupnp_service_action_return_error(a,717,"Unsupported play speed"); return; } m->paused=false; if (m->accepted) play(*m); notify(); }
else if (!strcmp(name,"Seek")) { gchar *unit=nullptr,*target=nullptr; gupnp_service_action_get(a,"Unit",G_TYPE_STRING,&unit,"Target",G_TYPE_STRING,&target,nullptr); std::string u=unit ? unit : "",t=target ? target : ""; g_free(unit); g_free(target); unsigned h=0,min=0; double sec=0; int count=0; if (u!="REL_TIME" || sscanf(t.c_str(),"%u:%u:%lf%n",&h,&min,&sec,&count)!=3 || count!=int(t.size()) || min>=60 || sec<0 || sec>=60) throw std::runtime_error("Unsupported seek target"); seek(*m,h*3600. + min*60. + sec); }
else if (!strcmp(name,"SetVolume")) { guint value=100; gupnp_service_action_get(a,"DesiredVolume",G_TYPE_UINT,&value,nullptr); set_volume(*m,std::min(value,100u)/100.); }
else if (!strcmp(name,"SetMute")) { gboolean value=false; gupnp_service_action_get(a,"DesiredMute",G_TYPE_BOOLEAN,&value,nullptr); m->muted=value; if (m->pipeline) g_object_set(m->pipeline,"mute",value,nullptr); notify(); }
}
gupnp_service_action_return_success(a);
} catch (const std::exception& e) { gupnp_service_action_return_error(a,714,e.what()); }
}
static void query_variable(GUPnPService*,const char* variable,GValue* value,gpointer rendering) {
g_value_init(value,G_TYPE_STRING);
if (!strcmp(variable,"LastChange")) { auto text=last_change(GPOINTER_TO_INT(rendering)); g_value_set_string(value,text.c_str()); }
else if (!strcmp(variable,"SinkProtocolInfo")) g_value_set_string(value,protocols);
else if (!strcmp(variable,"CurrentConnectionIDs")) g_value_set_string(value,"0");
else g_value_set_string(value,"");
}
static bool lan_context(GUPnPContext* context) {
ifaddrs* first=nullptr; if (getifaddrs(&first)) return false;
bool result=false; auto* name=gssdp_client_get_interface(GSSDP_CLIENT(context));
for (auto* i=first;i;i=i->ifa_next) if (i->ifa_name && name && !strcmp(i->ifa_name,name) && (i->ifa_flags&IFF_UP) && (i->ifa_flags&IFF_RUNNING) && (i->ifa_flags&IFF_BROADCAST) && !(i->ifa_flags&(IFF_LOOPBACK|IFF_POINTOPOINT))) result=true;
freeifaddrs(first); return result;
}
static void context_available(GUPnPContextManager* manager,GUPnPContext* context,gpointer) {
if (!lan_context(context)) return;
try {
g_object_set(gupnp_context_get_session(context),"timeout",5u,nullptr);
auto* cp=gupnp_control_point_new(context,"urn:schemas-upnp-org:device:MediaRenderer:1");
g_signal_connect(cp,"device-proxy-available",G_CALLBACK(available),nullptr); g_signal_connect(cp,"device-proxy-unavailable",G_CALLBACK(unavailable),nullptr);
gupnp_context_manager_manage_control_point(manager,cp); gssdp_resource_browser_set_active(GSSDP_RESOURCE_BROWSER(cp),true); g_object_unref(cp);
if (receive_enabled) {
GError* error=nullptr; auto* root=gupnp_root_device_new(context,"device.xml",xml_directory.c_str(),&error);
if (!root) { std::string reason=error ? error->message : "DLNA description error"; g_clear_error(&error); throw std::runtime_error(reason); }
own_udn=gupnp_device_info_get_udn(GUPNP_DEVICE_INFO(root));
for (auto type:{"AVTransport","RenderingControl","ConnectionManager"}) {
std::string urn="urn:schemas-upnp-org:service:"+std::string(type)+":1"; auto* service=gupnp_device_info_get_service(GUPNP_DEVICE_INFO(root),urn.c_str());
if (!service) throw std::runtime_error("Missing DLNA service");
for (auto name:{"SetAVTransportURI","Play","Pause","Stop","Seek","GetTransportInfo","GetMediaInfo","GetPositionInfo","GetDeviceCapabilities","GetTransportSettings","GetCurrentTransportActions","GetProtocolInfo","GetCurrentConnectionIDs","GetCurrentConnectionInfo","GetVolume","GetMute","SetVolume","SetMute"}) {
std::string signal="action-invoked::"+std::string(name); g_signal_connect(service,signal.c_str(),G_CALLBACK(action),nullptr);
}
g_signal_connect(service,"query-variable",G_CALLBACK(query_variable),GINT_TO_POINTER(!strcmp(type,"RenderingControl")));
if (!strcmp(type,"AVTransport")) transports.push_back(GUPNP_SERVICE(service));
else if (!strcmp(type,"RenderingControl")) renderers.push_back(GUPNP_SERVICE(service));
else g_object_unref(service);
}
gupnp_context_manager_manage_root_device(manager,root); gupnp_root_device_set_available(root,true); g_object_unref(root);
}
output({{"event","ready"},{"port",gupnp_context_get_port(context)},{"host",gssdp_client_get_host_ip(GSSDP_CLIENT(context))}});
} catch (const std::exception& e) { output({{"event","fatal"},{"error",e.what()}}); }
}
int main(int argc,char** argv) {
try {
if (argc<4) throw std::runtime_error("receiver DIRECTORY XMLDIR RECEIVE");
directory=argv[1]; xml_directory=argv[2]; receive_enabled=!strcmp(argv[3],"1"); gst_init(&argc,&argv);
// Frame's V4L2/Vulkan decoders can return corrupted frames and block
// playback controls. Prefer the bundled FFmpeg software decoders.
for (const char* name : {"avdec_h264", "avdec_h265"}) {
auto* decoder=gst_element_factory_find(name);
if (!decoder) throw std::runtime_error(std::string("Required decoder unavailable: ")+name);
gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(decoder),GST_RANK_PRIMARY+100);
gst_object_unref(decoder);
}
for (const char* name : {"v4l2h264dec", "v4l2h265dec", "vulkanh264dec", "vulkanh265dec"}) {
auto* decoder=gst_element_factory_find(name);
if (decoder) { gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(decoder),GST_RANK_NONE); gst_object_unref(decoder); }
}
auto* manager=gupnp_context_manager_create_full(GSSDP_UDA_VERSION_1_0,G_SOCKET_FAMILY_IPV4,0);
g_signal_connect(manager,"context-available",G_CALLBACK(context_available),nullptr);
loop=g_main_loop_new(nullptr,false); auto* channel=g_io_channel_unix_new(STDIN_FILENO); g_io_add_watch(channel,(GIOCondition)(G_IO_IN|G_IO_HUP),input,nullptr);
g_main_loop_run(loop); sessions.clear();
for (auto& [_,p]:devices) g_object_unref(p);
for (auto* p:transports) g_object_unref(p);
for (auto* p:renderers) g_object_unref(p);
g_io_channel_unref(channel); g_object_unref(manager); g_main_loop_unref(loop); return 0;
} catch (const std::exception& e) { output({{"event","fatal"},{"error",e.what()}}); return 1; }
}
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Add only lifecycle events to UxPlay; protocol/decoder code remains upstream."""
from pathlib import Path
import sys
p=Path(sys.argv[1])/'uxplay.cpp'
s=p.read_text()
if 'framely_client_ended' not in s:
at=s.index('extern "C" void conn_init')
s=s[:at]+'''#include "framely/frame_sink.h"
static void framely_client_ended() {
const char* directory=getenv("FRAMELY_CAST_DIR"),*id=getenv("FRAMELY_CAST_ID");
if(directory&&id)FrameSink(directory,id,"AirPlay").event("ended");
}
'''+s[at:]
s=s.replace('if (open_connections == 0) {','if (open_connections == 0) {\n framely_client_ended();',1)
s=s.replace('extern "C" void conn_reset (void *cls, int reason) {','extern "C" void conn_reset (void *cls, int reason) {\n framely_client_ended();',1)
p.write_text(s)
from shutil import copyfile
dst=p.parent/'framely';dst.mkdir(exist_ok=True)
copyfile(Path(__file__).parent/'native/frame_sink.h',dst/'frame_sink.h')
# Keep one client throughout a consented session; a second device must not
# inherit the first device's permission while UxPlay still has open sockets.
s=p.read_text()
if 'framely_client_device' not in s:
s=s.replace('#include "framely/frame_sink.h"','#include "framely/frame_sink.h"\n#include <mutex>\nstatic std::mutex framely_client_mutex;\nstatic std::string framely_client_device;')
s=s.replace('static void framely_client_ended() {','static void framely_client_ended() {\n {std::lock_guard<std::mutex> lock(framely_client_mutex);framely_client_device.clear();}')
marker=' // Pass device model to renderer for device frame display'
assert marker in s
s=s.replace(marker,''' if (*admit && deviceid) {
std::lock_guard<std::mutex> lock(framely_client_mutex);
if (framely_client_device.empty()) framely_client_device=deviceid;
else if (framely_client_device!=deviceid) *admit=false;
}
'''+marker)
p.write_text(s)
# A GStreamer sink can survive successive audio sessions. Announce each valid
# media SETUP independently of its first decoded buffer.
s=p.read_text()
if 'framely_media_request' not in s:
marker=' unsigned char type;\n LOGI("ct=%d'
assert marker in s
s=s.replace(marker,''' // framely_media_request: a new SETUP needs fresh consent after teardown.
const char* directory=getenv("FRAMELY_CAST_DIR"),*id=getenv("FRAMELY_CAST_ID");
if(directory&&id)FrameSink(directory,id,"AirPlay").event("request");
unsigned char type;
LOGI("ct=%d''',1)
p.write_text(s)
# Mirroring may have no audio stream. Its size report arrives before decoding,
# so it must also request consent independently of the decoder's first frame.
s=p.read_text()
if 'framely_mirror_request' not in s:
marker='extern "C" void video_report_size(void *cls, float *width_source, float *height_source, float *width, float *height) {'
assert marker in s
s=s.replace(marker,marker+'''
// framely_mirror_request: notify even while the video decoder is starting.
const char* directory=getenv("FRAMELY_CAST_DIR"),*id=getenv("FRAMELY_CAST_ID");
if(directory&&id&&*width_source>0&&*width_source<=4096&&*height_source>0&&*height_source<=4096)
FrameSink(directory,id,"AirPlay").event("request",(unsigned)*width_source,(unsigned)*height_source);
''',1)
p.write_text(s)
# Classify media before a decoded buffer arrives, including URL playback.
s=p.read_text()
if 'framely_media_kind' not in s:
marker='if(directory&&id)FrameSink(directory,id,"AirPlay").event("request");'
assert marker in s
s=s.replace(marker, 'if(directory&&id)FrameSink(directory,id,"AirPlay").event("request",0,0,*usingScreen?"video":"audio"); // framely_media_kind',1)
marker='extern "C" void on_video_play(void *cls, const char* location, const float start_position) {'
assert marker in s
s=s.replace(marker, marker+'\n const char* directory=getenv("FRAMELY_CAST_DIR"),*id=getenv("FRAMELY_CAST_ID");\n if(directory&&id)FrameSink(directory,id,"AirPlay").event("request",0,0,"video");',1)
# stdout is a file in production; retain useful errors before a restart.
s=s.replace(' va_end(vargs);', ' va_end(vargs);\n fflush(stdout);',1)
p.write_text(s)
from shutil import copyfile
copyfile(Path(__file__).parent/'native/frame_sink.h',p.parent/'framely/frame_sink.h')
# A mirror SETUP is already an explicit video request; do not wait for sound
# or the first codec packet before asking for consent.
header=p.parent/'lib/raop.h'
s=header.read_text()
if 'framely_video_setup' not in s:
marker=' void (*video_report_size)'
assert marker in s
s=s.replace(marker, ' void (*framely_video_setup)(void *cls);\n'+marker,1)
header.write_text(s)
handlers=p.parent/'lib/raop_handlers.h'
s=handlers.read_text()
if 'callbacks.framely_video_setup' not in s:
marker=' raop_rtp_mirror_start(conn->raop_rtp_mirror, &dport, raop->clientFPSdata);'
assert marker in s
s=s.replace(marker,marker+'\n if (raop->callbacks.framely_video_setup) raop->callbacks.framely_video_setup(raop->callbacks.cls);',1)
handlers.write_text(s)
s=p.read_text()
if 'extern "C" void framely_video_setup' not in s:
marker='extern "C" void video_report_size('
at=s.index(marker)
s=s[:at]+'''extern "C" void framely_video_setup(void *cls) {
const char* directory=getenv("FRAMELY_CAST_DIR"),*id=getenv("FRAMELY_CAST_ID");
if(directory&&id)FrameSink(directory,id,"AirPlay").event("request",0,0,"video");
}
'''+s[at:]
marker=' raop_cbs.video_report_size = video_report_size;'
assert marker in s
s=s.replace(marker,marker+'\n raop_cbs.framely_video_setup = framely_video_setup;',1)
p.write_text(s)
# HLS uses playbin's separate audio sink; apply the same consent gate there.
renderer=p.parent/'renderers/video_renderer.c'
s=renderer.read_text()
if 'Framely HLS consent' not in s:
marker=' g_object_set(G_OBJECT (renderer_type[i]->pipeline), "video-sink", playbin_videosink, NULL);'
assert marker in s
s=s.replace(marker,marker+'''
/* Framely HLS consent: audio must use the accepted session too. */
if (!strcmp(videosink, "framelyvideosink")) {
GstElement *audio = gst_element_factory_make("framelyaudiosink", NULL);
g_assert(audio);
g_object_set(renderer_type[i]->pipeline, "audio-sink", audio, NULL);
}
''',1)
renderer.write_text(s)
+2
View File
@@ -0,0 +1,2 @@
<?xml version='1.0' encoding='utf-8'?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0"><specVersion><major>1</major><minor>0</minor></specVersion><actionList><action><name>SetAVTransportURI</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>CurrentURI</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_CurrentURI</relatedStateVariable></argument><argument><name>CurrentURIMetaData</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_CurrentURIMetaData</relatedStateVariable></argument></argumentList></action><action><name>Play</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>Speed</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Speed</relatedStateVariable></argument></argumentList></action><action><name>Pause</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument></argumentList></action><action><name>Stop</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument></argumentList></action><action><name>GetTransportInfo</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>CurrentTransportState</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentTransportState</relatedStateVariable></argument><argument><name>CurrentTransportStatus</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentTransportStatus</relatedStateVariable></argument><argument><name>CurrentSpeed</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentSpeed</relatedStateVariable></argument></argumentList></action><action><name>GetMediaInfo</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>NrTracks</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_NrTracks</relatedStateVariable></argument><argument><name>MediaDuration</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_MediaDuration</relatedStateVariable></argument><argument><name>CurrentURI</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentURI</relatedStateVariable></argument><argument><name>CurrentURIMetaData</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentURIMetaData</relatedStateVariable></argument><argument><name>NextURI</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_NextURI</relatedStateVariable></argument><argument><name>NextURIMetaData</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_NextURIMetaData</relatedStateVariable></argument><argument><name>PlayMedium</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_PlayMedium</relatedStateVariable></argument><argument><name>RecordMedium</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_RecordMedium</relatedStateVariable></argument><argument><name>WriteStatus</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_WriteStatus</relatedStateVariable></argument></argumentList></action><action><name>Seek</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>Unit</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Unit</relatedStateVariable></argument><argument><name>Target</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Target</relatedStateVariable></argument></argumentList></action><action><name>GetDeviceCapabilities</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>PlayMedia</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_PlayMedia</relatedStateVariable></argument><argument><name>RecMedia</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_RecMedia</relatedStateVariable></argument><argument><name>RecQualityModes</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_RecQualityModes</relatedStateVariable></argument></argumentList></action><action><name>GetTransportSettings</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>PlayMode</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_PlayMode</relatedStateVariable></argument><argument><name>RecQualityMode</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_RecQualityMode</relatedStateVariable></argumeLine truncated
+2
View File
@@ -0,0 +1,2 @@
<?xml version='1.0' encoding='utf-8'?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0"><specVersion><major>1</major><minor>0</minor></specVersion><actionList><action><name>GetProtocolInfo</name><argumentList><argument><name>Source</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Source</relatedStateVariable></argument><argument><name>Sink</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Sink</relatedStateVariable></argument></argumentList></action><action><name>GetCurrentConnectionIDs</name><argumentList><argument><name>ConnectionIDs</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_ConnectionIDs</relatedStateVariable></argument></argumentList></action><action><name>GetCurrentConnectionInfo</name><argumentList><argument><name>ConnectionID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable></argument><argument><name>RcsID</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable></argument><argument><name>AVTransportID</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable></argument><argument><name>ProtocolInfo</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable></argument><argument><name>PeerConnectionManager</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_PeerConnectionManager</relatedStateVariable></argument><argument><name>PeerConnectionID</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_PeerConnectionID</relatedStateVariable></argument><argument><name>Direction</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable></argument><argument><name>Status</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Status</relatedStateVariable></argument></argumentList></action></actionList><serviceStateTable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Source</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Sink</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_ConnectionIDs</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_ConnectionID</name><dataType>i4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_RcsID</name><dataType>i4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_AVTransportID</name><dataType>i4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_ProtocolInfo</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_PeerConnectionManager</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_PeerConnectionID</name><dataType>i4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Direction</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Status</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="yes"><name>SourceProtocolInfo</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="yes"><name>SinkProtocolInfo</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="yes"><name>CurrentConnectionIDs</name><dataType>string</dataType></stateVariable></serviceStateTable></scpd>
+2
View File
@@ -0,0 +1,2 @@
<?xml version='1.0' encoding='utf-8'?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0"><specVersion><major>1</major><minor>0</minor></specVersion><actionList><action><name>GetVolume</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>Channel</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable></argument><argument><name>CurrentVolume</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentVolume</relatedStateVariable></argument></argumentList></action><action><name>SetVolume</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>Channel</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable></argument><argument><name>DesiredVolume</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_DesiredVolume</relatedStateVariable></argument></argumentList></action><action><name>GetMute</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>Channel</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable></argument><argument><name>CurrentMute</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentMute</relatedStateVariable></argument></argumentList></action><action><name>SetMute</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>Channel</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable></argument><argument><name>DesiredMute</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_DesiredMute</relatedStateVariable></argument></argumentList></action></actionList><serviceStateTable><stateVariable sendEvents="no"><name>A_ARG_TYPE_InstanceID</name><dataType>ui4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Channel</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_CurrentVolume</name><dataType>ui2</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_DesiredVolume</name><dataType>ui2</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_CurrentMute</name><dataType>boolean</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_DesiredMute</name><dataType>boolean</dataType></stateVariable><stateVariable sendEvents="yes"><name>LastChange</name><dataType>string</dataType></stateVariable></serviceStateTable></scpd>
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Generate the standard UPnP AV service descriptions shipped with Framely."""
from pathlib import Path
from xml.etree.ElementTree import Element,SubElement,tostring
root=Path(__file__).parent
services={
'AVTransport':[
('SetAVTransportURI',[('InstanceID','in','ui4'),('CurrentURI','in','string'),('CurrentURIMetaData','in','string')]),
('Play',[('InstanceID','in','ui4'),('Speed','in','string')]),('Pause',[('InstanceID','in','ui4')]),('Stop',[('InstanceID','in','ui4')]),
('GetTransportInfo',[('InstanceID','in','ui4'),('CurrentTransportState','out','string'),('CurrentTransportStatus','out','string'),('CurrentSpeed','out','string')]),
('GetMediaInfo',[('InstanceID','in','ui4')]+[(v,'out',t) for v,t in [('NrTracks','ui4'),('MediaDuration','string'),('CurrentURI','string'),('CurrentURIMetaData','string'),('NextURI','string'),('NextURIMetaData','string'),('PlayMedium','string'),('RecordMedium','string'),('WriteStatus','string')]]),
('Seek',[('InstanceID','in','ui4'),('Unit','in','string'),('Target','in','string')]),
('GetDeviceCapabilities',[('InstanceID','in','ui4'),('PlayMedia','out','string'),('RecMedia','out','string'),('RecQualityModes','out','string')]),
('GetTransportSettings',[('InstanceID','in','ui4'),('PlayMode','out','string'),('RecQualityMode','out','string')]),
('GetCurrentTransportActions',[('InstanceID','in','ui4'),('Actions','out','string')]),
('GetPositionInfo',[('InstanceID','in','ui4')]+[(v,'out',t) for v,t in [('Track','ui4'),('TrackDuration','string'),('TrackMetaData','string'),('TrackURI','string'),('RelTime','string'),('AbsTime','string'),('RelCount','i4'),('AbsCount','i4')]])],
'RenderingControl':[(k,[('InstanceID','in','ui4'),('Channel','in','string'),(v,d,t)]) for k,v,d,t in [('GetVolume','CurrentVolume','out','ui2'),('SetVolume','DesiredVolume','in','ui2'),('GetMute','CurrentMute','out','boolean'),('SetMute','DesiredMute','in','boolean')]],
'ConnectionManager':[('GetProtocolInfo',[('Source','out','string'),('Sink','out','string')]),('GetCurrentConnectionIDs',[('ConnectionIDs','out','string')]),('GetCurrentConnectionInfo',[('ConnectionID','in','i4')]+[(v,'out',t) for v,t in [('RcsID','i4'),('AVTransportID','i4'),('ProtocolInfo','string'),('PeerConnectionManager','string'),('PeerConnectionID','i4'),('Direction','string'),('Status','string')]])],
}
for service,actions in services.items():
doc=Element('scpd',xmlns='urn:schemas-upnp-org:service-1-0');version=SubElement(doc,'specVersion');SubElement(version,'major').text='1';SubElement(version,'minor').text='0';actionlist=SubElement(doc,'actionList');variables={}
for name,args in actions:
action=SubElement(actionlist,'action');SubElement(action,'name').text=name;arglist=SubElement(action,'argumentList')
for v,d,t in args:
arg=SubElement(arglist,'argument');SubElement(arg,'name').text=v;SubElement(arg,'direction').text=d;SubElement(arg,'relatedStateVariable').text='A_ARG_TYPE_'+v;variables[v]=t
table=SubElement(doc,'serviceStateTable')
for v,t in variables.items():
var=SubElement(table,'stateVariable',sendEvents='no');SubElement(var,'name').text='A_ARG_TYPE_'+v;SubElement(var,'dataType').text=t
for name in (['LastChange'] if service!='ConnectionManager' else ['SourceProtocolInfo','SinkProtocolInfo','CurrentConnectionIDs']):
var=SubElement(table,'stateVariable',sendEvents='yes');SubElement(var,'name').text=name;SubElement(var,'dataType').text='string'
(root/(service+'.xml')).write_bytes(tostring(doc,encoding='utf-8',xml_declaration=True))
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <sys/file.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
// Raw decoded video is uploaded once into a GL texture, then scaled on the GPU.
struct CastFrame {
std::string path;GLuint texture=0,composite=0;int composite_width=0,composite_height=0;uint32_t width=0,height=0;timespec stamp{};std::vector<uint8_t> data;
bool update(){if(path.empty())return false;int fd=open(path.c_str(),O_RDONLY|O_CLOEXEC|O_NOFOLLOW);if(fd<0)return false;
struct stat st{};bool changed=fstat(fd,&st)==0&&(st.st_mtim.tv_sec!=stamp.tv_sec||st.st_mtim.tv_nsec!=stamp.tv_nsec);
if(!changed||flock(fd,LOCK_SH|LOCK_NB)){close(fd);return false;}
uint32_t header[4]{};bool valid=pread(fd,header,16,0)==16&&header[0]==0x46434153&&header[1]>0&&header[2]>0&&header[1]<=4096&&header[2]<=4096&&header[3]==header[1]*4&&st.st_size==16+int64_t(header[1])*header[2]*4;
if(valid){data.resize(size_t(header[1])*header[2]*4);valid=pread(fd,data.data(),data.size(),16)==ssize_t(data.size());}
flock(fd,LOCK_UN);close(fd);if(!valid)return false;
stamp=st.st_mtim;width=header[1];height=header[2];if(!texture)glGenTextures(1,&texture);glBindTexture(GL_TEXTURE_2D,texture);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);glPixelStorei(GL_UNPACK_ROW_LENGTH,0);glPixelStorei(GL_UNPACK_ALIGNMENT,4);glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,width,height,0,GL_BGRA,GL_UNSIGNED_BYTE,data.data());return true;
}
GLuint draw(GLuint target,int target_width,int target_height){if(!texture||target_height<=0)return target;
static auto gen=(PFNGLGENFRAMEBUFFERSPROC)glXGetProcAddressARB((const GLubyte*)"glGenFramebuffers");
static auto bind=(PFNGLBINDFRAMEBUFFERPROC)glXGetProcAddressARB((const GLubyte*)"glBindFramebuffer");
static auto attach=(PFNGLFRAMEBUFFERTEXTURE2DPROC)glXGetProcAddressARB((const GLubyte*)"glFramebufferTexture2D");
static auto blit=(PFNGLBLITFRAMEBUFFERPROC)glXGetProcAddressARB((const GLubyte*)"glBlitFramebuffer");
static auto del=(PFNGLDELETEFRAMEBUFFERSPROC)glXGetProcAddressARB((const GLubyte*)"glDeleteFramebuffers");
if(!gen||!bind||!attach||!blit||!del)return target;
if(!composite)glGenTextures(1,&composite);glBindTexture(GL_TEXTURE_2D,composite);if(composite_width!=target_width||composite_height!=target_height){glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,target_width,target_height,0,GL_BGRA,GL_UNSIGNED_BYTE,nullptr);composite_width=target_width;composite_height=target_height;}GLuint fbo[2];gen(2,fbo);bind(GL_READ_FRAMEBUFFER,fbo[0]);attach(GL_READ_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,texture,0);bind(GL_DRAW_FRAMEBUFFER,fbo[1]);attach(GL_DRAW_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,composite,0);// Decoder frames are top-down; CEF controls are already flipped for GL.
blit(0,height,width,0,0,0,target_width,target_height,GL_COLOR_BUFFER_BIT,GL_LINEAR);
// CEF paints transparent, premultiplied controls over the full video plane.
// Keep its texture intact so partial CEF damage never contains stale video.
bind(GL_FRAMEBUFFER,fbo[1]);glPushAttrib(GL_ALL_ATTRIB_BITS);glViewport(0,0,target_width,target_height);glDisable(GL_DEPTH_TEST);glDisable(GL_SCISSOR_TEST);glEnable(GL_TEXTURE_2D);glEnable(GL_BLEND);glBlendFunc(GL_ONE,GL_ONE_MINUS_SRC_ALPHA);glColor4f(1,1,1,1);glBindTexture(GL_TEXTURE_2D,target);
glMatrixMode(GL_PROJECTION);glPushMatrix();glLoadIdentity();glOrtho(0,1,0,1,-1,1);glMatrixMode(GL_MODELVIEW);glPushMatrix();glLoadIdentity();glBegin(GL_QUADS);glTexCoord2f(0,0);glVertex2f(0,0);glTexCoord2f(1,0);glVertex2f(1,0);glTexCoord2f(1,1);glVertex2f(1,1);glTexCoord2f(0,1);glVertex2f(0,1);glEnd();glPopMatrix();glMatrixMode(GL_PROJECTION);glPopMatrix();glPopAttrib();bind(GL_FRAMEBUFFER,0);del(2,fbo);return composite;
}
~CastFrame(){if(texture)glDeleteTextures(1,&texture);if(composite)glDeleteTextures(1,&composite);}
};
+3
View File
@@ -19,6 +19,7 @@ static void stop(int){stopped=1;}
#include "gamepad_mapping.h"
#include "gamepad_steam.h"
#include "gamepad_feedback.h"
#include "gamepad_direct.h"
static const int axes[]={ABS_X,ABS_Y,ABS_RX,ABS_RY,ABS_Z,ABS_RZ,ABS_HAT0X,ABS_HAT0Y};
static bool event(int fd,int type,int code,int value){input_event e{};e.type=type;e.code=code;e.value=value;return write(fd,&e,sizeof e)==sizeof e;}
static GamepadTriggers triggers;
@@ -29,6 +30,8 @@ int main(int argc,char**argv){
// follows that thread's lifetime, so it destroys the pad as soon as prepare
// returns. The session-owned stdin pipe and parent process own this helper.
const auto parent=getppid();
signal(SIGTERM,stop);signal(SIGINT,stop);signal(SIGPIPE,SIG_IGN);
if(const char* mode=getenv("FRAMELY_GAMEPAD_SOURCE");mode&&std::string(mode)=="steam-direct")return steam_direct(getenv("FRAMELY_STEAM_GAMEPAD_INFO"),getenv("FRAMELY_GAMEPAD_READY"));
const bool steamMode=std::getenv("FRAMELY_GAMEPAD_SOURCE")&&std::string(std::getenv("FRAMELY_GAMEPAD_SOURCE"))=="steam";
SteamGamepad steam;
if(steamMode&&!steam.start(std::getenv("FRAMELY_STEAM_SDL_LIBRARY"),std::getenv("FRAMELY_STEAM_GAMEPAD_INFO"))){fprintf(stderr,"Steam Input SDL3 runtime unavailable; select Frame direct input in APK settings.\n");return 1;}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
// Steam's already-configured evdev output, without SDL or a second uinput pad.
#include "gamepad_steam.h"
#include <filesystem>
#include <sys/stat.h>
#include <chrono>
#include <poll.h>
#include "gamepad_direct_policy.h"
inline int direct_slot(const input_id& id,const char* name,const std::vector<SteamPadInfo>& rows){
if(id.vendor!=0x28de||id.product!=0x11ff)return -1;
const char* at=std::strstr(name,"pad ");int slot=-1;char extra;
if(!at||std::sscanf(at+4,"%d%c",&slot,&extra)!=1||!allowed_steam_slot(rows,slot))return -1;
return slot;
}
inline int steam_direct(const char* infoPath,const char* directory){
if(!infoPath||!directory)return 1;
std::ifstream info(infoPath);auto rows=read_steam_pad_info(info);
std::string node;int slot=64;struct stat identity{};
std::error_code ec;
for(auto& entry:std::filesystem::directory_iterator("/dev/input",ec)){
const auto name=entry.path().filename().string();if(name.rfind("event",0)!=0)continue;
int fd=open(entry.path().c_str(),O_RDWR|O_NONBLOCK|O_CLOEXEC);if(fd<0)continue;
input_id id{};char label[128]{};int next=-1;struct stat st{};
if(ioctl(fd,EVIOCGID,&id)==0&&ioctl(fd,EVIOCGNAME(sizeof label),label)>0&&fstat(fd,&st)==0&&S_ISCHR(st.st_mode))next=direct_slot(id,label,rows);
close(fd);if(next>=0&&next<slot){node=entry.path();slot=next;identity=st;}
}
if(node.empty()){fprintf(stderr,"No writable Steam Input output is available; wake the controller and retry, or select the bridge.\n");return 1;}
const auto source=*std::find_if(rows.begin(),rows.end(),[&](auto p){return p.slot==slot;});
const auto control=std::string(directory)+"/control";
int ctl=open(control.c_str(),O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC,0644);if(ctl<0)return 1;
if(fchmod(ctl,0644)!=0){close(ctl);return 1;}
auto now=[](){return uint64_t(std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());};
bool sourceValid=true;unsigned missing=0;bool enabled=false,rumble=!getenv("FRAMELY_GAMEPAD_RUMBLE")||std::string(getenv("FRAMELY_GAMEPAD_RUMBLE"))!="0";
auto publish=[&](){FramelyDirectControl c{now()+2000000000ULL,uint32_t(enabled&&sourceValid),uint32_t(rumble)};return pwrite(ctl,&c,sizeof c,0)==sizeof c;};
if(!publish()){close(ctl);return 1;}
printf("%s\n",node.c_str());fflush(stdout);
std::string commands;const auto parent=getppid();uint64_t scanned=0;
while(!stopped&&getppid()==parent){
pollfd p{STDIN_FILENO,POLLIN,0};int result=poll(&p,1,50);if(result<0&&errno!=EINTR)break;
if(result>0&&(p.revents&(POLLIN|POLLHUP|POLLERR))){char buffer[128];auto n=read(0,buffer,sizeof buffer);if(n<=0)break;commands.append(buffer,n);size_t at;
while((at=commands.find('\n'))!=std::string::npos){auto cmd=commands.substr(0,at);commands.erase(0,at+1);if(cmd=="enable")enabled=true;else if(cmd=="disable")enabled=false;else if(cmd=="rumble-on")rumble=true;else if(cmd=="rumble-off")rumble=false;}
if(commands.size()>128)break;
}
if(now()-scanned>=500000000ULL){scanned=now();struct stat current{};std::ifstream file(infoPath);auto latest=read_steam_pad_info(file);
if(stat(node.c_str(),&current)!=0||current.st_ino!=identity.st_ino||current.st_rdev!=identity.st_rdev){
fprintf(stderr,"Steam Input output changed/disconnected; restart this container to reconnect.\n");break;
}
sourceValid=std::any_of(latest.begin(),latest.end(),[&](auto p){return p.slot==source.slot&&p.identified&&p.vendor==source.vendor&&p.product==source.product;});
if(sourceValid)missing=0;else if(++missing>=6){fprintf(stderr,"Steam Input controller ownership changed; restart this container to reconnect.\n");break;}
}
if(!publish())break;
}
enabled=false;rumble=false;publish();close(ctl);return 0;
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <stdint.h>
struct FramelyDirectControl { uint64_t expires_ns; uint32_t enabled; uint32_t rumble; };
static inline int framely_direct_enabled(struct FramelyDirectControl c,uint64_t now){return c.enabled==1&&now<c.expires_ns;}
static inline int framely_direct_rumble(struct FramelyDirectControl c,uint64_t now){return framely_direct_enabled(c,now)&&c.rumble==1;}
+86 -2
View File
@@ -1,15 +1,99 @@
// Bionic preload: exclusively claim only Framely's mounted virtual event node.
// Bionic preload: isolated virtual pad, or guarded Steam Input evdev passthrough.
// Raw AArch64 syscalls avoid a glibc dependency inside Android.
#define _GNU_SOURCE
#include <stdarg.h>
#include <fcntl.h>
#include <linux/input.h>
#include <time.h>
#include <stddef.h>
#include "gamepad_direct_policy.h"
extern int *__errno(void);
extern int pthread_create(unsigned long*,const void*,void*(*)(void*),void*);
extern int pthread_detach(unsigned long);
static long call(long nr,long a,long b,long c,long d){register long x0 __asm__("x0")=a;register long x1 __asm__("x1")=b;register long x2 __asm__("x2")=c;register long x3 __asm__("x3")=d;register long x8 __asm__("x8")=nr;__asm__ volatile("svc 0":"+r"(x0):"r"(x1),"r"(x2),"r"(x3),"r"(x8):"memory");return x0;}
static long checked(long r){if(r<0){*__errno()=-r;return -1;}return r;}
static int same(const char*a,const char*b){while(*a&&*a==*b){a++;b++;}return *a==*b;}
static int opened(int dir,const char*p,int flags,int mode){long fd=call(56,dir,(long)p,flags,mode);if(fd<0){*__errno()=-fd;return -1;}if(same(p,"/dev/input/event250")){long r=call(29,fd,EVIOCGRAB,1,0);if(r==0){long ready=call(56,-100,(long)"/framely-gamepad-ready/ready",O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC,0644);if(ready>=0){call(64,ready,(long)"ready\n",6,0);call(57,ready,0,0,0);}}}return fd;}
#ifndef FRAMELY_EVENT_PATH
#define FRAMELY_EVENT_PATH "/dev/input/event250"
#define FRAMELY_CONTROL_PATH "/framely-gamepad-control"
#define FRAMELY_READY_PATH "/framely-gamepad-ready/ready"
#endif
#define MAX_FDS 4096
static unsigned char direct_fds[MAX_FDS],was_enabled[MAX_FDS];
static uint64_t playing[MAX_FDS];
static int control=-1,lock=0,started=0;
static void enter(void){while(__atomic_exchange_n(&lock,1,__ATOMIC_ACQUIRE))call(124,0,0,0,0);}
static void leave(void){__atomic_store_n(&lock,0,__ATOMIC_RELEASE);}
static struct FramelyDirectControl policy(uint64_t* now){
struct timespec t;struct FramelyDirectControl c={0};*now=0;
if(call(113,CLOCK_MONOTONIC,(long)&t,0,0)<0)return c;
*now=(uint64_t)t.tv_sec*1000000000ULL+t.tv_nsec;
if(control<0||call(67,control,(long)&c,sizeof c,0)!=sizeof c){c.expires_ns=0;c.enabled=0;c.rumble=0;}return c;
}
static void stop_effects(int fd){uint64_t mask=playing[fd];playing[fd]=0;for(int i=0;i<64;i++)if(mask&(1ULL<<i)){struct input_event e={0};e.type=EV_FF;e.code=i;call(64,fd,(long)&e,sizeof e,0);}}
static void* watch(void* unused){(void)unused;for(;;){enter();uint64_t now;struct FramelyDirectControl c=policy(&now);if(!framely_direct_rumble(c,now))for(int fd=0;fd<MAX_FDS;fd++)if(direct_fds[fd]&&playing[fd])stop_effects(fd);leave();struct timespec delay={0,20000000};call(101,(long)&delay,0,0,0);}return 0;}
static int opened(int dir,const char*p,int flags,int mode){
long fd=call(56,dir,(long)p,flags,mode);if(fd<0)return checked(fd);
if(same(p,FRAMELY_EVENT_PATH)){
enter();if(control<0)control=call(56,-100,(long)FRAMELY_CONTROL_PATH,O_RDONLY|O_CLOEXEC,0);
if(control<0&&control!=-2){int error=-control;leave();call(57,fd,0,0,0);*__errno()=error;return -1;}
int direct=control>=0;
if(direct){if(fd>=MAX_FDS){leave();call(57,fd,0,0,0);*__errno()=24;return -1;}__atomic_store_n(&direct_fds[fd],1,__ATOMIC_RELEASE);was_enabled[fd]=1;playing[fd]=0;
if(!started){unsigned long thread;started=pthread_create(&thread,0,watch,0)==0;if(started)pthread_detach(thread);}
// Without the feedback watchdog, fail closed rather than leave rumble active.
if(!started){__atomic_store_n(&direct_fds[fd],0,__ATOMIC_RELEASE);leave();call(57,fd,0,0,0);*__errno()=5;return -1;}
}
leave();long r=direct?0:call(29,fd,EVIOCGRAB,1,0);
if(r==0){long ready=call(56,-100,(long)FRAMELY_READY_PATH,O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC,0644);if(ready>=0){call(64,ready,(long)"ready\n",6,0);call(57,ready,0,0,0);}}
}
return fd;
}
int open(const char*p,int flags,...){int mode=0;if((flags&O_CREAT)||(flags&O_TMPFILE)==O_TMPFILE){va_list ap;va_start(ap,flags);mode=va_arg(ap,int);va_end(ap);}return opened(-100,p,flags,mode);}
int open64(const char*p,int flags,...){int mode=0;if((flags&O_CREAT)||(flags&O_TMPFILE)==O_TMPFILE){va_list ap;va_start(ap,flags);mode=va_arg(ap,int);va_end(ap);}return opened(-100,p,flags,mode);}
int openat(int dir,const char*p,int flags,...){int mode=0;if((flags&O_CREAT)||(flags&O_TMPFILE)==O_TMPFILE){va_list ap;va_start(ap,flags);mode=va_arg(ap,int);va_end(ap);}return opened(dir,p,flags,mode);}
int __open_2(const char*p,int flags){return opened(-100,p,flags,0);}
int __openat_2(int dir,const char*p,int flags){return opened(dir,p,flags,0);}
long read(int fd,void* buffer,size_t bytes){
long n=call(63,fd,(long)buffer,bytes,0);if(n<=0)return checked(n);
if(fd<0||fd>=MAX_FDS||!__atomic_load_n(&direct_fds[fd],__ATOMIC_ACQUIRE))return n;
enter();if(direct_fds[fd]){uint64_t now;struct FramelyDirectControl c=policy(&now);int enabled=framely_direct_enabled(c,now);
if(!enabled){
// Drain the original events. Release all Android button/axis state once,
// then report EAGAIN, so background input is never replayed on resumption.
const int keys[]={304,305,307,308,310,311,312,313,314,315,317,318};const int axes[]={0,1,3,4,2,5,16,17};
if(was_enabled[fd]&&bytes>=21*sizeof(struct input_event)){struct input_event* e=buffer;int count=0;for(int i=0;i<12;i++){e[count]=(struct input_event){0};e[count].type=EV_KEY;e[count++].code=keys[i];}for(int i=0;i<8;i++){e[count]=(struct input_event){0};e[count].type=EV_ABS;e[count++].code=axes[i];}e[count]=(struct input_event){0};e[count].type=EV_SYN;e[count++].code=SYN_REPORT;n=count*sizeof *e;was_enabled[fd]=0;}else n=-11;
}else was_enabled[fd]=1;
}leave();return checked(n);
}
long write(int fd,const void* buffer,size_t bytes){
if(fd<0||fd>=MAX_FDS||!__atomic_load_n(&direct_fds[fd],__ATOMIC_ACQUIRE))return checked(call(64,fd,(long)buffer,bytes,0));
enter();long n;
if(direct_fds[fd]){
uint64_t now;struct FramelyDirectControl c=policy(&now);int allowed=framely_direct_rumble(c,now);const struct input_event* events=buffer;
if(bytes%sizeof *events){leave();*__errno()=22;return -1;}
n=0;for(size_t i=0;i<bytes/sizeof *events;i++){
struct input_event e=events[i];if(e.type==EV_FF&&e.value>0&&!allowed)e.value=0;
long r=call(64,fd,(long)&e,sizeof e,0);if(r<0){if(n==0)n=r;break;}n+=r;
if(e.type==EV_FF&&e.code<64){if(e.value>0)playing[fd]|=1ULL<<e.code;else playing[fd]&=~(1ULL<<e.code);}
}
}else n=call(64,fd,(long)buffer,bytes,0);
leave();return checked(n);
}
int close(int fd){if(fd<0||fd>=MAX_FDS||!__atomic_load_n(&direct_fds[fd],__ATOMIC_ACQUIRE))return checked(call(57,fd,0,0,0));enter();if(fd>=0&&fd<MAX_FDS&&direct_fds[fd]){stop_effects(fd);__atomic_store_n(&direct_fds[fd],0,__ATOMIC_RELEASE);}long result=call(57,fd,0,0,0);leave();return checked(result);}
int ioctl(int fd,unsigned long request,...){
va_list ap;va_start(ap,request);void* argument=va_arg(ap,void*);va_end(ap);
if(fd<0||fd>=MAX_FDS||!__atomic_load_n(&direct_fds[fd],__ATOMIC_ACQUIRE))return checked(call(29,fd,request,(long)argument,0));
enter();long result=call(29,fd,request,(long)argument,0);
if(result>=0&&direct_fds[fd]){
uint64_t now;struct FramelyDirectControl c=policy(&now);
if(!framely_direct_enabled(c,now)&&_IOC_TYPE(request)=='E'&&(_IOC_DIR(request)&_IOC_READ)){
if(_IOC_NR(request)==0x18){unsigned char* bytes=argument;for(unsigned i=0;i<_IOC_SIZE(request);i++)bytes[i]=0;}
else if(_IOC_NR(request)>=0x40&&_IOC_NR(request)<=0x7f&&_IOC_SIZE(request)==sizeof(struct input_absinfo))((struct input_absinfo*)argument)->value=0;
}
if(request==EVIOCRMFF&&(unsigned long)argument<64)playing[fd]&=~(1ULL<<(unsigned long)argument);
}
leave();return checked(result);
}
// Bionic's fortified entry points must use the same foreground/feedback gate.
long __read_chk(int fd,void* buffer,size_t bytes,size_t capacity){if(bytes>capacity){*__errno()=14;return -1;}return read(fd,buffer,bytes);}
long __write_chk(int fd,const void* buffer,size_t bytes,size_t capacity){if(bytes>capacity){*__errno()=14;return -1;}return write(fd,buffer,bytes);}
+91 -19
View File
@@ -108,6 +108,7 @@ 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;
@@ -122,15 +123,18 @@ template<class T> struct Handler {
Handler(){api.base.size=sizeof(T);api.base.add_ref=[](cef_base_ref_counted_t* p){++reinterpret_cast<Handler*>(p)->refs;};api.base.release=[](cef_base_ref_counted_t* p){--reinterpret_cast<Handler*>(p)->refs;return 0;};api.base.has_one_ref=[](cef_base_ref_counted_t* p){return int(reinterpret_cast<Handler*>(p)->refs==1);};api.base.has_at_least_one_ref=[](cef_base_ref_counted_t* p){return int(reinterpret_cast<Handler*>(p)->refs>0);};}
T* acquire(){api.base.add_ref(&api.base);return &api;}
};
#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;
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_at{},activation_at{};
cef_mouse_event_t pointer{};bool pointer_valid=false,stick_scrolling=false;vr::TrackedDeviceIndex_t pointer_device=vr::k_unTrackedDeviceIndexInvalid;std::chrono::steady_clock::time_point pointer_at{};
cef_file_dialog_callback_t* file_dialog=nullptr;std::string file_dialog_id;bool file_multiple=false,file_directory=false;
bool active_seen=false,notification_content=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
bool active_seen=false,notification_content=false,notification_requested=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
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;
@@ -162,7 +166,8 @@ static std::vector<uint8_t> icon(bool hover,bool active){
return p;
}
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;{std::lock_guard<std::mutex> lock(visibility_mutex);payload=visible_views.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");return json::parse(response.substr(body+4));}
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;}
// 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
@@ -265,8 +270,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;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));}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.
@@ -280,16 +302,24 @@ static void anchor_notification(View& v){
v.placement.curve(overlays,v.overlay,toast.width,toast.radius);
}
static View& create_view(const std::string& key,const std::string& title,const std::string& url,bool dock,int width,int height,const std::string& plugin="",float physical_width=0){
if(views.count(key)){auto& v=*views.at(key);if(dock&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="notifications"&&key!="launcher")show(v,true);return v;}
// Plugin windows always use Steam window controls; dock only controls the tab.
const bool dashboard=dock||!plugin.empty()||key.rfind("framely.cast.",0)==0;
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=dock;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications")?2:1;
auto error=dock?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(v->overlay,0);
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;
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,key=="framely.apk.permission");overlays->SetOverlaySortOrder(v->overlay,key=="framely.apk.permission"?100:0);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(dock){
if(key=="launcher")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_NoDashboardTab,true);
if(dashboard){
overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_NoDashboardTab,!dock||key=="launcher");
auto result=overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MinimalControlBar,key=="launcher");
if(result)std::cerr<<"Dashboard controls unavailable "<<key<<" "<<result<<"\n";
for(auto flag:{vr::VROverlayFlags_EnableControlBarKeyboard,vr::VROverlayFlags_EnableControlBarClose}){
@@ -297,9 +327,9 @@ static View& create_view(const std::string& key,const std::string& title,const s
if(result)std::cerr<<"Dashboard control unavailable "<<key<<" "<<result<<"\n";
}
}
if(dock){auto pixels=icon(false,true);overlays->SetOverlayRaw(v->thumbnail,pixels.data(),128,128,4);overlays->SetOverlayWidthInMeters(v->overlay,physical_width>0?physical_width:(key=="framely.manager"?3.2f:key=="launcher"?3.6f:1.2f));}else if(key!="menu"&&key!="notifications"&&key!="launcher"){anchor_to_head(*v,physical_width>0?physical_width:1.0f);}
cef_window_info_t info{};info.size=sizeof(info);info.windowless_rendering_enabled=1;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=browser_frame_rate();bs.background_color=0;std::cout<<"Browser frame rate "<<key<<" "<<bs.windowless_frame_rate<<"\n";cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&info,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);
if(!browser){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);throw std::runtime_error("Browser creation failed");}v->browser=browser;v->browser_id=browser->get_identifier(browser);v->host=browser->get_host(browser);glGenTextures(2,v->textures);auto* ptr=v.get();views.emplace(key,std::move(v));ptr->host->notify_screen_info_changed(ptr->host);ptr->host->was_resized(ptr->host);ptr->host->was_hidden(ptr->host,key!="notifications");if(dock&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications"&&key!="launcher")show(*ptr,true);return *ptr;
if(dashboard){auto pixels=icon(false,true);overlays->SetOverlayRaw(v->thumbnail,pixels.data(),128,128,4);overlays->SetOverlayWidthInMeters(v->overlay,physical_width>0?physical_width:(key=="framely.manager"?3.2f:key=="launcher"?3.6f:1.2f));}else if(key!="menu"&&key!="notifications"&&key!="launcher"){anchor_to_head(*v,physical_width>0?physical_width:1.0f);}
cef_window_info_t info{};info.size=sizeof(info);info.windowless_rendering_enabled=1;info.runtime_style=CEF_RUNTIME_STYLE_ALLOY;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=browser_frame_rate();bs.background_color=0;std::cout<<"Browser frame rate "<<key<<" "<<bs.windowless_frame_rate<<"\n";cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&info,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);
if(!browser){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);throw std::runtime_error("Browser creation failed");}v->browser=browser;v->browser_id=browser->get_identifier(browser);v->host=browser->get_host(browser);glGenTextures(2,v->textures);auto* ptr=v.get();views.emplace(key,std::move(v));ptr->host->notify_screen_info_changed(ptr->host);ptr->host->was_resized(ptr->host);ptr->host->was_hidden(ptr->host,key!="notifications");if(ptr->dashboard&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications"&&key!="launcher")show(*ptr,true);return *ptr;
}
static void plugin_thumbnail(View& view,const std::string& encoded){
if(!view.thumbnail||encoded.empty()||encoded.size()>1400000)return;
@@ -527,6 +557,32 @@ 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){
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);
@@ -547,9 +603,9 @@ 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=="window.open"){hide_launcher(false);std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely Line truncated
static void 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=="apk.permission.open"){auto key="framely.apk.permission";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto& v=create_view(key,"Android permission",origin+"/apk/permission",false,1000,560,"",.85f);anchor_permission(v);}else if(kind=="apk.permission.close"){if(views.count("framely.apk.permission"))close_view(*views.at("framely.apk.permission"));}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.heighLine truncated
}
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")close_view(v);break;default:break;}}
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=="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&&v.key!="framely.apk.permission")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];}
@@ -607,7 +663,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")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.
@@ -655,10 +711,25 @@ 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"&&!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);}}
while(overlays->PollNextOverlayEvent(v->overlay,&event,sizeof(event)))overlay_input(*v,event);
if(v->dirty&&!v->closing){
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){
glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);
// Upload to the other texture; never redefine storage submitted this frame.
const int slot=1-v->texture_slot;const GLuint id=v->textures[slot];
@@ -681,8 +752,9 @@ int main(int argc,char** argv){
glPixelStorei(GL_UNPACK_ROW_LENGTH,0);damage.clear();
if(mipmapped)generate_mipmap(GL_TEXTURE_2D);
}
GLuint submitted=v->cast_frame.draw(id,pixel_width,pixel_height);
// glFlush queues the upload; glFinish makes the full image ready for sharing.
glFinish();vr::Texture_t texture{reinterpret_cast<void*>(uintptr_t(id)),vr::TextureType_OpenGL,vr::ColorSpace_Auto};
glFinish();vr::Texture_t texture{reinterpret_cast<void*>(uintptr_t(submitted)),vr::TextureType_OpenGL,vr::ColorSpace_Auto};
auto e=overlays->SetOverlayTexture(v->overlay,&texture);glFlush();
if(e)std::cerr<<"Texture submission failed "<<key<<" "<<e<<"\n";
else{v->texture_slot=slot;v->dirty=false;
@@ -710,7 +782,7 @@ int main(int argc,char** argv){
}
if(v->notification_content&&!v->placement.valid)anchor_notification(*v);
if(v->placement.valid)v->placement.curve(overlays,v->overlay,v->placement.width,notification_curve_radius);
show(*v,v->notification_content&&v->placement.valid);
show(*v,v->notification_requested&&v->notification_content&&v->placement.valid);
}
}
{json snapshot=json::object();for(auto&[key,v]:views){if(key=="menu"||key=="launcher"||key=="framely.manager"||key.rfind("framely.window.",0)==0)snapshot[key]=capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay));}std::lock_guard<std::mutex> lock(visibility_mutex);visible_views=std::move(snapshot);}
@@ -0,0 +1,21 @@
# Steam Input evdev output. Preserve its driver dead zones and axis ranges.
key 304 BUTTON_A
key 305 BUTTON_B
key 307 BUTTON_X
key 308 BUTTON_Y
key 310 BUTTON_L1
key 311 BUTTON_R1
key 312 BUTTON_L2
key 313 BUTTON_R2
key 314 BUTTON_SELECT
key 315 BUTTON_START
key 317 BUTTON_THUMBL
key 318 BUTTON_THUMBR
axis 0x00 X
axis 0x01 Y
axis 0x03 Z
axis 0x04 RZ
axis 0x02 LTRIGGER
axis 0x05 RTRIGGER
axis 0x10 HAT_X
axis 0x11 HAT_Y
+1 -1
View File
@@ -21,5 +21,5 @@ inline bool allow_plugin_navigation(const std::string& url, const std::string& o
}
inline bool manager_navigation_page(const std::string& page) {
return page=="installed"||page=="catalog"||page=="sources"||page=="notification-settings"||page=="settings"||page=="launcher-settings"||page=="updates"||page=="about"||page=="terminal"||page=="files"||page=="apk"||page=="apk-containers"||page=="apk-cleanup"||page=="apk-settings";
return page=="casting"||page=="casting-picture"||page=="casting-receive"||page=="casting-devices"||page=="installed"||page=="catalog"||page=="sources"||page=="notification-settings"||page=="settings"||page=="launcher-settings"||page=="updates"||page=="about"||page=="terminal"||page=="files"||page=="apk"||page=="apk-containers"||page=="apk-cleanup"||page=="apk-settings";
}
+28 -2
View File
@@ -13,10 +13,31 @@ steam_uid=$(id -u "$steam_user")
steam_home=$(getent passwd "$steam_user" | cut -d: -f6)
[[ $steam_uid -ge 1000 && $steam_home == /* && $steam_home != *' '* ]] || { echo 'Invalid Steam user/home.' >&2; exit 1; }
[[ -w /etc/systemd/system && -w /etc && -w /var/lib ]] || { echo 'System configuration is not writable. Framely did not change the read-only setting; installation stopped.' >&2; exit 1; }
# A terminal opened by Framely belongs to its service cgroup. Stop requests
# would kill this installer along with the UI, before it can switch/restart.
# Journal output avoids retaining a pipe to the terminal being shut down.
installer_cgroup=$(cat /proc/self/cgroup)
if [[ $installer_cgroup =~ /framely(-session|-ui-recovery|-backend-[^/]+|-plugin-[^/]+|-hook-[^/]+)?\.service(/|$|[[:space:]]) ]]; then
worker_args=("$steam_user")
if $repair; then worker_args=(--repair "$steam_user"); fi
install_unit="framely-install-$$-$(date +%s)"
systemd-run --quiet --collect --no-block --unit="$install_unit" \
--property=Type=exec --property=StandardInput=null \
--property=StandardOutput=journal --property=StandardError=journal \
/usr/bin/bash "$base/install.sh" "${worker_args[@]}"
echo "Installation continues independently. Log: journalctl -u $install_unit"
exit 0
fi
cd "$base"
sha256sum --quiet -c SHA256SUMS
for binary in bin/framely; do
dependencies=$(ldd "$binary" 2>&1)
casting_binaries=()
if [[ -d lib/media ]]; then
command -v ffmpeg >/dev/null || { echo "Missing system FFmpeg for casting." >&2; exit 1; }
casting_binaries=(bin/framely-panel-grab lib/media/framely-capture lib/media/framely-receiver lib/media/uxplay lib/media/libgstframely.so lib/media/libgstlibav.so lib/media/mediamtx)
fi
for binary in bin/framely "${casting_binaries[@]}"; do
dependencies=$(ldd "$binary" 2>&1 || true)
[[ -f $binary ]] || { echo "Missing binary: $binary" >&2; exit 1; }
[[ $dependencies != *'not found'* ]] || { echo "$dependencies" >&2; exit 1; }
done
version=$(cat VERSION)
@@ -41,6 +62,9 @@ for unit in framely.service framely-session.service; do
previous_active[$unit]=$(systemctl is-active "$unit" || true)
previous_enabled[$unit]=$(systemctl is-enabled "$unit" 2>/dev/null || true)
done
if systemctl --user --machine="$steam_user@" is-active --quiet framely-ui-recovery.service 2>/dev/null; then
previous_active[framely-session.service]=active
fi
if [[ -d $root && ! -L $root ]]; then
# Stop writers before moving state across filesystems. Restore running services
# if migration or subsequent preflight fails; the compatibility path survives.
@@ -145,6 +169,8 @@ rollback_on_failure() {
exit "$code"
}
trap rollback_on_failure ERR
# Retire the temporary user UI used to recover an interrupted installation.
systemctl --user --machine="$steam_user@" stop framely-ui-recovery.service 2>/dev/null || true
systemctl stop framely-session.service framely.service 2>/dev/null || true
# Clear the uninstall guard retained by older releases only for a clean reinstall.
# Existing installations, repairs and incomplete plugin cleanup retain safe mode.
+3
View File
@@ -18,6 +18,9 @@ export function installKeyboard(open:(params:{existing:string;password:boolean;m
};
(window as any).__framelyEditKeyboard=(edit:string)=>{
if(!field?.isConnected)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;
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}[];
+499 -23
View File
@@ -24,6 +24,8 @@ mod cleanup;
mod graphics;
mod lifecycle;
mod native;
pub(crate) mod permissions;
mod q2f;
mod steam_bridge;
mod steam_shortcuts;
pub(crate) mod steam_ui;
@@ -49,6 +51,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)]
@@ -107,6 +111,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 {
@@ -180,9 +223,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 +252,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 +408,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,9 +432,10 @@ 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");
let baked = managed_base.join(id).join("baked");
if baked.parent().is_some_and(|p| p.is_dir())
&& !fs::symlink_metadata(&baked).is_ok_and(|m| m.file_type().is_symlink())
{
@@ -592,7 +642,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.
}
@@ -947,9 +997,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 +1476,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 +1514,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 +1544,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:?}"'
@@ -1286,7 +1605,12 @@ if [[ -n "${FRAMELY_GAMEPAD_EVENT:-}" ]]; then
podman_mount_entry "${FRAMELY_GAMEPAD_EVENT:?}" /dev/input/event250 rw
podman_mount_entry "${FRAMELY_GAMEPAD_GRAB:?}" /vendor/lib64/libframely_gamepad_grab.so ro
if [[ -n "${FRAMELY_GAMEPAD_LAYOUT:-}" ]]; then
podman_mount_entry "$FRAMELY_GAMEPAD_LAYOUT" /system/usr/keylayout/Vendor_0001_Product_f001.kl ro
local layout=/system/usr/keylayout/Vendor_0001_Product_f001.kl
if [[ "${FRAMELY_GAMEPAD_DIRECT:-0}" == 1 ]]; then
layout=/system/usr/keylayout/Vendor_28de_Product_11ff.kl
podman_mount_entry "${FRAMELY_GAMEPAD_READY:?}/control" /framely-gamepad-control ro
fi
podman_mount_entry "$FRAMELY_GAMEPAD_LAYOUT" "$layout" ro
fi
podman_mount_entry "${FRAMELY_GAMEPAD_READY:?}" /framely-gamepad-ready rw
}
@@ -1610,11 +1934,15 @@ fn start_oriented_container_for(
cmd.env_remove("FRAMELY_STEAM_APP_ID");
}
if let Some(mount) = gamepad {
cmd.env("FRAMELY_GAMEPAD_EVENT", &mount.event)
.env("FRAMELY_GAMEPAD_GRAB", &mount.grab)
.env("FRAMELY_GAMEPAD_LAYOUT", &mount.layout)
.env("FRAMELY_GAMEPAD_READY", &mount.ready)
.env("FRAMELY_GAMEPAD_TOKEN", &mount.token);
cmd.env(
"FRAMELY_GAMEPAD_DIRECT",
if mount.direct { "1" } else { "0" },
)
.env("FRAMELY_GAMEPAD_EVENT", &mount.event)
.env("FRAMELY_GAMEPAD_GRAB", &mount.grab)
.env("FRAMELY_GAMEPAD_LAYOUT", &mount.layout)
.env("FRAMELY_GAMEPAD_READY", &mount.ready)
.env("FRAMELY_GAMEPAD_TOKEN", &mount.token);
}
if c.id.starts_with("external-") || c.name != c.id {
cmd.env(
@@ -1837,17 +2165,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());
}
}
}
}
@@ -2444,6 +2776,17 @@ 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<()>,
) -> Result<Value> {
q2f::convert(home, ticket, cancel, progress)
}
pub fn q2f_status(home: &Path) -> Result<Value> {
q2f::status(home)
}
pub fn operate(
home: &Path,
kind: &str,
@@ -2459,7 +2802,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
@@ -2588,7 +2931,7 @@ fn operate_internal(
}
.to_owned();
ensure!(
matches!(source.as_str(), "steam" | "frame"),
matches!(source.as_str(), "steam" | "frame" | "steam-direct"),
"Invalid gamepad input source"
);
let rumble = if p.get("rumble").is_some() {
@@ -2693,8 +3036,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,
@@ -2836,6 +3178,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!(
@@ -2866,6 +3213,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" => {
@@ -3545,7 +3900,7 @@ mod tests {
)
.is_err());
assert!(!load(home).unwrap().gamepad_enabled);
for source in ["frame", "steam"] {
for source in ["frame", "steam", "steam-direct"] {
operate(
home,
"gamepad.settings",
@@ -3680,6 +4035,28 @@ setup_props
assert!(fs::read_to_string(dir.path().join("props"))
.unwrap()
.contains("framely.gamepad.bridge=token"));
fs::write(dir.path().join("mounts"), "").unwrap();
let status = Command::new("bash")
.arg(&path)
.env("FRAMELY_LEPTON_DIR", library.parent().unwrap())
.env("TEST_PROPS", dir.path().join("props"))
.env("TEST_PREFIX", dir.path())
.env("TEST_MOUNTS", dir.path().join("mounts"))
.env("FRAMELY_GAMEPAD_EVENT", "/dev/input/event5")
.env("FRAMELY_GAMEPAD_GRAB", "/runtime/grab.so")
.env("FRAMELY_GAMEPAD_LAYOUT", "/runtime/steam.kl")
.env("FRAMELY_GAMEPAD_READY", "/private/gamepad")
.env("FRAMELY_GAMEPAD_DIRECT", "1")
.status()
.unwrap();
assert!(status.success());
let mounts = fs::read_to_string(dir.path().join("mounts")).unwrap();
assert_eq!(mounts.lines().count(), 5);
assert!(mounts.contains("/dev/input/event5|/dev/input/event250|rw"));
assert!(mounts
.contains("/runtime/steam.kl|/system/usr/keylayout/Vendor_28de_Product_11ff.kl|ro"));
assert!(mounts.contains("/private/gamepad/control|/framely-gamepad-control|ro"));
assert!(!mounts.contains("/dev/input|"));
}
#[test]
fn direct_boot_preserves_context_and_controls_window_visibility() {
@@ -3834,6 +4211,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");
@@ -4287,6 +4731,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();
+1 -1
View File
@@ -113,7 +113,7 @@ 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);
+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
+30 -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
@@ -216,6 +224,10 @@ pub(super) fn prepare(
)?;
crate::gamepad::select_target(&c.name, &a.metadata.package)?;
for (k, v) in [
(
"FRAMELY_GAMEPAD_DIRECT",
json!(if m.direct { "1" } else { "0" }),
),
("FRAMELY_GAMEPAD_EVENT", json!(m.event)),
("FRAMELY_GAMEPAD_GRAB", json!(m.grab)),
("FRAMELY_GAMEPAD_LAYOUT", json!(m.layout)),
@@ -340,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])
@@ -358,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();
@@ -378,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();
@@ -386,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
@@ -409,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())
@@ -420,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();
@@ -437,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))
}
+237
View File
@@ -0,0 +1,237 @@
//! Optional Quest APK conversion. The runtime is versioned separately from Framely.
use super::{apk_metadata, init, review, space, Cancellation, Metadata, MUTATION};
use anyhow::{ensure, Context, Result};
use serde_json::{json, Value};
use std::{
fs,
os::unix::fs::{symlink, PermissionsExt},
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
time::Duration,
};
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::*;
#[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"
);
}
}
pub(super) fn convert(
home: &Path,
ticket: &str,
cancel: &Cancellation,
progress: impl Fn(Value) -> Result<()>,
) -> 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);
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 after: Metadata = apk_metadata::read(&converted)?;
ensure!(
after.package == before.package && after.version_code == before.version_code,
"Quest conversion changed APK package or version"
);
cancel.check()?;
// Create a fresh review after conversion so the user has the full
// review window even when patching takes several minutes.
drop(_guard);
super::inspect(home, &converted, cancel)
})();
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}),
)
}
+29 -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,7 +230,7 @@ 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),
@@ -238,7 +238,7 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
r.steam_binding = Some(Binding {
game_id: game,
native: true,
revision: revision(a),
revision: revision(a, r),
});
Ok(())
}
@@ -285,8 +285,30 @@ 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});
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");
let current_revision = 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 +391,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 +443,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;
+6
View File
@@ -0,0 +1,6 @@
//! Built-in casting. Settings live in the core database; all media workers
//! run as the Steam session user, apart from the framebuffer export helper.
pub mod settings;
pub mod runtime;
pub mod receiver;
+589
View File
@@ -0,0 +1,589 @@
use super::settings::Settings;
use anyhow::{bail, ensure, Context, Result};
use serde_json::{json, Value};
use std::{
collections::{BTreeMap, VecDeque},
fs,
io::{BufRead, BufReader, Write},
os::unix::{fs::PermissionsExt, net::UnixDatagram, process::CommandExt},
path::PathBuf,
process::{Child, ChildStdin, Command, Stdio},
sync::mpsc,
time::{Duration, Instant},
};
struct Worker {
child: Child,
input: ChildStdin,
output: mpsc::Receiver<Value>,
}
impl Worker {
fn new(mut command: Command, log: PathBuf) -> Result<Self> {
super::runtime::bind_to_session(&mut command);
let mut child = command
.process_group(0)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::from(
fs::OpenOptions::new().create(true).append(true).open(log)?,
))
.spawn()?;
let input = child.stdin.take().unwrap();
let output = child.stdout.take().unwrap();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for line in BufReader::new(output).lines() {
let Ok(line) = line else {
break;
};
if let Ok(v) = serde_json::from_str(&line) {
if tx.send(v).is_err() {
break;
}
}
}
});
Ok(Self {
child,
input,
output: rx,
})
}
fn stop(&mut self) {
if self.child.try_wait().ok().flatten().is_none() {
unsafe {
libc::kill(-(self.child.id() as i32), libc::SIGTERM);
}
let end = Instant::now() + Duration::from_secs(2);
while self.child.try_wait().ok().flatten().is_none() && Instant::now() < end {
std::thread::sleep(Duration::from_millis(20));
}
if self.child.try_wait().ok().flatten().is_none() {
unsafe {
libc::kill(-(self.child.id() as i32), libc::SIGKILL);
}
}
}
let _ = self.child.wait();
}
}
impl Drop for Worker {
fn drop(&mut self) {
self.stop();
}
}
pub struct Receivers {
pub directory: PathBuf,
binaries: PathBuf,
configuration: Option<(bool, bool, String)>,
socket: Option<UnixDatagram>,
worker: Option<Worker>,
airplay: Option<Child>,
airplay_id: String,
sequence: u64,
pub sessions: BTreeMap<String, Value>,
events: VecDeque<Value>,
pub error: Option<String>,
}
impl Receivers {
pub fn new(directory: PathBuf, binaries: PathBuf) -> Self {
Self {
directory,
binaries,
configuration: None,
socket: None,
worker: None,
airplay: None,
airplay_id: String::new(),
sequence: 0,
sessions: BTreeMap::new(),
events: VecDeque::new(),
error: None,
}
}
pub fn configure(&mut self, s: &Settings) -> Result<()> {
let configuration = (s.airplay, s.dlna, s.receiver_name.clone());
if self.configuration.as_ref() == Some(&configuration) {
return Ok(());
}
self.stop();
self.configuration = Some(configuration);
self.error = None;
let result = (|| {
fs::create_dir_all(&self.directory)?;
fs::set_permissions(&self.directory, fs::Permissions::from_mode(0o700))?;
let path = self.directory.join("events.sock");
let _ = fs::remove_file(&path);
let socket = UnixDatagram::bind(path)?;
socket.set_nonblocking(true)?;
self.socket = Some(socket);
self.write_description(&s.receiver_name)?;
let executable = self.binaries.join("framely-receiver");
ensure!(executable.is_file(), "缺少内置投屏接收程序");
let mut cmd = Command::new(executable);
cmd.arg(&self.directory)
.arg(self.directory.join("upnp"))
.arg(if s.dlna { "1" } else { "0" })
.env("GST_PLUGIN_PATH", &self.binaries);
self.worker = Some(Worker::new(cmd, self.directory.join("receiver.log"))?);
if s.airplay {
self.start_airplay(&s.receiver_name)?;
}
Ok(())
})();
if let Err(e) = &result {
self.error = Some(format!("{e:#}"));
}
result
}
fn write_description(&self, name: &str) -> Result<()> {
let xml = self.directory.join("upnp");
fs::create_dir_all(&xml)?;
let resources = self.binaries.join("upnp");
for file in [
"AVTransport.xml",
"RenderingControl.xml",
"ConnectionManager.xml",
] {
fs::copy(resources.join(file), xml.join(file))?;
}
let name = name
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
let services=["AVTransport","RenderingControl","ConnectionManager"].map(|s|format!("<service><serviceType>urn:schemas-upnp-org:service:{s}:1</serviceType><serviceId>urn:upnp-org:serviceId:{s}</serviceId><SCPDURL>/{s}.xml</SCPDURL><controlURL>/{s}/control</controlURL><eventSubURL>/{s}/event</eventSubURL></service>")).join("");
// The advertised renderer has a persistent identity for this Steam user.
use sha2::{Digest, Sha256};
let mut identity = fs::read("/etc/machine-id").context("无法读取设备身份")?;
identity.extend_from_slice(crate::steam::home()?.as_os_str().as_encoded_bytes());
let hash = hex::encode(Sha256::digest(identity));
let uuid = format!(
"{}-{}-{}-{}-{}",
&hash[..8],
&hash[8..12],
&hash[12..16],
&hash[16..20],
&hash[20..32]
);
fs::write(xml.join("device.xml"),format!("<?xml version=\"1.0\"?><root xmlns=\"urn:schemas-upnp-org:device-1-0\"><specVersion><major>1</major><minor>0</minor></specVersion><device><deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType><friendlyName>{name}</friendlyName><manufacturer>Framely</manufacturer><modelName>Framely</modelName><UDN>uuid:{uuid}</UDN><serviceList>{services}</serviceList></device></root>"))?;
Ok(())
}
fn start_airplay(&mut self, name: &str) -> Result<()> {
let executable = self.binaries.join("uxplay");
ensure!(executable.is_file(), "缺少内置 AirPlay 接收程序");
self.airplay_id = crate::session::cast_random_key()[..32].into();
let log = fs::OpenOptions::new().create(true).truncate(true).write(true)
.open(self.directory.join("airplay.log"))?;
let mut cmd = Command::new(executable);
cmd.process_group(0)
.args([
"-n",
name,
"-avdec",
"-hls",
"-vs",
"framelyvideosink",
"-as",
"framelyaudiosink",
])
.env("GST_PLUGIN_PATH", &self.binaries)
.env("FRAMELY_CAST_DIR", &self.directory)
.env("FRAMELY_CAST_ID", &self.airplay_id)
.stdin(Stdio::null())
.stdout(Stdio::from(log.try_clone()?))
.stderr(Stdio::from(log));
super::runtime::bind_to_session(&mut cmd);
self.airplay = Some(cmd.spawn()?);
Ok(())
}
fn event(&mut self, mut e: Value) {
let Some(id) = e["id"].as_str().map(str::to_owned) else {
if e["event"] == "fatal" {
self.error = e["error"].as_str().map(str::to_owned);
}
return;
};
if !id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') || id.len() > 64 {
return;
}
if e["protocol"] == "AirPlay" && id != self.airplay_id {
return;
}
let kind = e["event"].as_str().unwrap_or("");
if matches!(kind, "request" | "video" | "audio") {
let fresh = !self.sessions.contains_key(&id);
if fresh {
e["accepted"] = json!(false);
e["createdAt"] = json!(crate::service::now_ms());
self.sessions.insert(id.clone(), e.clone());
self.events
.push_back(json!({"kind":"cast.request","session":e}));
} else {
let session = self.sessions.get_mut(&id).unwrap();
let mut changed = false;
if e["width"].as_u64().unwrap_or(0) > 0 && e["height"].as_u64().unwrap_or(0) > 0 {
changed = session["width"] != e["width"] || session["height"] != e["height"];
session["width"] = e["width"].clone();
session["height"] = e["height"].clone();
session["mediaType"] = json!("video");
} else if e["mediaType"] == "video" && session["mediaType"] != "video" {
session["mediaType"] = json!("video");
changed = true;
}
if session["accepted"] == true {
if changed || matches!(kind, "video" | "audio") {
self.events.push_back(json!({"kind":"cast.window","session":session.clone()}));
}
} else if kind == "request" && e["protocol"] == "AirPlay" {
session["createdAt"] = json!(crate::service::now_ms());
self.events.push_back(json!({"kind":"cast.request","session":session.clone()}));
}
}
} else if matches!(kind, "ended" | "error") {
if let Some(mut s) = self.sessions.remove(&id) {
s["error"] = e["error"].clone();
self.events
.push_back(json!({"kind":"cast.ended","session":s}));
}
self.clean_session(&id);
}
}
pub fn poll(&mut self) -> Vec<Value> {
let mut values = Vec::new();
if let Some(w) = &self.worker {
values.extend(w.output.try_iter());
}
if let Some(socket) = &self.socket {
loop {
let mut bytes = [0; 16384];
let Ok(n) = socket.recv(&mut bytes) else {
break;
};
if let Ok(v) = serde_json::from_slice(&bytes[..n]) {
values.push(v);
}
}
}
let mut disconnected = false;
for e in values {
if e["protocol"] == "AirPlay"
&& e["event"] == "ended"
&& e["id"] == self.airplay_id
&& self.sessions.contains_key(&self.airplay_id)
{
disconnected = true;
}
self.event(e);
}
if disconnected {
if let Some(mut p) = self.airplay.take() {
stop_child(&mut p);
}
if let Some(config) = &self.configuration {
let name = config.2.clone();
if config.0 {
if let Err(e) = self.start_airplay(&name) {
self.error = Some(e.to_string());
}
}
}
}
let mut retained = VecDeque::new();
while let Some(e) = self.events.pop_front() {
if e["kind"] == "cast.engine.event" {
self.event(e["data"].clone());
} else {
retained.push_back(e);
}
}
self.events = retained;
if let Some(code) = self
.worker
.as_mut()
.and_then(|w| w.child.try_wait().ok().flatten())
{
self.error = Some(format!("DLNA 接收程序已退出:{code}"));
self.worker = None;
self.end_protocol("DLNA");
}
if let Some(code) = self
.airplay
.as_mut()
.and_then(|w| w.try_wait().ok().flatten())
{
let details = fs::read_to_string(self.directory.join("airplay.log")).unwrap_or_default();
let tail = details.lines().rev().take(8).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n");
self.error = Some(format!("AirPlay 接收程序已退出:{code}\n{tail}"));
self.airplay = None;
self.end_protocol("AirPlay");
}
// Pending requests expire; a delayed notification must never open an old cast.
let expired: Vec<_> = self
.sessions
.iter()
.filter(|(_, s)| {
s["accepted"] != true
&& crate::service::now_ms().saturating_sub(s["createdAt"].as_u64().unwrap_or(0))
> 60000
})
.map(|(id, _)| id.clone())
.collect();
for id in expired {
let _ = self.control(&id, "reject", json!({}));
}
self.events.drain(..).collect()
}
fn request(&mut self, mut value: Value) -> Result<Value> {
self.sequence += 1;
value["request"] = json!(self.sequence);
let sequence = self.sequence;
let w = self.worker.as_mut().context("投屏服务尚未启动")?;
writeln!(w.input, "{value}")?;
w.input.flush()?;
let deadline = Instant::now() + Duration::from_secs(8);
loop {
let reply = w
.output
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.context("投屏服务未响应")?;
if reply["request"].as_u64() == Some(sequence) {
if let Some(e) = reply["error"].as_str() {
bail!("{e}");
}
return Ok(reply["result"].clone());
}
self.events
.push_back(json!({"kind":"cast.engine.event","data":reply}));
}
}
pub fn devices(&mut self) -> Result<Value> {
self.request(json!({"kind":"devices"}))
}
pub fn send(&mut self, id: &str, uri: &str) -> Result<Value> {
self.request(json!({"kind":"send","id":id,"uri":uri}))
}
pub fn stop_send(&mut self, id: &str) -> Result<Value> {
self.request(json!({"kind":"send.stop","id":id}))
}
pub fn control(&mut self, id: &str, kind: &str, params: Value) -> Result<Value> {
let session = self.sessions.get(id).context("投屏请求已过期")?.clone();
ensure!(
matches!(
kind,
"accept" | "reject" | "stop" | "pause" | "volume" | "seek" | "status"
),
"Invalid media action"
);
ensure!(
matches!(kind, "accept" | "reject" | "stop") || session["accepted"] == true,
"尚未接受投屏"
);
ensure!(
!matches!(kind, "accept" | "reject") || session["accepted"] != true,
"投屏已接受"
);
if session["protocol"] == "AirPlay" {
ensure!(id == self.airplay_id, "投屏请求已过期");
let path = self.directory.join(format!("{id}.accept"));
match kind {
"accept" => {
fs::write(&path, [])?;
}
"pause" => {
if params["paused"].as_bool().unwrap_or(true) {
let _ = fs::remove_file(path);
} else {
fs::write(path, [])?;
}
}
"reject" | "stop" => {
let _ = fs::remove_file(path);
if let Some(mut child) = self.airplay.take() {
stop_child(&mut child);
}
self.clean_session(id);
self.sessions.remove(id);
self.events
.push_back(json!({"kind":"cast.ended","session":session}));
let name = self.configuration.as_ref().context("服务已关闭")?.2.clone();
self.start_airplay(&name)?;
return Ok(json!(true));
}
"volume" => {
let volume = params["volume"].as_f64().context("Invalid volume")?;
ensure!(
volume.is_finite() && (0.0..=1.0).contains(&volume),
"Invalid volume"
);
fs::write(
self.directory.join(format!("{id}.volume")),
volume.to_string(),
)?;
}
"status" => return Ok(json!({"paused":!path.exists(),"position":0,"duration":0})),
_ => bail!("AirPlay 不支持跳转"),
};
} else {
let mut p = params;
p["kind"] = json!(kind);
p["id"] = json!(id);
let result = self.request(p)?;
if kind == "status" {
return Ok(result);
}
}
if kind == "accept" {
self.sessions.get_mut(id).unwrap()["accepted"] = json!(true);
if session["protocol"] == "AirPlay" {
self.events
.push_back(json!({"kind":"cast.window","session":self.sessions[id]}));
}
}
if matches!(kind, "stop" | "reject") {
self.clean_session(id);
self.sessions.remove(id);
self.events
.push_back(json!({"kind":"cast.ended","session":session}));
}
Ok(json!(true))
}
pub fn window(&self, id: &str) -> Result<Value> {
let s = self.sessions.get(id).context("投屏已经结束")?;
ensure!(s["accepted"] == true, "尚未接受投屏");
Ok(s.clone())
}
fn end_protocol(&mut self, protocol: &str) {
let ids: Vec<_> = self
.sessions
.iter()
.filter(|(_, s)| s["protocol"] == protocol)
.map(|(id, _)| id.clone())
.collect();
for id in ids {
self.event(json!({"event":"ended","id":id}));
}
}
fn clean_session(&self, id: &str) {
for suffix in ["accept", "frame", "volume"] {
let _ = fs::remove_file(self.directory.join(format!("{id}.{suffix}")));
}
}
fn stop(&mut self) {
self.worker = None;
if let Some(mut p) = self.airplay.take() {
stop_child(&mut p);
}
for (id, s) in &self.sessions {
self.clean_session(id);
self.events
.push_back(json!({"kind":"cast.ended","session":s}));
}
self.sessions.clear();
self.socket = None;
}
}
impl Drop for Receivers {
fn drop(&mut self) {
self.stop();
}
}
fn stop_child(child: &mut Child) {
if child.try_wait().ok().flatten().is_some() {
return;
}
unsafe {
libc::kill(-(child.id() as i32), libc::SIGTERM);
}
let deadline = Instant::now() + Duration::from_secs(2);
while child.try_wait().ok().flatten().is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
if child.try_wait().ok().flatten().is_none() {
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
}
let _ = child.wait();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn airplay_setup_requests_consent_before_frames_and_after_reconnect() {
let root=tempfile::tempdir().unwrap();
let mut r=Receivers::new(root.path().to_owned(),root.path().join("bin"));
r.airplay_id="0123".into();
for _ in 0..2 {
r.event(json!({"event":"request","id":"0123","protocol":"AirPlay"}));
assert_eq!(r.events.pop_front().unwrap()["kind"],"cast.request");
assert!(r.window("0123").is_err());
r.event(json!({"event":"request","id":"0123","protocol":"AirPlay"}));
assert_eq!(r.events.pop_front().unwrap()["kind"],"cast.request");
r.control("0123","accept",json!({})).unwrap();
assert_eq!(r.window("0123").unwrap()["accepted"],true);
r.event(json!({"event":"ended","id":"0123","protocol":"AirPlay"}));
assert!(!root.path().join("0123.accept").exists());
r.events.clear();
}
}
#[test]
fn airplay_video_setup_promotes_audio_session_before_decoding() {
let root = tempfile::tempdir().unwrap();
let mut r = Receivers::new(root.path().to_owned(), root.path().join("bin"));
r.airplay_id = "0123".into();
r.event(json!({"event":"request","id":"0123","protocol":"AirPlay","mediaType":"audio"}));
r.control("0123", "accept", json!({})).unwrap();
r.events.clear();
r.event(json!({"event":"request","id":"0123","protocol":"AirPlay","mediaType":"video"}));
assert_eq!(r.window("0123").unwrap()["mediaType"], "video");
assert_eq!(r.events.pop_front().unwrap()["kind"], "cast.window");
r.event(json!({"event":"request","id":"0123","protocol":"AirPlay","width":1080,"height":1920}));
let update = r.events.pop_front().unwrap();
assert_eq!(update["session"]["width"], 1080);
assert_eq!(update["session"]["height"], 1920);
r.event(json!({"event":"audio","id":"0123","protocol":"AirPlay","mediaType":"audio"}));
assert_eq!(r.window("0123").unwrap()["mediaType"], "video");
}
#[test]
fn consent_blocks_playback_controls_and_rejects_stale_airplay_sessions() {
let root = tempfile::tempdir().unwrap();
let mut r = Receivers::new(root.path().to_owned(), root.path().join("bin"));
r.airplay_id = "0123".into();
r.event(json!({"event":"video","id":"0123","protocol":"AirPlay","width":320,"height":240}));
assert!(r.window("0123").is_err());
assert!(r.control("0123", "pause", json!({})).is_err());
r.control("0123", "accept", json!({})).unwrap();
assert_eq!(r.window("0123").unwrap()["width"], 320);
assert!(r.control("0123", "accept", json!({})).is_err());
r.airplay_id = "4567".into();
assert!(r.control("0123", "volume", json!({"volume":0.5})).is_err());
}
#[test]
fn session_updates_preserve_consent_and_disconnect_cleans_only_its_protocol() {
let root = tempfile::tempdir().unwrap();
let mut r = Receivers::new(root.path().to_owned(), root.path().join("bin"));
r.airplay_id = "4567".into();
for (id, protocol) in [("0123", "DLNA"), ("4567", "AirPlay")] {
r.event(json!({"event":"request","id":id,"protocol":protocol}));
r.sessions.get_mut(id).unwrap()["accepted"] = json!(true);
for suffix in ["frame", "accept", "volume"] {
fs::write(root.path().join(format!("{id}.{suffix}")), []).unwrap();
}
}
r.event(json!({"event":"video","id":"0123","protocol":"DLNA","width":240,"height":320}));
assert_eq!(r.window("0123").unwrap()["height"], 320);
assert_eq!(r.sessions.len(), 2);
r.end_protocol("DLNA");
assert!(r.window("0123").is_err());
assert!(r.window("4567").is_ok());
for suffix in ["frame", "accept", "volume"] {
assert!(!root.path().join(format!("0123.{suffix}")).exists());
}
assert!(r
.events
.iter()
.any(|e| e["kind"] == "cast.ended" && e["session"]["id"] == "0123"));
}
}
+694
View File
@@ -0,0 +1,694 @@
use super::settings::{Codec, Eye, Output, Settings, Source};
use anyhow::{bail, ensure, Context, Result};
use serde_json::{json, Value};
use std::{
fs,
io::Read,
os::unix::{fs::PermissionsExt, process::CommandExt},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
pub struct Casting {
pub directory: PathBuf,
pub binaries: PathBuf,
workers: Vec<Child>,
started: Option<Instant>,
pub whep_port: u16,
rtsp_port: u16,
api_port: u16,
dlna_started: bool,
vp8: Option<(Child, Instant)>,
compatibility_poll: Instant,
active_settings: Option<Settings>,
pub hls_port: u16,
pub stream_key: String,
error: Option<String>,
preview_restore: Option<Option<Settings>>,
}
fn port() -> Result<u16> {
Ok(std::net::TcpListener::bind("127.0.0.1:0")?
.local_addr()?
.port())
}
impl Casting {
pub fn new(directory: PathBuf, binaries: PathBuf) -> Self {
Self {
directory,
binaries,
workers: Vec::new(),
started: None,
whep_port: 0,
rtsp_port: 0,
api_port: 0,
dlna_started: false,
vp8: None,
compatibility_poll: Instant::now(),
active_settings: None,
hls_port: 0,
stream_key: String::new(),
error: None,
preview_restore: None,
}
}
pub fn status(&mut self) -> Value {
let exited = self
.workers
.iter_mut()
.find_map(|p| p.try_wait().ok().flatten());
if let Some(code) = exited {
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()})
}
fn stop_workers(&mut self) {
for child in &mut self.workers {
if child.try_wait().ok().flatten().is_some() {
continue;
}
// Each worker owns a process group, including its media children.
unsafe {
libc::kill(-(child.id() as i32), libc::SIGTERM);
}
}
let deadline = Instant::now() + Duration::from_secs(2);
for child in &mut self.workers {
while child.try_wait().ok().flatten().is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
if child.try_wait().ok().flatten().is_none() {
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
}
let _ = child.wait();
}
self.workers.clear();
self.dlna_started = false;
self.stop_vp8();
self.active_settings = None;
self.started = None;
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}
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, "请先开始头显串流");
if self.vp8.is_some() {
return Ok(());
}
let s = self
.active_settings
.clone()
.context("Missing capture settings")?;
let source = format!("rtsp://127.0.0.1:{}/headset", self.rtsp_port);
let target = format!("rtsp://127.0.0.1:{}/headset_vp8", self.rtsp_port);
self.spawn(Command::new("ffmpeg").args([
"-hide_banner",
"-loglevel",
"warning",
"-nostdin",
"-rtsp_transport",
"tcp",
"-i",
&source,
"-map",
"0:v:0",
"-map",
"0:a?",
"-c:v",
"libvpx",
"-deadline",
"realtime",
"-cpu-used",
"8",
"-lag-in-frames",
"0",
"-error-resilient",
"1",
"-threads",
"2",
"-r",
&s.fps.to_string(),
"-g",
&s.fps.to_string(),
"-b:v",
&format!("{}M", s.bitrate_mbps),
"-c:a",
"copy",
"-f",
"rtsp",
"-rtsp_transport",
"tcp",
&target,
]))?;
self.vp8 = Some((self.workers.pop().unwrap(), Instant::now()));
let ready = (|| {
let end = Instant::now() + Duration::from_secs(12);
while !self.path_ready("headset_vp8") {
ensure!(Instant::now() < end, "兼容编码启动超时");
ensure!(
self.vp8.as_mut().unwrap().0.try_wait()?.is_none(),
"兼容编码启动失败"
);
std::thread::sleep(Duration::from_millis(50));
}
Ok(())
})();
if ready.is_err() {
self.stop_vp8();
}
ready
}
fn stop_vp8(&mut self) {
if let Some((mut child, _)) = self.vp8.take() {
if child.try_wait().ok().flatten().is_none() {
unsafe {
libc::kill(-(child.id() as i32), libc::SIGTERM);
}
let end = Instant::now() + Duration::from_secs(2);
while child.try_wait().ok().flatten().is_none() && Instant::now() < end {
std::thread::sleep(Duration::from_millis(20));
}
if child.try_wait().ok().flatten().is_none() {
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
}
}
let _ = child.wait();
}
}
pub fn maintain(&mut self) {
if self.compatibility_poll.elapsed() < Duration::from_secs(2) {
return;
}
self.compatibility_poll = Instant::now();
let Some((child, started)) = &mut self.vp8 else {
return;
};
if child.try_wait().ok().flatten().is_some() {
self.stop_vp8();
return;
}
// Allow ICE establishment before deciding the compatibility encoder is idle.
if started.elapsed() < Duration::from_secs(10) {
return;
}
let idle = ureq::get(&format!(
"http://127.0.0.1:{}/v3/paths/get/headset_vp8",
self.api_port
))
.timeout(Duration::from_millis(300))
.call()
.ok()
.and_then(|r| r.into_json::<Value>().ok())
.is_some_and(|v| v["readers"].as_array().is_some_and(Vec::is_empty));
if idle {
self.stop_vp8();
}
}
fn path_ready(&self, path: &str) -> bool {
ureq::get(&format!(
"http://127.0.0.1:{}/v3/paths/get/{path}",
self.api_port
))
.timeout(Duration::from_millis(300))
.call()
.ok()
.and_then(|r| r.into_json::<Value>().ok())
.is_some_and(|v| v["ready"] == true)
}
pub fn ensure_dlna(&mut self) -> Result<()> {
ensure!(self.status()["running"] == true, "请先开始头显串流");
if self.dlna_started {
return Ok(());
}
let source = format!("rtsp://127.0.0.1:{}/headset", self.rtsp_port);
let target = format!("rtsp://127.0.0.1:{}/dlna", self.rtsp_port);
let mut command=Command::new("ffmpeg");command.args([
"-hide_banner",
"-loglevel",
"warning",
"-nostdin",
"-rtsp_transport",
"tcp",
"-i",
&source,
"-map",
"0:v:0",
"-map",
"0:a?",
"-c:a",
"aac",
"-b:a",
"128k",
"-ar",
"48000",
"-f",
"rtsp",
"-rtsp_transport",
"tcp",
]);
if self.active_settings.as_ref().is_some_and(|s|s.codec==Codec::H265) {command.args(["-c:v","libx264","-preset","ultrafast","-tune","zerolatency","-profile:v","baseline","-bf","0","-r",&self.active_settings.as_ref().unwrap().fps.to_string()]);} else {command.args(["-c:v","copy"]);}
command.arg(&target);
self.spawn(&mut command)?;
let deadline=Instant::now()+Duration::from_secs(12);
while !self.path_ready("dlna") {
ensure!(self.workers.last_mut().unwrap().try_wait()?.is_none(),"DLNA 兼容编码启动失败:{}",fs::read_to_string(self.directory.join("stream.log")).unwrap_or_default());
ensure!(Instant::now()<deadline,"DLNA 兼容编码启动超时");
std::thread::sleep(Duration::from_millis(50));
}
self.dlna_started = true;
Ok(())
}
pub fn stop(&mut self) -> Value {
self.preview_restore = None;
self.stop_workers();
self.error = None;
json!(true)
}
pub fn start_preview(&mut self, settings: &Settings, socket: &Path) -> Result<Value> {
ensure!(self.preview_restore.is_none(), "取景预览已在运行");
let restore = self.active_settings.clone();
self.stop_workers();
let mut reference = settings.clone();
reference.source = Source::Screen;
reference.codec = Codec::H264;
reference.output = Output::Eye;
reference.width = 960;
reference.height = 960;
reference.fps = 30;
reference.bitrate_mbps = 4;
reference.horizontal_fov = None;
reference.center_x = 0.;
reference.center_y = 0.;
reference.system_audio = false;
reference.microphone = false;
self.start(&reference, Some(socket))?;
self.preview_restore = Some(restore);
Ok(self.status())
}
pub fn end_preview(&mut self) -> Result<Option<Settings>> {
let restore = self.preview_restore.take().context("没有正在运行的取景预览")?;
self.stop_workers();
self.error = None;
Ok(restore)
}
fn spawn(&mut self, cmd: &mut Command) -> Result<()> {
bind_to_session(cmd);
cmd.process_group(0)
.stdin(Stdio::null())
.stdout(Stdio::null());
let log = fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.directory.join("stream.log"))?;
cmd.stderr(Stdio::from(log));
self.workers.push(cmd.spawn().context("启动串流程序失败")?);
Ok(())
}
pub fn start(&mut self, settings: &Settings, panel_socket: Option<&Path>) -> Result<Value> {
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"), [])?;
let result = self.start_inner(settings, panel_socket);
if let Err(e) = &result {
use std::io::{Read, Seek, SeekFrom};
let mut detail = String::new();
if let Ok(mut log) = fs::File::open(self.directory.join("stream.log")) {
if let Ok(length) = log.metadata().map(|m| m.len()) {
let _ = log.seek(SeekFrom::Start(length.saturating_sub(2048)));
let mut bytes = Vec::new();
let _ = log.take(2048).read_to_end(&mut bytes);
detail = String::from_utf8_lossy(&bytes).into_owned();
}
}
self.error = Some(format!("{e:#}\n{}", detail.trim()));
self.stop_workers();
return Err(anyhow::anyhow!(self.error.clone().unwrap()));
}
result
}
fn start_inner(&mut self, s: &Settings, panel_socket: Option<&Path>) -> Result<Value> {
let mediamtx = self.binaries.join("mediamtx");
ensure!(
mediamtx.is_file(),
"缺少内置媒体服务,请重新安装完整 Framely 运行包"
);
let rtsp = port()?;
self.rtsp_port = rtsp;
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);
let config_path = self.directory.join("mediamtx.yml");
fs::write(&config_path, configuration)?;
self.spawn(Command::new(mediamtx).arg(config_path))?;
// Wait for readiness instead of hiding startup failures behind a fixed sleep.
let deadline = Instant::now() + Duration::from_secs(5);
while std::net::TcpStream::connect(("127.0.0.1", rtsp)).is_err() {
ensure!(Instant::now() < deadline, "媒体服务启动超时");
if let Some(code) = self.workers[0].try_wait()? {
bail!("媒体服务启动失败:{code}");
}
std::thread::sleep(Duration::from_millis(30));
}
let mut ffmpeg = Command::new("ffmpeg");
ffmpeg.args(["-hide_banner", "-loglevel", "warning", "-nostdin"]);
let mut captured = None;
if s.source == Source::Screen {
let executable = self.binaries.join("framely-capture");
ensure!(executable.is_file(), "缺少屏幕采集程序");
let socket = panel_socket.context("缺少屏幕采集助手连接")?;
let stream = std::os::unix::net::UnixStream::connect(socket)?;
use std::os::fd::AsRawFd;
let fd = stream.as_raw_fd();
let eye = if s.output == Output::Raw {
0
} else {
match s.eye {
Eye::Left => 0,
Eye::Right => 1,
Eye::SteamVR => {
dominant_eye().context("无法读取 SteamVR 主力眼,请手动选择左眼或右眼")?
}
}
};
let mut command = Command::new(executable);
command.args([
"--width",
&s.width.to_string(),
"--height",
&s.height.to_string(),
"--fps",
&s.fps.to_string(),
"--bitrate",
&s.bitrate_mbps.to_string(),
"--eye",
&eye.to_string(),
"--codec",
s.codec.name(),
]);
if s.output == Output::Raw {
command.arg("--raw");
} else {
if let Some(fov) = s.horizontal_fov {
command.args(["--fov", &fov.to_string()]);
}
command.args([
"--center-x",
&s.center_x.to_string(),
"--center-y",
&s.center_y.to_string(),
]);
}
bind_to_session(&mut command);
command
.process_group(0)
.env("FRAMELY_GRAB_FD", "3")
.stdout(Stdio::piped())
.stdin(Stdio::null());
let log = fs::OpenOptions::new()
.append(true)
.open(self.directory.join("stream.log"))?;
command.stderr(Stdio::from(log));
unsafe {
command.pre_exec(move || {
if fd != 3 && libc::dup2(fd, 3) < 0 {
return Err(std::io::Error::last_os_error());
}
if libc::fcntl(3, libc::F_SETFD, 0) < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let mut child = command.spawn()?;
drop(stream);
captured = child.stdout.take();
self.workers.push(child);
ffmpeg.args([
"-fflags",
"+genpts",
"-thread_queue_size",
"4",
"-probesize",
"32768",
"-analyzeduration",
"0",
"-framerate",
&s.fps.to_string(),
"-f",
if s.codec == Codec::H265 {"hevc"} else {"h264"},
"-i",
"pipe:0",
]);
} 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@");
}
if s.microphone {
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"]);
}
if s.source == Source::Screen {
ffmpeg.args(["-c:v", "copy"]);
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)]);
}
} 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),
"-c:v", if s.codec==Codec::H265 {"libx265"} else {"libx264"}, "-preset", "ultrafast", "-tune", "zerolatency", "-bf", "0", "-g", &s.fps.to_string(), "-b:v", &format!("{}M",s.bitrate_mbps)]);
if s.codec==Codec::H265 {ffmpeg.args(["-x265-params", "bframes=0:rc-lookahead=0:repeat-headers=1:pools=2:frame-threads=1"]);} else {ffmpeg.args(["-profile:v", "baseline"]);}
}
if audio {
ffmpeg.args(["-c:a", "libopus", "-b:a", "96k", "-ar", "48000", "-application", "lowdelay", "-frame_duration", "10"]);
} else {
ffmpeg.arg("-an");
}
ffmpeg.args([
"-max_interleave_delta",
"100000",
"-flush_packets",
"1",
"-f",
"rtsp",
"-rtsp_transport",
"tcp",
&format!("rtsp://127.0.0.1:{rtsp}/headset"),
]);
bind_to_session(&mut ffmpeg);
ffmpeg.process_group(0).stdout(Stdio::null());
ffmpeg.stdin(captured.map(Stdio::from).unwrap_or_else(Stdio::null));
ffmpeg.stderr(Stdio::from(
fs::OpenOptions::new()
.append(true)
.open(self.directory.join("stream.log"))?,
));
self.workers
.push(ffmpeg.spawn().context("启动 FFmpeg 失败")?);
let deadline = Instant::now() + Duration::from_secs(12);
loop {
if self
.workers
.iter_mut()
.any(|child| child.try_wait().ok().flatten().is_some())
{
bail!("采集或编码程序启动失败,请查看串流日志");
}
if ureq::get(&format!(
"http://127.0.0.1:{}/v3/paths/get/headset",
self.api_port
))
.timeout(Duration::from_millis(300))
.call()
.ok()
.and_then(|r| r.into_json::<Value>().ok())
.is_some_and(|v| v["ready"] == true)
{
break;
}
ensure!(
Instant::now() < deadline,
"采集未输出画面,请确认头显已唤醒"
);
std::thread::sleep(Duration::from_millis(100));
}
self.started = Some(Instant::now());
self.active_settings = Some(s.clone());
self.error = None;
Ok(self.status())
}
}
impl Drop for Casting {
fn drop(&mut self) {
self.stop_workers();
}
}
pub fn dominant_eye() -> Result<usize> {
let home = crate::steam::home()?;
let custom = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".config"));
let files = [
custom.join("openvr/config/steamvr.vrsettings"),
home.join(".local/share/Steam/config/steamvr.vrsettings"),
PathBuf::from("/opt/steamvr/resources/settings/default.vrsettings"),
];
for path in files {
let Ok(bytes) = fs::read(path) else {
continue;
};
let Ok(value) = serde_json::from_slice::<Value>(&bytes) else {
continue;
};
if let Some(eye) = value["steamvr"]["dominantEye"].as_u64().filter(|v| *v <= 1) {
return Ok(eye as usize);
}
}
bail!("未找到 SteamVR 主力眼设置")
}
pub fn binary_directory(assets: &Path) -> PathBuf {
let root = assets
.parent()
.and_then(Path::parent)
.unwrap_or(Path::new("."));
let installed = root.join("lib/media");
if installed.is_dir() {
installed
} else {
root.join("media/bin")
}
}
// Pulse defaults to large fragments. Small fragments and independent, bounded
// input queues prevent a blocking audio read from backing up live video.
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]);
}
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
// cleanup belongs to the owning runtime and the session's systemd cgroup.
command.process_group(0);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn media_worker_survives_the_http_request_thread() {
let mut child = std::thread::spawn(|| {
let mut command = Command::new("sleep");
command.arg("30");
bind_to_session(&mut command);
command.spawn().unwrap()
}).join().unwrap();
std::thread::sleep(Duration::from_millis(100));
let exited = child.try_wait().unwrap();
let _ = child.kill();
let _ = child.wait();
assert!(exited.is_none(), "media ended with its request thread: {exited:?}");
}
#[test]
#[ignore = "requires Steam Frame, FFmpeg and the packaged media workers"]
fn steamvr_stream_has_requested_output_and_stops_all_workers() {
let directory = tempfile::tempdir().unwrap();
let binaries = PathBuf::from(std::env::var("FRAMELY_MEDIA_BIN").unwrap());
let mut cast = Casting::new(directory.path().to_owned(), binaries);
let settings = Settings {
source: Source::SteamVR,
width: 640,
height: 360,
fps: 30,
system_audio: true,
..Settings::default()
};
cast.start(&settings, None).unwrap_or_else(|e| {
panic!(
"{e:#}\n{}",
fs::read_to_string(directory.path().join("stream.log")).unwrap_or_default()
)
});
let address = format!("rtsp://127.0.0.1:{}/headset", cast.rtsp_port);
let output = Command::new("ffprobe")
.args([
"-v",
"error",
"-rtsp_transport",
"tcp",
"-show_entries",
"stream=codec_name,width,height,r_frame_rate",
"-of",
"json",
&address,
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let streams: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(streams["streams"][0]["width"], 640);
assert_eq!(streams["streams"][0]["height"], 360);
assert_eq!(streams["streams"][0]["codec_name"], "h264");
assert!(streams["streams"]
.as_array()
.unwrap()
.iter()
.any(|v| v["codec_name"] == "opus"));
cast.ensure_dlna().unwrap();
let end = Instant::now() + Duration::from_secs(10);
while ureq::get(&format!(
"http://127.0.0.1:{}/dlna/index.m3u8",
cast.hls_port
))
.timeout(Duration::from_millis(500))
.call()
.is_err()
{
assert!(
Instant::now() < end,
"{}",
fs::read_to_string(directory.path().join("stream.log")).unwrap_or_default()
);
}
let rtsp = cast.rtsp_port;
cast.stop();
assert!(std::net::TcpStream::connect(("127.0.0.1", rtsp)).is_err());
}
}
+147
View File
@@ -0,0 +1,147 @@
use anyhow::{ensure, Result};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum Source {
#[default]
Screen,
SteamVR,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum Output {
#[default]
Eye,
Raw,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum Eye {
#[default]
SteamVR,
Left,
Right,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Codec {
#[default]
H264,
H265,
}
impl Codec {
pub fn name(self) -> &'static str { match self { Self::H264 => "h264", Self::H265 => "h265" } }
pub fn rtp(self) -> &'static str { match self { Self::H264 => "H264/90000", Self::H265 => "H265/90000" } }
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
pub struct Settings {
pub source: Source,
pub codec: Codec,
pub output: Output,
pub eye: Eye,
pub width: u32,
pub height: u32,
pub fps: u32,
pub bitrate_mbps: u32,
/// None selects the widest fully covered field of view for this aspect.
pub horizontal_fov: Option<f64>,
pub center_x: f64,
pub center_y: f64,
pub system_audio: bool,
pub microphone: bool,
pub airplay: bool,
pub dlna: bool,
pub receiver_name: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Screen,
codec: Codec::H264,
output: Output::Eye,
eye: Eye::SteamVR,
width: 1920,
height: 1080,
fps: 30,
bitrate_mbps: 8,
horizontal_fov: None,
center_x: 0.,
center_y: 0.,
system_audio: true,
microphone: false,
airplay: false,
dlna: false,
receiver_name: "Framely".into(),
}
}
}
impl Settings {
pub fn validate(&self) -> Result<()> {
ensure!(
(160..=4096).contains(&self.width)
&& self.width % 2 == 0
&& (160..=4096).contains(&self.height)
&& self.height % 2 == 0
&& u64::from(self.width) * u64::from(self.height) <= 8_847_360,
"输出宽高必须是 160–4096 之间的偶数,且不超过 8847360 像素"
);
ensure!((1..=120).contains(&self.fps), "输出帧率必须是 1–120 fps");
ensure!(
(1..=80).contains(&self.bitrate_mbps),
"视频码率必须是 1–80 Mbps"
);
ensure!(
self.horizontal_fov
.is_none_or(|v| v.is_finite() && (10.0..=150.0).contains(&v)),
"水平视野必须是 10–150 度"
);
ensure!(
[self.center_x, self.center_y]
.into_iter()
.all(|v| v.is_finite() && (-60.0..=60.0).contains(&v)),
"取景中心必须在 -60–60 度之间"
);
ensure!(
!self.receiver_name.trim().is_empty()
&& self.receiver_name.len() <= 80
&& !self.receiver_name.chars().any(char::is_control),
"接收名称必须是 1–80 字节且不能包含控制字符"
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
let hevc: Settings=serde_json::from_value(serde_json::json!({"codec":"h265"})).unwrap();
assert_eq!(hevc.codec,Codec::H265);
assert_eq!(serde_json::to_value(hevc).unwrap()["codec"],"h265");
assert!(serde_json::from_value::<Settings>(serde_json::json!({"codec":"av1"})).is_err());
}
#[test]
fn settings_reject_unsafe_media_sizes_and_invalid_ranges() {
let mut s = Settings::default();
s.validate().unwrap();
s.width = 4096;
s.height = 4096;
assert!(s.validate().is_err());
s = Settings::default();
s.horizontal_fov = Some(f64::NAN);
assert!(s.validate().is_err());
s = Settings::default();
s.receiver_name = "Framely\nInjected".into();
assert!(s.validate().is_err());
assert!(
serde_json::from_value::<Settings>(serde_json::json!({"source":"automatic"})).is_err()
);
}
}
+19 -3
View File
@@ -12,6 +12,8 @@ use std::{
};
#[derive(Clone)]
pub struct Mount {
pub direct: bool,
pub source: String,
pub context: String,
pub token: String,
pub event: PathBuf,
@@ -143,7 +145,7 @@ pub fn prepare(
threshold: u8,
) -> Result<Mount> {
ensure!(
matches!(source, "steam" | "frame"),
matches!(source, "steam" | "frame" | "steam-direct"),
"Invalid gamepad input source"
);
ensure!(SESSION.load(std::sync::atomic::Ordering::Relaxed),"Gamepad launch requires the UI session; open the APK from the management panel or launcher");
@@ -168,6 +170,10 @@ pub fn prepare(
}
}
if let Some(mount) = current {
ensure!(
mount.source == source,
"Controller source changed; close and restart this container"
);
return Ok(mount);
}
let exe = std::env::current_exe()?;
@@ -178,7 +184,12 @@ pub fn prepare(
let helper = distribution.join("lib/openvr/framely-gamepad");
let grab = distribution.join("lib/openvr/libframely-gamepad-grab.so");
let manifest = distribution.join("share/input/gamepad/actions.json");
let layout = distribution.join("share/input/gamepad/Vendor_0001_Product_f001.kl");
let direct = source == "steam-direct";
let layout = distribution.join(if direct {
"share/input/gamepad/Vendor_28de_Product_11ff.kl"
} else {
"share/input/gamepad/Vendor_0001_Product_f001.kl"
});
ensure!(
helper.is_file() && grab.is_file() && manifest.is_file() && layout.is_file(),
"Gamepad input runtime is missing; install a complete Framely build"
@@ -211,6 +222,7 @@ pub fn prepare(
};
let mut child = Command::new(helper)
.env("FRAMELY_GAMEPAD_SOURCE", source)
.env("FRAMELY_GAMEPAD_READY", &ready)
.env("FRAMELY_GAMEPAD_TRIGGER_THRESHOLD", threshold.to_string())
.env("FRAMELY_GAMEPAD_RUMBLE", if rumble { "1" } else { "0" })
.env(
@@ -246,6 +258,8 @@ pub fn prepare(
.map(|s| PathBuf::from(s.trim()));
let mut bridge = Bridge {
mount: Mount {
direct,
source: source.into(),
context: context.into(),
token,
event: PathBuf::new(),
@@ -325,7 +339,7 @@ pub fn activate(context: &str, package: &str) -> Result<()> {
}
ensure!(
claimed(&mount.ready),
"Android did not claim the virtual gamepad; restart the container and retry"
"Android did not register the gamepad; restart the container and retry"
);
select_target(context, package)
}
@@ -384,6 +398,8 @@ mod tests {
let id = child.id();
let mut bridge = Bridge {
mount: Mount {
direct: false,
source: "steam".into(),
context: "test".into(),
token: "same-device".into(),
event: PathBuf::from("/dev/input/event999"),
+3
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
+2
View File
@@ -1,6 +1,7 @@
mod apk;
mod apk_metadata;
mod auth;
mod cast;
mod desktop;
mod diagnostics;
mod file_browser;
@@ -20,6 +21,7 @@ mod relations;
mod service;
mod session;
mod steam;
mod storage_location;
mod subscriptions;
mod terminal;
#[cfg(test)]
+150 -31
View File
@@ -1,4 +1,5 @@
//! Filesystem operations run exclusively inside the unprivileged session service.
mod upload_paths;
use crate::jobs::Cancellation;
use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize};
@@ -73,9 +74,14 @@ use std::os::unix::fs::OpenOptionsExt;
struct ViewPreferences {
#[serde(default = "default_file_view")]
view: String,
#[serde(default = "default_upload_concurrency")]
upload_concurrency: u8,
#[serde(default)]
folders: BTreeMap<String, FolderSort>,
}
fn default_upload_concurrency() -> u8 {
3
}
fn default_file_view() -> String {
"list".into()
}
@@ -91,6 +97,7 @@ fn view_preferences(home: &Path, p: &Value) -> Result<Value> {
} else {
ViewPreferences {
view: default_file_view(),
upload_concurrency: default_upload_concurrency(),
..Default::default()
}
};
@@ -129,6 +136,15 @@ fn view_preferences(home: &Path, p: &Value) -> Result<Value> {
);
changed = true;
}
if let Some(n) = p.get("uploadConcurrency") {
let n = n.as_u64().context("Invalid upload concurrency")?;
ensure!(
(1..=8).contains(&n),
"Upload concurrency must be between 1 and 8"
);
preferences.upload_concurrency = n as u8;
changed = true;
}
let value = serde_json::to_value(preferences)?;
if changed {
save_json(&file, &value)?;
@@ -142,6 +158,7 @@ pub struct Files {
downloads: Mutex<BTreeMap<String, Download>>,
}
struct Upload {
anchor: upload_paths::Anchor,
temp: PathBuf,
target: PathBuf,
size: u64,
@@ -153,7 +170,9 @@ struct Upload {
}
impl Drop for Upload {
fn drop(&mut self) {
let _ = fs::remove_file(&self.temp);
if let Some(leaf) = self.temp.file_name() {
self.anchor.unlink(Path::new(leaf));
}
}
}
struct Download {
@@ -661,31 +680,49 @@ impl Files {
if downloads.get(id).is_some_and(|d| d.inline && !d.owned) { downloads.remove(id); }
Ok(json!(true))
},
"upload.start" => {
let parent = PathBuf::from(strparam(p,"directory")?).canonicalize()?;
let relative = Path::new(strparam(p,"name")?);
ensure!(!relative.is_absolute()&&relative.components().all(|c|matches!(c, Component::Normal(_))),"Invalid upload path");
let target = parent.join(relative);
let folder = target.parent().context("Missing upload directory")?;
let mut ancestor = folder;
while !ancestor.exists(){
ancestor = ancestor.parent().context("Invalid upload parent")?;
"upload.preflight" => {
let anchor=upload_paths::Anchor::root(p)?;
let identity=anchor.identity()?;
let items=p["items"].as_array().context("Missing upload items")?;
ensure!(items.len()<=256,"Too many upload preflight items");
let mut result=Vec::new();
for item in items {
let name=strparam(item,"name")?;
let relative=upload_paths::relative(name)?;
let directory=item["directory"]==true;
let check=(|| -> Result<bool> {
let root=upload_paths::Anchor::root(p)?;
let parent=root.descend(relative.parent().unwrap_or(Path::new("")),false)?;
let leaf=relative.file_name().context("Missing upload filename")?;
if directory { if let Some(st)=parent.metadata(Path::new(leaf))? {ensure!(st.st_mode&libc::S_IFMT==libc::S_IFDIR,"Upload directory conflicts with a file or symbolic link");Ok(true)} else {Ok(false)} }
else {parent.file_conflict(Path::new(leaf))}
})();
let missing=check.as_ref().err().is_some_and(|e|e.chain().any(|cause|cause.downcast_ref::<std::io::Error>().is_some_and(|e|e.kind()==std::io::ErrorKind::NotFound)));
result.push(match check {Ok(exists)=>json!({"name":name,"exists":exists}),Err(_) if missing=>json!({"name":name,"exists":false}),Err(e)=>json!({"name":name,"error":format!("{e:#}")})});
}
ensure!(ancestor.canonicalize()?.starts_with(&parent),"Upload path escapes its destination");
fs::create_dir_all(folder)?;
ensure!(folder.canonicalize()?.starts_with(&parent),"Upload path escapes its destination");
ensure!(self.uploads.lock().unwrap().len()<64,"Too many simultaneous uploads");
let size = p["size"].as_u64().context("Missing upload size")?;
Ok(json!({"directory":anchor.root,"rootIdentity":identity,"items":result}))
},
"upload.directory" => {
let relative=upload_paths::relative(strparam(p,"name")?)?;
let anchor=upload_paths::Anchor::root(p)?.descend(relative,true)?;
Ok(json!({"status":"done","path":anchor.directory}))
},
"upload.start" => {
let relative=upload_paths::relative(strparam(p,"name")?)?;
let policy=p["conflict"].as_str().unwrap_or("error");
ensure!(matches!(policy,"skip"|"keep"|"overwrite"|"error"),"Invalid upload conflict policy");
let size=p["size"].as_u64().context("Missing upload size")?;
ensure!(size<=64*1024*1024*1024,"Upload too large");
let id = token();
let temp = folder.join(format!(".framely-upload-{id}"));
let file = fs::OpenOptions::new().read(true).write(true).create_new(true).mode(0o600).open(&temp)?;
self.uploads.lock().unwrap().insert(id.clone(), Upload{
temp, target, size, received:0, file, ranges:BTreeMap::new(), policy:p["conflict"].as_str().unwrap_or("error").into(), created:now()
});
Ok(json!({
"id":id,"chunkSize":crate::uploads::CHUNK
}))
let anchor=upload_paths::Anchor::root(p)?.descend(relative.parent().unwrap_or(Path::new("")),true)?;
let leaf=Path::new(relative.file_name().context("Missing upload filename")?);
if anchor.file_conflict(leaf)?&&policy=="skip" {return Ok(json!({"status":"skipped","path":anchor.directory.join(leaf)}));}
let mut uploads=self.uploads.lock().unwrap();
ensure!(uploads.len()<64,"Too many simultaneous uploads");
let id=token();let temp=anchor.directory.join(format!(".framely-upload-{id}"));
let file=anchor.temporary(Path::new(temp.file_name().unwrap()))?;
let target=anchor.directory.join(leaf);
uploads.insert(id.clone(),Upload{anchor,temp,target,size,received:0,file,ranges:BTreeMap::new(),policy:policy.into(),created:now()});
Ok(json!({"id":id,"chunkSize":crate::uploads::CHUNK,"status":"uploading"}))
},
"upload.cancel" => {
self.uploads.lock().unwrap().remove(strparam(p,"id")?);
@@ -697,13 +734,10 @@ impl Files {
let u = uploads.get(id).context("Upload expired")?;
ensure!(u.size==u.received,"Upload is incomplete");
u.file.sync_all()?;
let dest = target(&u.target, &u.policy, p["approve"]==true)?;
if let Some(dest) = dest{
ensure!(!fs::symlink_metadata(&dest).is_ok_and(|m|m.is_dir()),"Cannot overwrite a directory with a file");
fs::rename(&u.temp, dest)?;
}
let dest=u.anchor.finish(Path::new(u.temp.file_name().unwrap()),Path::new(u.target.file_name().unwrap()),&u.policy,p["approve"]==true)?;
let result=json!({"status":if dest.is_some(){"done"}else{"skipped"},"path":dest.unwrap_or_else(||u.target.clone())});
uploads.remove(id);
Ok(json!(true))
Ok(result)
},
_ => bail!("Unknown file operation")
}
@@ -1554,6 +1588,88 @@ mod operation_tests {
assert!(unrelated.exists());
}
#[test]
fn upload_preflight_empty_folders_conflicts_and_replacement_safety() {
let home = tempfile::tempdir().unwrap();
let root = home.path();
let manager = Files::default();
fs::write(root.join("same"), "old").unwrap();
let review=manager.api(root,&json!({"operation":"upload.preflight","directory":root,"items":[{"name":"folder/empty","directory":true},{"name":"same","directory":false}]})).unwrap();
assert!(!root.join("folder").exists());
assert_eq!(review["items"][1]["exists"], true);
let base = json!({"directory":root,"rootIdentity":review["rootIdentity"]});
let mut p = base.clone();
p["operation"] = json!("upload.directory");
p["name"] = json!("folder/empty");
manager.api(root, &p).unwrap();
assert!(root.join("folder/empty").is_dir());
p["operation"] = json!("upload.start");
p["name"] = json!("same");
p["size"] = json!(0);
p["conflict"] = json!("skip");
assert_eq!(manager.api(root, &p).unwrap()["status"], "skipped");
assert!(manager.uploads.lock().unwrap().is_empty());
p["name"] = json!("folder");
assert!(manager.api(root, &p).is_err());
p["name"] = json!("race");
p["conflict"] = json!("keep");
let one = manager.api(root, &p).unwrap();
let two = manager.api(root, &p).unwrap();
let first = manager
.api(root, &json!({"operation":"upload.finish","id":one["id"]}))
.unwrap();
let second = manager
.api(root, &json!({"operation":"upload.finish","id":two["id"]}))
.unwrap();
assert_ne!(first["path"], second["path"]);
p["name"] = json!("folder/moved");
let start = manager.api(root, &p).unwrap();
fs::rename(root.join("folder"), root.join("moved-folder")).unwrap();
fs::create_dir(root.join("folder")).unwrap();
assert!(manager
.api(root, &json!({"operation":"upload.finish","id":start["id"]}))
.is_err());
manager
.api(root, &json!({"operation":"upload.cancel","id":start["id"]}))
.unwrap();
assert!(!root.join("folder/moved").exists());
assert!(!fs::read_dir(root.join("moved-folder")).unwrap().any(|e| e
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".framely-upload-")));
let outside = tempfile::tempdir().unwrap();
symlink(outside.path(), root.join("escape")).unwrap();
p["name"] = json!("escape/file");
assert!(manager.api(root, &p).is_err());
assert!(!outside.path().join("file").exists());
for n in [0, 9] {
assert!(manager
.api(
root,
&json!({"operation":"view.preferences","uploadConcurrency":n})
)
.is_err());
}
assert_eq!(
manager
.api(root, &json!({"operation":"view.preferences"}))
.unwrap()["uploadConcurrency"],
3
);
manager
.api(
root,
&json!({"operation":"view.preferences","uploadConcurrency":8}),
)
.unwrap();
assert_eq!(
manager
.api(root, &json!({"operation":"view.preferences"}))
.unwrap()["uploadConcurrency"],
8
);
}
#[test]
fn upload_is_atomic_and_offsets_are_checked() {
if unsafe { libc::geteuid() } == 0 {
return;
@@ -1696,7 +1812,10 @@ mod operation_tests {
let defaults = manager
.api(home.path(), &json!({"operation":"view.preferences"}))
.unwrap();
assert_eq!(defaults, json!({"view":"list","folders":{}}));
assert_eq!(
defaults,
json!({"view":"list","folders":{},"uploadConcurrency":3})
);
manager
.api(
home.path(),
+238
View File
@@ -0,0 +1,238 @@
//! Descriptor-relative upload writes: never follow nested destination links.
use super::*;
use std::{
ffi::CString,
os::{
fd::{AsRawFd, FromRawFd},
unix::ffi::OsStrExt,
},
};
fn name(p: &Path) -> Result<CString> {
Ok(CString::new(p.as_os_str().as_bytes())?)
}
fn open_directory(fd: i32, part: &Path) -> Result<fs::File> {
let part = name(part)?;
let next = unsafe {
libc::openat(
fd,
part.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
if next < 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(unsafe { fs::File::from_raw_fd(next) })
}
pub(super) fn relative(value: &str) -> Result<&Path> {
let p = Path::new(value);
ensure!(
!value.is_empty()
&& !p.is_absolute()
&& p.components().all(|c| matches!(c, Component::Normal(_))),
"Invalid upload path"
);
for c in p.components() {
valid_name(c.as_os_str().to_str().context("Invalid upload filename")?)?;
}
Ok(p)
}
pub(super) struct Anchor {
pub root: PathBuf,
pub directory: PathBuf,
root_fd: fs::File,
pub fd: fs::File,
}
impl Anchor {
pub fn root(p: &Value) -> Result<Self> {
let requested = PathBuf::from(strparam(p, "directory")?);
ensure!(requested.is_absolute(), "Use an absolute file path");
let root = requested.canonicalize()?;
let fd = open_directory(libc::AT_FDCWD, &root)?;
let m = fd.metadata()?;
if let Some(expected) = p.get("rootIdentity") {
ensure!(
expected["dev"].as_u64() == Some(m.dev())
&& expected["ino"].as_u64() == Some(m.ino()),
"Upload destination changed; confirm its path again"
);
}
Ok(Self {
directory: root.clone(),
root,
root_fd: fd.try_clone()?,
fd,
})
}
pub fn identity(&self) -> Result<Value> {
let m = self.root_fd.metadata()?;
Ok(json!({"dev":m.dev(),"ino":m.ino()}))
}
pub fn descend(mut self, relative: &Path, create: bool) -> Result<Self> {
for c in relative.components() {
let part = Path::new(c.as_os_str());
match open_directory(self.fd.as_raw_fd(), part) {
Ok(next) => self.fd = next,
Err(e)
if create
&& e.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
{
let n = name(part)?;
let r = unsafe { libc::mkdirat(self.fd.as_raw_fd(), n.as_ptr(), 0o755) };
if r < 0
&& std::io::Error::last_os_error().kind()
!= std::io::ErrorKind::AlreadyExists
{
return Err(std::io::Error::last_os_error().into());
}
self.fd = open_directory(self.fd.as_raw_fd(), part)?;
}
Err(e) => {
return Err(e.context("Upload directory is unavailable or is a symbolic link"))
}
}
self.directory.push(part);
}
self.check()?;
Ok(self)
}
pub fn check(&self) -> Result<()> {
let root = open_directory(libc::AT_FDCWD, &self.root)?;
let a = root.metadata()?;
let b = self.root_fd.metadata()?;
ensure!(
a.dev() == b.dev() && a.ino() == b.ino(),
"Upload destination moved or changed"
);
let mut fd = root;
for c in self.directory.strip_prefix(&self.root)?.components() {
fd = open_directory(fd.as_raw_fd(), Path::new(c.as_os_str()))?;
}
let a = fd.metadata()?;
let b = self.fd.metadata()?;
ensure!(
a.dev() == b.dev() && a.ino() == b.ino(),
"Upload destination moved or changed"
);
Ok(())
}
pub fn metadata(&self, leaf: &Path) -> Result<Option<libc::stat>> {
let mut st = std::mem::MaybeUninit::uninit();
let leaf = name(leaf)?;
let r = unsafe {
libc::fstatat(
self.fd.as_raw_fd(),
leaf.as_ptr(),
st.as_mut_ptr(),
libc::AT_SYMLINK_NOFOLLOW,
)
};
if r == 0 {
return Ok(Some(unsafe { st.assume_init() }));
}
let e = std::io::Error::last_os_error();
if e.kind() == std::io::ErrorKind::NotFound {
Ok(None)
} else {
Err(e.into())
}
}
pub fn file_conflict(&self, leaf: &Path) -> Result<bool> {
if let Some(st) = self.metadata(leaf)? {
ensure!(
matches!(st.st_mode & libc::S_IFMT, libc::S_IFREG | libc::S_IFLNK),
"Upload file conflicts with a directory or special file"
);
Ok(true)
} else {
Ok(false)
}
}
pub fn temporary(&self, leaf: &Path) -> Result<fs::File> {
let leaf = name(leaf)?;
let fd = unsafe {
libc::openat(
self.fd.as_raw_fd(),
leaf.as_ptr(),
libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
0o600,
)
};
if fd < 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(unsafe { fs::File::from_raw_fd(fd) })
}
pub fn unlink(&self, leaf: &Path) {
if let Ok(n) = name(leaf) {
unsafe {
libc::unlinkat(self.fd.as_raw_fd(), n.as_ptr(), 0);
}
}
}
pub fn finish(
&self,
temp: &Path,
leaf: &Path,
policy: &str,
approve: bool,
) -> Result<Option<PathBuf>> {
self.check()?;
if policy == "overwrite" {
ensure!(approve, "Confirm overwriting files");
}
for i in 0..10000 {
let candidate = if i == 0 {
leaf.to_path_buf()
} else {
let stem = leaf.file_stem().unwrap_or_default().to_string_lossy();
let ext = leaf
.extension()
.map(|v| format!(".{}", v.to_string_lossy()))
.unwrap_or_default();
PathBuf::from(format!("{stem} ({i}){ext}"))
};
let exists = self.file_conflict(&candidate)?;
if exists && policy == "skip" {
return Ok(None);
}
if exists && policy == "keep" {
continue;
}
ensure!(
!exists || policy == "overwrite",
"A file with that name already exists"
);
let src = name(temp)?;
let dest = name(&candidate)?;
let r = unsafe {
libc::renameat2(
self.fd.as_raw_fd(),
src.as_ptr(),
self.fd.as_raw_fd(),
dest.as_ptr(),
if policy == "overwrite" {
0
} else {
libc::RENAME_NOREPLACE
},
)
};
if r == 0 {
return Ok(Some(self.directory.join(candidate)));
}
let e = std::io::Error::last_os_error();
if e.kind() == std::io::ErrorKind::AlreadyExists {
if policy == "skip" {
return Ok(None);
}
if policy == "keep" {
continue;
}
}
return Err(e.into());
}
bail!("Too many filename conflicts")
}
}
+5 -1
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;
@@ -814,6 +814,10 @@ 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")]
pub language: String,
#[serde(default)]
+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")?;
+350 -31
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,8 +110,16 @@ 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()?;
for p in db.plugins.values_mut() {
if let Some(e) = &p.manifest.engines {
if let Err(error) = e.check() {
@@ -123,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())?,
)?;
@@ -143,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")
@@ -202,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(),
@@ -239,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")
@@ -287,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,
@@ -314,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),
);
@@ -375,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(
@@ -633,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();
@@ -735,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!(
@@ -765,6 +793,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())? {
@@ -1006,9 +1046,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)?;
}
@@ -1080,6 +1120,48 @@ impl Service {
self.save()?;
Ok(json!(true))
}
"cast.settings.save" => {
let settings: crate::cast::settings::Settings = serde_json::from_value(p)?;
settings.validate()?;
self.db.casting = settings;
self.save()?;
Ok(json!(true))
}
"cast.panel.start" => {
let executable = self.root.join("current/bin/framely-panel-grab");
ensure!(executable.is_file(), "缺少内置屏幕采集助手");
let directory = self.root.join("cast-runtime");
fs::create_dir_all(&directory)?;
fs::set_permissions(&directory, fs::Permissions::from_mode(0o755))?;
let path = directory.join(format!("panel-{}.sock", &crate::session::cast_random_key()[..16]));
let listener = UnixListener::bind(&path)?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
let name = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?;
ensure!(unsafe { libc::chown(name.as_ptr(), self.manager, u32::MAX) } == 0, "无法设置采集助手连接身份");
listener.set_nonblocking(true)?;
let manager = self.manager;
let cleanup = path.clone();
std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
match listener.accept() {
Ok((stream, _)) => {
let mut credentials: libc::ucred = unsafe { std::mem::zeroed() };
let mut length = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
let valid = unsafe { libc::getsockopt(stream.as_raw_fd(), libc::SOL_SOCKET, libc::SO_PEERCRED, (&mut credentials as *mut libc::ucred).cast(), &mut length) } == 0 && credentials.uid == manager;
if !valid { continue; }
let _ = fs::remove_file(&cleanup);
let _ = Command::new(&executable).stdin(std::process::Stdio::from(std::os::fd::OwnedFd::from(stream))).stdout(std::process::Stdio::null()).status();
break;
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => std::thread::sleep(Duration::from_millis(20)),
Err(_) => break,
}
}
let _ = fs::remove_file(cleanup);
});
Ok(json!({"socket":path}))
}
"launcher.order.save" => {
let order: Vec<String> = serde_json::from_value(p["order"].clone())?;
ensure!(order.len() <= 4096, "Too many launcher entries");
@@ -1345,6 +1427,14 @@ impl Service {
}
Ok(json!(true))
}
"cast.notification" => {
ensure!(id.is_empty(), "Core notification only");
let notification: Notification=serde_json::from_value(p["notification"].clone())?;
notification.validate()?;
ensure!(notification.id.starts_with("cast."), "Invalid cast notification");
self.insert_notification("", "Framely", notification, None)?;
Ok(json!(true))
},
"notification.send" => self.notify(&id, p["notification"].clone()),
"notification.remove" | "notification.dismiss" => {
if !id.is_empty() {
@@ -1752,7 +1842,9 @@ impl Service {
.iter()
.find(|a| a.id == action)
.context("Action not declared")?;
let result = if let Some(update) = &entry.update {
let result = if id.is_empty() && key.starts_with("cast.") {
json!(true)
} else if let Some(update) = &entry.update {
ensure!(
self.db.update_source.as_ref() == Some(&update.source)
&& self.db.update_channel == update.channel,
@@ -1971,35 +2063,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 {
@@ -2013,6 +2111,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);
}
@@ -2047,6 +2149,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(
@@ -2072,6 +2178,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(
@@ -2093,6 +2203,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<()> {
@@ -2308,7 +2421,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 {
@@ -2318,7 +2433,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(
@@ -2349,6 +2466,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
@@ -2370,6 +2551,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();
@@ -2525,6 +2805,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 {
+341 -19
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,
@@ -35,6 +36,8 @@ struct Agent {
terminals: crate::terminal::Terminals,
files: Arc<crate::manager_files::Files>,
catalog_cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
casting: Mutex<crate::cast::runtime::Casting>,
receivers: Mutex<crate::cast::receiver::Receivers>,
}
#[derive(Default)]
struct EventLog {
@@ -63,6 +66,7 @@ fn random_key() -> String {
rand::rngs::OsRng.fill_bytes(&mut b);
hex::encode(b)
}
pub(crate) fn cast_random_key() -> String { random_key() }
fn push(q: &Mutex<VecDeque<Value>>, v: Value) {
let mut q = q.lock().unwrap();
if q.len() >= 256 {
@@ -130,6 +134,16 @@ impl Agent {
} else {
self.origin.clone()
};
if let Some(resource)=path.strip_prefix("/cast/media/") {
ensure!(r.method()==&Method::Get||r.method()==&Method::Head,"Invalid media request");
let (key,file)=resource.split_once('/').context("Invalid media resource")?;
let port={let mut cast=self.casting.lock().unwrap();ensure!(!cast.stream_key.is_empty()&&key==cast.stream_key&&cast.status()["running"]==true,"Media session expired");cast.hls_port};
ensure!(file.len()<128&&file.bytes().all(|b|b.is_ascii_alphanumeric()||b==b'.'||b==b'_'||b==b'-')&&(file.ends_with(".m3u8")||file.ends_with(".ts")),"Invalid media resource");
let target=format!("http://127.0.0.1:{port}/dlna/{file}");
let response=match ureq::AgentBuilder::new().timeout(Duration::from_secs(15)).redirects(0).build().request(r.method().as_str(),&target).call(){Ok(v)=>v,Err(ureq::Error::Status(_,v))=>v,Err(e)=>return Err(e.into())};
let code=response.status();let mime=response.header("Content-Type").unwrap_or("application/octet-stream").to_owned();
r.respond(Response::new(StatusCode(code),vec![header("Content-Type",&mime),header("Cache-Control","no-store"),header("X-Content-Type-Options","nosniff")],response.into_reader(),None,None))?;return Ok(());
}
if remote && (path.starts_with("/boot/") || path.starts_with("/host/")) {
r.respond(Response::empty(StatusCode(403)))?;
return Ok(());
@@ -167,10 +181,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")
@@ -224,11 +238,11 @@ impl Agent {
if configured
&& !protected
&& r.method() == &Method::Get
&& (path == "/" || path == "/manager")
&& (path == "/" || path == "/manager" || path == "/cast/watch")
{
r.respond(
Response::empty(StatusCode(302))
.with_header(header("Location", "/manager"))
.with_header(header("Location", if path == "/cast/watch" { "/cast/watch" } else { "/manager" }))
.with_header(header(
"Set-Cookie",
&format!(
@@ -284,6 +298,7 @@ impl Agent {
let mut body = String::new();
r.as_reader().take(8193).read_to_string(&mut body)?;
ensure!(body.len() <= 8192, "Login request too large");
let destination = url::form_urlencoded::parse(body.as_bytes()).any(|(k,v)| k == "next" && v == "/cast/watch");
let code = url::form_urlencoded::parse(body.as_bytes())
.find(|(k, _)| k == "password")
.map(|(_, v)| v.into_owned());
@@ -310,7 +325,7 @@ impl Agent {
if verified && key_before == *self.network_key.lock().unwrap() {
r.respond(
Response::empty(StatusCode(302))
.with_header(header("Location", "/manager"))
.with_header(header("Location", if destination { "/cast/watch" } else { "/manager" }))
.with_header(header(
"Set-Cookie",
&format!(
@@ -328,8 +343,8 @@ impl Agent {
}
return Ok(());
}
if r.method() == &Method::Get && (path == "/" || path == "/manager") {
r.respond(Response::from_string(if configured { NETWORK_LOGIN } else { NETWORK_SETUP }).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'")))?;
if r.method() == &Method::Get && (path == "/" || path == "/manager" || path == "/cast/watch") {
r.respond(Response::from_string(if configured { if path == "/cast/watch" { NETWORK_LOGIN.replace("<form method=\"post\" action=\"/login\">", "<form method=\"post\" action=\"/login\"><input type=\"hidden\" name=\"next\" value=\"/cast/watch\">") } else { NETWORK_LOGIN.into() } } else { NETWORK_SETUP.into() }).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'")))?;
} else {
r.respond(
Response::empty(StatusCode(401))
@@ -351,7 +366,10 @@ 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)?;
if views.is_object() {
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!({}));}}}
} else if views.is_object() {
self.core("host.ui.visibility", views)?;
}
}
@@ -393,8 +411,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")
@@ -772,6 +790,50 @@ impl Agent {
},
);
}
if path == "/cast/whep" || path.starts_with("/cast/whep/") {
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();
let mut body = Vec::new();
r.as_reader().take(65537).read_to_end(&mut body)?;
ensure!(body.len() <= 65536, "SDP too large");
let (codec,resource)=if suffix.is_empty() {
ensure!(r.method()==&Method::Post||r.method()==&Method::Options,"Missing playback session");
let offer=std::str::from_utf8(&body)?;
let supports=|codec:&str|offer.lines().any(|line|line.starts_with("a=rtpmap:")&&line.split_whitespace().nth(1).is_some_and(|v|v.eq_ignore_ascii_case(codec)));
let selected=self.casting.lock().unwrap().codec();
if supports(selected.rtp()) {(selected.name(),"")} else {ensure!(supports("VP8/90000"),"浏览器没有可用的视频编码");("vp8","")}
} else {
let (codec,resource)=suffix.strip_prefix('/').and_then(|s|s.split_once('/')).context("Invalid playback session")?;
ensure!(matches!(codec,"h264"|"h265"|"vp8")&&resource.len()==36&&resource.bytes().all(|b|b.is_ascii_hexdigit()||b==b'-'),"Invalid playback session");
(codec,resource)
};
let port = { let mut cast=self.casting.lock().unwrap();ensure!(cast.status()["running"]==true,"串流尚未开始");if codec=="vp8"&&suffix.is_empty(){cast.ensure_vp8()?;}cast.whep_port };
let media_path=if codec=="vp8"{"headset_vp8"}else{"headset"};
let target=format!("http://127.0.0.1:{port}/{media_path}/whep{}",if resource.is_empty(){String::new()}else{format!("/{resource}")});
let mut request = ureq::AgentBuilder::new().redirects(0).timeout(Duration::from_secs(10)).build().request(r.method().as_str(), &target);
for name in ["Content-Type", "If-Match"] {
if let Some(value) = r.headers().iter().find(|h| h.field.equiv(name)) { request = request.set(name, value.value.as_str()); }
}
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")];
for name in ["Content-Type", "ETag", "Accept-Patch"] {
if let Some(value) = response.header(name) { headers.push(header(name,value)); }
}
if let Some(location) = response.header("Location") {
let parsed = url::Url::parse(&target)?.join(location)?;
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())));
}
let mut bytes = Vec::new(); response.into_reader().take(65537).read_to_end(&mut bytes)?;
ensure!(bytes.len() <= 65536, "Media response too large");
r.respond(Response::new(StatusCode(code), headers, std::io::Cursor::new(bytes), None, None))?;
return Ok(());
}
if path == "/api" && r.method() == &Method::Post {
let origin = r
.headers()
@@ -791,6 +853,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 {
@@ -803,6 +869,9 @@ impl Agent {
return send_json(r, v);
}
if path == "/"
|| path == "/cast/watch"
|| path.starts_with("/cast/window/")
|| path == "/apk/permission"
|| path == "/manager"
|| path == "/notifications"
|| path == "/launcher"
@@ -827,6 +896,27 @@ impl Agent {
r.respond(Response::from_string("Not found").with_status_code(404))?;
Ok(())
}
fn maintain_cast(&self) {
self.casting.lock().unwrap().maintain();
let events=self.receivers.lock().unwrap().poll();
for event in events {
let session=&event["session"];
let Some(id)=session["id"].as_str() else {continue;};
match event["kind"].as_str().unwrap_or("") {
"cast.request" => {let _=self.core("cast.notification",json!({"notification":{"id":format!("cast.{id}"),"title":"收到投屏","body":format!("{} 请求投屏,是否打开?",session["protocol"].as_str().unwrap_or("设备")),"durationMs":60000,"inbox":true,"actions":[{"id":"open","label":"打开投屏"},{"id":"reject","label":"拒绝"}]}}));},
"cast.window" => {
let _=self.core("notification.remove",json!({"plugin":"","id":format!("cast.{id}")}));
let w=session["width"].as_u64().unwrap_or(0);let h=session["height"].as_u64().unwrap_or(0);
let video=session["mediaType"]=="video";
let width=if w>0 { w.min(1920) } else if video {1280} else {640};let height=if w>0&&h>0 {width*h/w}else if video {720}else{240};
let frame=self.receivers.lock().unwrap().directory.join(format!("{id}.frame"));
push(&self.commands,json!({"kind":"cast.window","id":id,"width":width,"height":height,"frame":frame}));
},
"cast.ended" => {push(&self.commands,json!({"kind":"cast.close","id":id}));let _=self.core("notification.remove",json!({"plugin":"","id":format!("cast.{id}")}));},
_=>{},
}
}
}
fn api(&self, v: Value) -> Result<Value> {
let method = v["method"].as_str().context("Missing method")?;
let p = v["params"].clone();
@@ -853,6 +943,76 @@ impl Agent {
);
}
match method {
"cast.status" => {
let mut value = self.casting.lock().unwrap().status();
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!([])};
Ok(value)
},
"cast.settings.save" => {
let settings: crate::cast::settings::Settings = serde_json::from_value(p.clone())?;
settings.validate()?;
let result = self.core(method,p)?;
self.receivers.lock().unwrap().configure(&settings)?;
Ok(result)
},
"cast.devices" => self.receivers.lock().unwrap().devices(),
"cast.send" => {
let network=self.network_status.lock().unwrap().clone();ensure!(network["enabled"]==true,"请先开启网络管理面板,以便设备获取投屏画面");
let id=p["id"].as_str().context("Missing device")?;
let devices=self.receivers.lock().unwrap().devices()?;
let host=devices.as_array().into_iter().flatten().find(|device|device["id"]==id).and_then(|d|d["localAddress"].as_str()).context("设备已离线,请重新查找")?.to_owned();
ensure!(host.parse::<std::net::Ipv4Addr>().is_ok(),"Invalid device interface");
let mut cast=self.casting.lock().unwrap();cast.ensure_dlna()?;
let uri=format!("http://{host}:{}/cast/media/{}/index.m3u8",network["port"],cast.stream_key);
self.receivers.lock().unwrap().send(p["id"].as_str().context("Missing device")?,&uri)
},
"cast.send.stop" => self.receivers.lock().unwrap().stop_send(p["id"].as_str().context("Missing device")?),
"cast.window" => self.receivers.lock().unwrap().window(p["id"].as_str().context("Missing session")?),
"cast.control" => self.receivers.lock().unwrap().control(p["id"].as_str().context("Missing session")?,p["action"].as_str().context("Missing action")?,p.clone()),
"notification.action" if p["plugin"] == "" && p["id"].as_str().is_some_and(|id|id.starts_with("cast.")) => {
let id=p["id"].as_str().unwrap().strip_prefix("cast.").unwrap();
let notices=self.core("status",json!({}))?;
ensure!(notices["notifications"].as_array().into_iter().flatten().chain(notices["inbox"].as_array().into_iter().flatten()).any(|n|n["plugin"]==""&&n["notification"]["id"]==p["id"]&&n["createdAt"]==p["createdAt"]),"投屏通知已过期或已改变");
let action=p["action"].as_str().context("Missing action")?;
self.receivers.lock().unwrap().control(id,if action=="open"{"accept"}else if action=="reject"{"reject"}else{anyhow::bail!("Invalid cast action")},json!({}))?;
self.core(method,p)
},
"cast.preview.start" => {
let settings: crate::cast::settings::Settings = serde_json::from_value(p.clone())?;
settings.validate()?;
ensure!(settings.source == crate::cast::settings::Source::Screen && settings.output == crate::cast::settings::Output::Eye, "当前采集方式不支持取景调整");
let response = self.core("cast.panel.start", json!({}))?;
let socket = PathBuf::from(response["socket"].as_str().context("Missing panel socket")?);
self.casting.lock().unwrap().start_preview(&settings, &socket)
},
"cast.preview.end" => {
let mut cast = self.casting.lock().unwrap();
if let Some(mut settings) = cast.end_preview()? {
if p["apply"] == true {
settings = serde_json::from_value(self.core("status",json!({}))?["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.start(&settings, panel.as_deref())?;
}
Ok(cast.status())
},
"cast.start" => {
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 };
self.casting.lock().unwrap().start(&settings, panel.as_deref())
}
"cast.stop" => Ok(self.casting.lock().unwrap().stop()),
"launcher.search.index" => {
let names: Vec<String> = serde_json::from_value(p["names"].clone())?;
ensure!(
@@ -881,6 +1041,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)
@@ -915,7 +1077,37 @@ 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 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(()) })
})
}
"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")?,
@@ -1160,6 +1352,10 @@ impl Agent {
| "launcher-settings"
| "apk"
| "apk-settings"
| "casting"
| "casting-picture"
| "casting-receive"
| "casting-devices"
)
)
{
@@ -1247,7 +1443,7 @@ impl Agent {
&& (matches!(
view,
"menu" | "launcher" | "framely.manager" | "notifications"
) || view.starts_with("framely.window.")),
) || view.starts_with("framely.window.") || view.starts_with("framely.cast.")),
"Invalid haptic view"
);
let feedback = p["feedback"].as_str().unwrap_or("hover");
@@ -1279,6 +1475,9 @@ impl Agent {
Ok(json!(true))
}
"status"
| "storage.plugin.status"
| "storage.plugin.start"
| "storage.plugin.job"
| "inspect"
| "install"
| "plugin.restart"
@@ -1699,6 +1898,8 @@ pub fn serve(
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))?;
let agent = Arc::new(Agent {
casting: Mutex::new(crate::cast::runtime::Casting::new(runtime.join("cast"), crate::cast::runtime::binary_directory(&assets))),
receivers: Mutex::new(crate::cast::receiver::Receivers::new(runtime.join("receive"), crate::cast::runtime::binary_directory(&assets))),
socket,
state,
assets,
@@ -1716,6 +1917,42 @@ pub fn serve(
files: Arc::default(),
catalog_cache: Arc::default(),
});
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);}}}
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() {
@@ -1768,6 +2005,11 @@ pub fn serve(
.contains(&e["kind"].as_str().unwrap_or(""))
{
let mut command = e.clone();
if command["kind"] == "notification.changed" {
if let Ok(status) = event_agent.core("status", json!({})) {
command["visible"] = json!(status["notifications"].as_array().is_some_and(|items|!items.is_empty()));
}
}
if let Some(path) = command["spec"]["iconPath"].as_str() {
if let Ok(bytes) = fs::read(path) {
if bytes.len() <= 1024 * 1024 {
@@ -1802,6 +2044,9 @@ pub fn serve(
"--disable-background-networking",
"--disable-component-update",
"--no-default-browser-check",
// Keep Chromium's fork-time stack guard compatible with the
// system libc and helper processes used on SteamOS.
"--change-stack-guard-on-fork=disable",
])
.env("FRAMELY_NATIVE_TOKEN", &native_key)
.env("FRAMELY_STEAM_ROOT", {
@@ -1831,31 +2076,42 @@ pub fn serve(
}
fn network_ipv4_addresses() -> Vec<String> {
let mut addresses = std::collections::BTreeSet::new();
let mut addresses = Vec::new();
unsafe {
let mut first: *mut libc::ifaddrs = std::ptr::null_mut();
if libc::getifaddrs(&mut first) != 0 {
return Vec::new();
}
if libc::getifaddrs(&mut first) != 0 { return Vec::new(); }
let mut current = first;
while !current.is_null() {
let interface = &*current;
// LAN addresses belong to broadcast interfaces, not VPN tunnels or loopback.
if !interface.ifa_addr.is_null()
&& interface.ifa_flags & libc::IFF_UP as u32 != 0
&& interface.ifa_flags & libc::IFF_LOOPBACK as u32 == 0
&& interface.ifa_flags & libc::IFF_RUNNING as u32 != 0
&& interface.ifa_flags & libc::IFF_BROADCAST as u32 != 0
&& interface.ifa_flags & (libc::IFF_LOOPBACK | libc::IFF_POINTOPOINT) as u32 == 0
&& (*interface.ifa_addr).sa_family as i32 == libc::AF_INET
{
let socket = &*(interface.ifa_addr as *const libc::sockaddr_in);
let ip = std::net::Ipv4Addr::from(socket.sin_addr.s_addr.to_ne_bytes());
if !ip.is_loopback() && !ip.is_unspecified() {
addresses.insert(ip.to_string());
if !ip.is_loopback() && !ip.is_unspecified() && !interface.ifa_name.is_null() {
let name=std::ffi::CStr::from_ptr(interface.ifa_name).to_string_lossy().into_owned();
addresses.push((name,ip.to_string()));
}
}
current = interface.ifa_next;
}
libc::freeifaddrs(first);
}
addresses.into_iter().collect()
// The main routing table identifies the Wi-Fi/Ethernet uplink even when a VPN
// policy routes ordinary connections through a tunnel.
let route=std::fs::read_to_string("/proc/net/route").unwrap_or_default();
let preferred=route.lines().skip(1).filter_map(|line| {
let fields:Vec<_>=line.split_whitespace().collect();
(fields.len()>6 && fields[1]=="00000000" && u32::from_str_radix(fields[3],16).unwrap_or(0)&1!=0)
.then(||(fields[6].parse::<u32>().unwrap_or(u32::MAX),fields[0]))
}).min_by_key(|(metric,_)|*metric).map(|(_,name)|name);
addresses.sort_by_key(|(name,ip)|(Some(name.as_str())!=preferred, name.clone(),ip.clone()));
addresses.into_iter().map(|(_,ip)|ip).collect()
}
const NETWORK_SETUP: &str = r#"<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Framely · 设置访问密码</title><link rel="icon" href="/assets/branding/framely-app-icon.svg" type="image/svg+xml"><link rel="alternate icon" href="/assets/branding/framely.ico"><link rel="apple-touch-icon" href="/assets/branding/framely-app-icon.png"><style>body{background:#17191c;color:#f0f1f3;font:18px system-ui;margin:0;display:grid;min-height:100vh;place-items:center}main{padding:32px;max-width:360px}input,button{box-sizing:border-box;width:100%;padding:14px;margin:12px 0;border:1px solid #59616d;border-radius:5px;background:#292c31;color:inherit;font:inherit}p{color:#a4a8b0}.brand{display:block;width:200px;height:48px;margin-bottom:24px;image-rendering:pixelated}</style><main><img class="brand" src="/assets/branding/framely-logo-light.svg" alt="Framely"><h1>设置访问密码</h1><p>首次访问网络管理面板,请设置至少 8 个字符的密码。保存后使用该密码登录。</p><form method="post" action="/setup"><label>访问密码<input name="password" type="password" required autocomplete="new-password" minlength="8" maxlength="512"></label><label>确认密码<input name="confirmPassword" type="password" required autocomplete="new-password" minlength="8" maxlength="512"></label><button>保存访问密码</button></form></main></html>"#;
@@ -1932,6 +2188,70 @@ fn wait_for_steam_session() {
}
#[cfg(test)]
mod tests {
#[test]
#[ignore = "manual browser and Steam Frame integration fixture"]
fn casting_browser_fixture() {
let root=tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(std::env::var("FRAMELY_MEDIA_BIN").unwrap(),root.path().join("media")).unwrap();
let port=std::env::var("FRAMELY_CAST_WEB_PORT").unwrap().parse::<u16>().unwrap();
let server=Server::http(("0.0.0.0",port)).unwrap();let agent=agent(&server,root.path());
let mut core=crate::tests::accepted_service(&agent.state,1000).unwrap();
core.handle("network.save",json!({"enabled":true,"port":port,"passwordEnabled":false,"password":"fixture password"})).unwrap();
core.handle("cast.settings.save",json!({"source":std::env::var("FRAMELY_CAST_TEST_SOURCE").unwrap_or_else(|_|"steamVR".into()),"codec":std::env::var("FRAMELY_CAST_TEST_CODEC").unwrap_or_else(|_|"h264".into()),"width":std::env::var("FRAMELY_CAST_TEST_WIDTH").ok().and_then(|v|v.parse::<u32>().ok()).unwrap_or(640),"height":std::env::var("FRAMELY_CAST_TEST_HEIGHT").ok().and_then(|v|v.parse::<u32>().ok()).unwrap_or(360),"fps":std::env::var("FRAMELY_CAST_TEST_FPS").ok().and_then(|v|v.parse::<u32>().ok()).unwrap_or(30),"systemAudio":true,"dlna":true})).unwrap();
let socket=UnixListener::bind(&agent.socket).unwrap();let running=Arc::new(AtomicBool::new(true));
socket.set_nonblocking(true).unwrap();let alive=running.clone();
let worker=std::thread::spawn(move||{while alive.load(Ordering::Relaxed){if let Ok((mut stream,_))=socket.accept(){let request=ipc::read(&mut stream).unwrap();let result=if request["method"]=="cast.panel.start" {ipc::call(Path::new("/run/framely/control.sock"),"cast.panel.start",json!({}))}else{core.handle(request["method"].as_str().unwrap(),request["params"].clone())};let response=ipc::response(result);let _=ipc::write(&mut stream,&response);}else{std::thread::sleep(Duration::from_millis(5));}}});
*agent.network_status.lock().unwrap()=json!({"enabled":true,"port":port});
let settings:crate::cast::settings::Settings=serde_json::from_value(agent.core("status",json!({})).unwrap()["database"]["casting"].clone()).unwrap();
agent.receivers.lock().unwrap().configure(&settings).unwrap();
let origin=agent.origin.clone();let cookie=format!("framely-network={}",agent.network_key.lock().unwrap());
let start=std::thread::spawn(move||ureq::post(&format!("{origin}/api")).set("Origin",&origin).set("Cookie",&cookie).send_json(json!({"method":"cast.start","params":{}})).unwrap().into_json::<Value>().unwrap());
while !start.is_finished() {if let Ok(Some(request))=server.recv_timeout(Duration::from_millis(50)){let a=agent.clone();std::thread::spawn(move||{a.handle_http(request,true).unwrap();});}}
let response=start.join().unwrap();assert!(response.get("error").is_none(),"HTTP stream startup failed: {response}");
std::thread::sleep(Duration::from_millis(200));assert_eq!(agent.casting.lock().unwrap().status()["running"],true);
if std::env::var_os("FRAMELY_CAST_TEST_THROUGHPUT").is_some() {
let capture_frames=||->u64 {fs::read_to_string(root.path().join("cast/stream.log")).unwrap_or_default().lines().filter_map(|line|serde_json::from_str::<Value>(line).ok()).filter(|v|v["event"]=="capture").filter_map(|v|v["frames"].as_u64()).last().unwrap_or(0)};
std::thread::sleep(Duration::from_secs(6));let first=capture_frames();
std::thread::sleep(Duration::from_secs(10));let last=capture_frames();
let fps=(last.saturating_sub(first)) as f64/10.;
println!("CAST_THROUGHPUT {fps:.1} fps requested={} with system audio",settings.fps);
assert!(fps>=settings.fps as f64*0.8,"Live audio blocked capture: {fps:.1} fps");
}
if std::env::var_os("FRAMELY_CAST_TEST_DLNA").is_some() {
let mut cast=agent.casting.lock().unwrap();cast.ensure_dlna().unwrap();
let rtsp=cast.test_rtsp_port();
for (path,codec) in [("headset",if settings.codec==crate::cast::settings::Codec::H265 {"hevc"}else{"h264"}),("dlna","h264")] {
std::thread::sleep(Duration::from_millis(500));
let output=Command::new("timeout").args(["15","ffprobe","-v","error","-rtsp_transport","tcp","-show_entries","stream=codec_name,width,height,r_frame_rate","-of","json",&format!("rtsp://127.0.0.1:{rtsp}/{path}")]).output().unwrap();
assert!(output.status.success(),"{}",String::from_utf8_lossy(&output.stderr));
let probe:Value=serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(probe["streams"][0]["codec_name"],codec);
assert_eq!(probe["streams"][0]["width"],settings.width);assert_eq!(probe["streams"][0]["height"],settings.height);
println!("CAST_CODEC_PASS {path}: {probe}");
}
}
println!("Fixture viewer: http://{}:{port}/cast/watch",network_ipv4_addresses().first().unwrap());
let probe=std::env::var("FRAMELY_CAST_BROWSER_PROBE").ok();
if std::env::var_os("FRAMELY_CAST_TEST_THROUGHPUT").is_some() && probe.is_none() {
agent.casting.lock().unwrap().stop();running.store(false,Ordering::Relaxed);worker.join().unwrap();return;
}
let mut browser=probe.map(|executable|Command::new(executable)
.arg(format!("http://127.0.0.1:{port}/cast/watch"))
.arg(root.path().join("browser"))
.args(["--no-sandbox","--disable-gpu","--change-stack-guard-on-fork=disable"])
.spawn().unwrap());
let end=std::time::Instant::now()+Duration::from_secs(if browser.is_some(){60}else{300});
let mut result=None;
while std::time::Instant::now()<end {
agent.maintain_cast();
if let Some(p)=browser.as_mut(){if let Some(code)=p.try_wait().unwrap(){result=Some(code);break;}}
if let Ok(Some(request))=server.recv_timeout(Duration::from_millis(50)){let a=agent.clone();std::thread::spawn(move||{let _=a.handle_http(request,true);});}
}
if let Some(mut p)=browser {if result.is_none(){let _=p.kill();let _=p.wait();}}
agent.casting.lock().unwrap().stop();running.store(false,Ordering::Relaxed);worker.join().unwrap();
if std::env::var("FRAMELY_CAST_BROWSER_PROBE").is_ok(){assert!(result.is_some_and(|r|r.success()),"Browser stream integration failed: {result:?}");}
}
#[test]
fn catalog_progress_reports_fast_source_and_cancellation_does_not_wait_for_slow_source() {
use std::sync::mpsc;
@@ -2111,6 +2431,8 @@ mod tests {
fn agent(server: &Server, root: &Path) -> Arc<Agent> {
Arc::new(Agent {
casting: Mutex::new(crate::cast::runtime::Casting::new(root.join("cast"), root.join("media"))),
receivers: Mutex::new(crate::cast::receiver::Receivers::new(root.join("receive"), root.join("media"))),
socket: root.join("control.sock"),
state: root.join("state"),
assets: std::env::var_os("FRAMELY_TEST_ASSETS")
+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);
}
}
+61 -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();
@@ -1542,6 +1597,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 +1631,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),
);
+29 -19
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())))
@@ -678,17 +678,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 +774,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 +793,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()
+8 -1
View File
@@ -36,7 +36,8 @@ impl Tracker {
&& (k == "menu"
|| k == "launcher"
|| k == "framely.manager"
|| k.starts_with("framely.window."))),
|| k.starts_with("framely.window.")
|| k.starts_with("framely.cast."))),
"Invalid native visibility views"
);
self.seen = Some(now);
@@ -74,6 +75,12 @@ impl Tracker {
mod tests {
use super::*;
#[test]
fn cast_windows_are_native_visibility_sources() {
let mut tracker=Tracker::default();
tracker.report(BTreeMap::from([("framely.cast.0123".into(),true)]),Instant::now()).unwrap();
assert!(tracker.state.capture_obscured);
}
#[test]
fn aggregate_close_and_lease() {
let now = Instant::now();
let mut t = Tracker::default();
+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"]')!;
+1 -1
View File
@@ -42,7 +42,7 @@ int main(int argc,char** argv){
cef_main_args_t args{argc,argv};if(std::strcmp(cef_api_hash(CEF_API_VERSION,0),CEF_API_HASH_PLATFORM))return 2;int child=cef_execute_process(&args,nullptr,nullptr);if(child>=0)return child;if(url.empty()||runtime.empty()||cef.empty())return 2;
cef_settings_t settings{};settings.size=sizeof(settings);settings.windowless_rendering_enabled=1;str(settings.root_cache_path,runtime+"/cache");str(settings.log_file,runtime+"/cef.log");str(settings.resources_dir_path,cef+"/Resources");str(settings.locales_dir_path,cef+"/Resources/locales");
if(!cef_initialize(&args,&settings,nullptr,nullptr))return 3;
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,1.5f);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:600,std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?2160:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?1200:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);// Semi-transparent charcoal panels still need a readable surface and clear rounded corners.
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,std::getenv("FRAMELY_TEST_VIEW_WIDTH")?std::atoi(std::getenv("FRAMELY_TEST_VIEW_WIDTH")):600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,1.5f);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:(std::getenv("FRAMELY_TEST_VIEW_WIDTH")?std::atoi(std::getenv("FRAMELY_TEST_VIEW_WIDTH")):600),std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?2160:std::getenv("FRAMELY_STORE_TEST")?5760:(std::getenv("FRAMELY_TEST_VIEW_WIDTH")?std::atoi(std::getenv("FRAMELY_TEST_VIEW_WIDTH"))*2:1200))&&h==(launcher_mode?1200:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);// Semi-transparent charcoal panels still need a readable surface and clear rounded corners.
if(!notification_mode&&w>0&&h>0){auto alpha=[&](int x,int y){return pixels[(y*w+x)*4+3];};if(alpha(0,0)==0&&alpha(w-1,0)==0&&alpha(0,h-1)==0&&alpha(w-1,h-1)==0&&alpha(w/2,40)>200)rounded_paint=true;}};
display.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* m,const cef_string_t*,int){auto s=text(m);std::cout<<s<<"\n";if(s.rfind("FRAMELY_PAGE_WHEEL_",0)==0){int x=0,y=0,dx=0,dy=0;if(std::sscanf(s.c_str(),"FRAMELY_PAGE_WHEEL_%d_%d_%d_%d",&x,&y,&dx,&dy)==4){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;host->send_mouse_move_event(host,&mouse,0);host->send_mouse_wheel_event(host,&mouse,dx,dy);host->base.release(&host->base);}}if(s.rfind("FRAMELY_NATIVE_KEYBOARD_",0)==0){auto* frame=b->get_focused_frame(b);auto command=s.substr(24);if(command=="TEXT")keyboard_edit(frame,"汉😃");else if(command=="BACKSPACE")keyboard_edit(frame,"\b");else if(command=="ENTER")keyboard_edit(frame,"\n");else if(command=="X")keyboard_edit(frame,"x");else if(command=="Y")keyboard_edit(frame,"y");else if(command=="LEFT")keyboard_edit(frame,"\x1b[D");else if(command=="RIGHT")keyboard_edit(frame,"\x1b[C");frame->base.release(&frame->base);}if(s=="FRAMELY_LAUNCHER_CANCEL_PRESS"){auto* pointer_host=b->get_host(b);cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_move_event(pointer_host,&outside,0);outside.modifiers=0;pointer_host->send_mouse_click_event(pointer_host,&outside,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;auto* frame=b->get_main_frame(b);cef_string_t code{},source{};str(code,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->execute_java_script(frame,&code,&source,1);cef_string_utf16_clear(&code);frame->base.release(&frame->base);pointer_host->base.release(&pointer_host->base);}if(s.rfind("FRAMELY_LAUNCHER_DRAG_",0)==0){int x=0,y=0;if(press_pending&&std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_DRAG_%d_%d",&x,&y)==2){press_dx=x-test_pointer.x;press_dy=y-test_pointer.y;}}if(s.rfind("FRAMELY_LAUNCHER_PRESS_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_PRESS_%d_%d_%d_%d_%d",&x,&y,&press_dx,&press_dy,&press_duration)==5){auto* pointer_host=b->get_host(b);test_pointer.begin(x,y,std::chrono::steady_clock::now());press_pending=true;cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;pointer_host->set_focus(pointer_host,1);pointer_host->send_mouse_move_event(pointer_host,&mouse,0);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_click_event(pointer_host,&mouse,MBT_LEFT,0,1);pointer_host->base.release(&pointer_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_HOVER_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_HOVER_%d_%d",&x,&y)==2){auto* hover_host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;hover_host->send_mouse_move_event(hover_host,&mouse,0);hover_host->base.release(&hover_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_REGION_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_REGION_%d_%d_%d_%d",&x,&y,&w,&h)==4){const float scale=launcher_mode?1.5f:4.f;int ink=0;for(int py=std::max(0,int(y*scale));py<std::min(pixel_height,int((y+h)*scale));py++)for(int px=std::max(0,int(x*scale));px<std::min(pixel_width,int((x+w)*scale));px++)if(pixels[(size_t(py)*pixel_width+px)*4+3]>128)ink++;if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted control "<<x<<","<<y<<"\n";}}}if(s.rfind("FRAMELY_LAUNCHER_INK_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_INK_%d_%d_%d_%d",&x,&y,&w,&h)==4){const float scale=launcher_mode?1.5f:4.f;int ink=0;for(int py=std::max(0,int(y*scale));py<std::min(pixel_height,int((y+h)*scale));py++)for(int px=std::max(0,int(x*scale));px<std::min(pixel_width,int((x+w)*scale));px++){size_t i=(size_t(py)*pixel_width+px)*4;if(pixels[i+3]>128&&pixels[i]>180&&pixels[i+1]>180&&pixels[i+2]>180)ink++;}if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted label "<<x<<","<<y<<" ink="<<ink<<"\n";}}}
if(s=="FRAMELY_DPI_CLICK_REQUEST"){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=50;mouse.y=45;host->set_focus(host,1);host->send_mouse_move_event(host,&mouse,0);host->send_mouse_click_event(host,&mouse,MBT_LEFT,0,1);host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);host->base.release(&host->base);}if(s=="FRAMELY_DPI_CLICK_PASS")dpi_click=true;if(s.rfind("FRAMELY_PREVIEW_INSTALL_",0)==0)snapshot(s.substr(16));if(s=="FRAMELY_PREVIEW_STORE")snapshot("store");if(s=="FRAMELY_PREVIEW_FAVORITES")snapshot("favorites");if(s=="FRAMELY_PREVIEW_INSTALLED")snapshot("installed");if(s=="FRAMELY_PREVIEW_SETTINGS")snapshot("settings");if(s=="FRAMELY_VIEW_READY")ready=true;if(s=="FRAMELY_BRIDGE_PASS")passed=true;if(s.rfind("FRAMELY_BRIDGE_FAIL",0)==0)failed=true;return 1;};life.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t*){closed=true;};
+27
View File
@@ -0,0 +1,27 @@
#include <GL/gl.h>
#include <GL/glx.h>
#include <GL/glext.h>
#include <X11/Xlib.h>
#include <cstdint>
#include <string>
#include <vector>
#include <cassert>
#include <iostream>
#include "cast_frame.h"
int main(){
auto* display=XOpenDisplay(nullptr);assert(display);int attrs[]={GLX_RGBA,GLX_RED_SIZE,8,GLX_GREEN_SIZE,8,GLX_BLUE_SIZE,8,GLX_ALPHA_SIZE,8,None};auto* visual=glXChooseVisual(display,DefaultScreen(display),attrs);assert(visual);auto context=glXCreateContext(display,visual,nullptr,True);assert(context);auto pixmap=XCreatePixmap(display,RootWindow(display,visual->screen),4,4,visual->depth);auto surface=glXCreateGLXPixmap(display,visual,pixmap);assert(glXMakeCurrent(display,surface,context));
{
CastFrame frame;frame.width=frame.height=2;glGenTextures(1,&frame.texture);glBindTexture(GL_TEXTURE_2D,frame.texture);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);std::vector<uint8_t> blue(16);for(size_t n=0;n<blue.size();n+=4){blue[n+2]=255;blue[n+3]=255;}glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,2,2,0,GL_RGBA,GL_UNSIGNED_BYTE,blue.data());
GLuint ui;glGenTextures(1,&ui);glBindTexture(GL_TEXTURE_2D,ui);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);std::vector<uint8_t> controls(64);for(size_t n=0;n<controls.size();n+=4){controls[n]=128;controls[n+3]=128;}glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,4,4,0,GL_RGBA,GL_UNSIGNED_BYTE,controls.data());
auto output=frame.draw(ui,4,4);assert(output!=ui);glBindTexture(GL_TEXTURE_2D,output);std::vector<uint8_t> pixels(64);glGetTexImage(GL_TEXTURE_2D,0,GL_RGBA,GL_UNSIGNED_BYTE,pixels.data());for(size_t n=0;n<pixels.size();n+=4){assert(pixels[n]==128);assert(pixels[n+1]==0);assert(pixels[n+2]>=126&&pixels[n+2]<=128);assert(pixels[n+3]==255);}glBindTexture(GL_TEXTURE_2D,ui);glGetTexImage(GL_TEXTURE_2D,0,GL_RGBA,GL_UNSIGNED_BYTE,pixels.data());assert(pixels==controls);
std::fill(controls.begin(),controls.end(),0);glTexSubImage2D(GL_TEXTURE_2D,0,0,0,4,4,GL_RGBA,GL_UNSIGNED_BYTE,controls.data());output=frame.draw(ui,4,4);glBindTexture(GL_TEXTURE_2D,output);glGetTexImage(GL_TEXTURE_2D,0,GL_RGBA,GL_UNSIGNED_BYTE,pixels.data());assert(pixels[0]==0&&pixels[2]==255);
// Raw decoder rows are top-down: red/green above blue/yellow.
// The composited GL texture must put blue/yellow on its bottom row.
const uint8_t corners[]={255,0,0,255, 0,255,0,255, 0,0,255,255, 255,255,0,255};
glBindTexture(GL_TEXTURE_2D,frame.texture);glTexSubImage2D(GL_TEXTURE_2D,0,0,0,2,2,GL_RGBA,GL_UNSIGNED_BYTE,corners);
output=frame.draw(ui,2,2);glBindTexture(GL_TEXTURE_2D,output);std::vector<uint8_t> upright(16);glGetTexImage(GL_TEXTURE_2D,0,GL_RGBA,GL_UNSIGNED_BYTE,upright.data());
const std::vector<uint8_t> expected={0,0,255,255, 255,255,0,255, 255,0,0,255, 0,255,0,255};assert(upright==expected);
assert(glGetError()==GL_NO_ERROR);glDeleteTextures(1,&ui);
}
glXMakeCurrent(display,None,nullptr);glXDestroyGLXPixmap(display,surface);XFreePixmap(display,pixmap);glXDestroyContext(display,context);XFree(visual);XCloseDisplay(display);std::cout<<"Full-frame video and transparent controls composition passed\n";
}
+43
View File
@@ -0,0 +1,43 @@
// Product integration test: receive the real stream in Framely's CEF engine.
#include "include/capi/cef_app_capi.h"
#include "include/capi/cef_client_capi.h"
#include "include/capi/cef_load_handler_capi.h"
#include "include/cef_api_hash.h"
#include <atomic>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <thread>
#include <fstream>
#include <vector>
template<class T> struct Handler {
T api{};std::atomic<int> refs{1};
Handler(){api.base.size=sizeof(T);api.base.add_ref=[](cef_base_ref_counted_t* p){++reinterpret_cast<Handler*>(p)->refs;};api.base.release=[](cef_base_ref_counted_t* p){--reinterpret_cast<Handler*>(p)->refs;return 0;};api.base.has_one_ref=[](cef_base_ref_counted_t* p){return int(reinterpret_cast<Handler*>(p)->refs==1);};api.base.has_at_least_one_ref=[](cef_base_ref_counted_t* p){return int(reinterpret_cast<Handler*>(p)->refs>0);};}
T* acquire(){api.base.add_ref(&api.base);return &api;}
};
static Handler<cef_client_t> client;static Handler<cef_render_handler_t> render;static Handler<cef_display_handler_t> display;static Handler<cef_life_span_handler_t> life;static Handler<cef_load_handler_t> load;
static bool passed=false,failed=false,closed=false;static unsigned paints=0;static std::vector<unsigned char> pixels;static int width=0,height=0;
static void str(cef_string_t& s,const std::string& v){cef_string_utf8_to_utf16(v.data(),v.size(),&s);}
static std::string text(const cef_string_t* s){cef_string_utf8_t o{};cef_string_utf16_to_utf8(s->str,s->length,&o);std::string r(o.str,o.length);cef_string_utf8_clear(&o);return r;}
int main(int argc,char** argv){
std::cout.setf(std::ios::unitbuf);
const std::string url=argc>1?argv[1]:"", runtime=argc>2?argv[2]:"";
cef_main_args_t args{argc,argv};if(std::strcmp(cef_api_hash(CEF_API_VERSION,0),CEF_API_HASH_PLATFORM))return 2;int child=cef_execute_process(&args,nullptr,nullptr);if(child>=0)return child;if(argc<3)return 2;
auto* cef=std::getenv("FRAMELY_CEF_ROOT");if(!cef)return 2;
cef_settings_t settings{};settings.size=sizeof(settings);settings.windowless_rendering_enabled=1;str(settings.root_cache_path,runtime+"/cache");str(settings.log_file,runtime+"/cef.log");str(settings.resources_dir_path,cef);str(settings.locales_dir_path,std::string(cef)+"/locales");
if(!cef_initialize(&args,&settings,nullptr,nullptr))return 3;
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r={0,0,960,720};};
render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t,const cef_rect_t*,const void* data,int w,int h){if(type==PET_VIEW){paints++;width=w;height=h;pixels.assign((const unsigned char*)data,(const unsigned char*)data+size_t(w)*h*4);}};
display.api.on_console_message=[](cef_display_handler_t*,cef_browser_t*,cef_log_severity_t,const cef_string_t* m,const cef_string_t*,int){auto s=text(m);std::cout<<s<<std::endl;if(s.rfind("CAST_BROWSER_PASS",0)==0)passed=true;if(s.rfind("CAST_BROWSER_FAIL",0)==0)failed=true;return 1;};
life.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t*){closed=true;};
load.api.on_load_end=[](cef_load_handler_t*,cef_browser_t*,cef_frame_t* frame,int){if(!frame->is_main(frame))return;cef_string_t script{},source{};
str(script,R"JS((()=>{let tries=0;const timer=setInterval(()=>{const video=document.querySelector('video');if(video?.readyState>=2&&video.currentTime>1){clearInterval(timer);if(video.videoWidth!==640||video.videoHeight!==360){console.error('CAST_BROWSER_FAIL unexpected dimensions '+video.videoWidth+'x'+video.videoHeight);return;}console.log('CAST_BROWSER_PASS '+JSON.stringify({width:video.videoWidth,height:video.videoHeight,time:video.currentTime,frames:video.getVideoPlaybackQuality?.().totalVideoFrames}));}else if(++tries>350){clearInterval(timer);console.error('CAST_BROWSER_FAIL '+document.body.innerText+' video='+JSON.stringify(video?{ready:video.readyState,time:video.currentTime,error:video.error?.message}:null));}},100);})())JS");
frame->execute_java_script(frame,&script,&source,1);cef_string_utf16_clear(&script);};
client.api.get_render_handler=[](cef_client_t*){return render.acquire();};client.api.get_display_handler=[](cef_client_t*){return display.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life.acquire();};client.api.get_load_handler=[](cef_client_t*){return load.acquire();};
cef_window_info_t win{};win.size=sizeof(win);win.windowless_rendering_enabled=1;win.runtime_style=CEF_RUNTIME_STYLE_ALLOY;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=30;cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&win,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);if(!browser)return 4;
auto* host=browser->get_host(browser);auto end=std::chrono::steady_clock::now()+std::chrono::seconds(40);
while(std::chrono::steady_clock::now()<end&&!passed&&!failed){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}
if(const char* output=getenv("FRAMELY_CAST_PREVIEW")){if(!pixels.empty()){std::ofstream file(output,std::ios::binary);file<<"P6\n"<<width<<" "<<height<<"\n255\n";for(size_t i=0;i<pixels.size();i+=4){unsigned char rgb[]={pixels[i+2],pixels[i+1],pixels[i]};file.write((const char*)rgb,3);}}}
host->close_browser(host,1);for(int i=0;i<400&&!closed;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}bool ok=passed&&!failed&&paints>0&&closed;std::cout<<"CAST_BROWSER_RESULT pass="<<ok<<" paints="<<paints<<" closed="<<closed<<std::endl;if(!closed)_Exit(5);host->base.release(&host->base);browser->base.release(&browser->base);cef_shutdown();return ok?0:5;
}
+49
View File
@@ -0,0 +1,49 @@
import test from 'node:test';import assert from 'node:assert/strict';import {build} from 'esbuild';
const bundle=async(path)=>{const r=await build({entryPoints:[path],bundle:true,write:false,platform:'node',format:'esm'});return import('data:text/javascript;base64,'+Buffer.from(r.outputFiles[0].contents).toString('base64'));};
const {FileUploadQueue,ChunkSlots,uploadBatchFileCounts}=await bundle('ui/src/file-upload-queue.ts');
const {droppedUploadEntries,selectedUploadEntries}=await bundle('ui/src/file-upload-entries.ts');
const wait=()=>new Promise(r=>setImmediate(r));const until=async(f)=>{for(let i=0;i<200;i++){if(f())return;await wait();}assert.fail('Timed out');};
const file=(name,size=8)=>({name,webkitRelativePath:'',size,slice:(a,b)=>new Blob([new Uint8Array(b-a)])});
test('directory readers are fully drained and empty folders retained',async()=>{
const regular={name:'readme',isFile:true,file:done=>done(file('readme'))};let reads=0;
const root={name:'folder',isDirectory:true,createReader:()=>({readEntries:done=>done(reads++===0?[regular]:reads===2?[{name:'empty',isDirectory:true,createReader:()=>({readEntries:done=>done([])})}]:[])})};
const entries=await droppedUploadEntries({items:[{kind:'file',webkitGetAsEntry:()=>root}],files:[]},new AbortController().signal);
assert.deepEqual(entries.map(e=>e.name),['folder','folder/readme','folder/empty']);assert.equal(reads,3);
const chosen=file('x');chosen.webkitRelativePath='中文/a/x';assert.deepEqual((await selectedUploadEntries([chosen])).map(e=>e.name),['中文','中文/a','中文/a/x']);
await assert.rejects(selectedUploadEntries([{...chosen,webkitRelativePath:'../bad'}]),/Invalid upload path/);
});
test('directory dependencies, fixed destinations, skips, failures and retries are independent',async()=>{
let fail=true;const calls=[],chunks=[];
const q=new FileUploadQueue({api:async p=>{calls.push(p);if(p.operation==='upload.directory')return {status:'done'};if(p.operation==='upload.start'){if(p.name==='skip')return {status:'skipped',path:'/first/skip'};if(p.name==='bad'&&fail)throw Error('permission');return {id:p.name,chunkSize:4};}return {status:'done',path:'/first/'+p.id};},chunk:async(id,offset)=>chunks.push([id,offset])});
q.add('/first',{dev:1,ino:2},'skip',[{name:'root',directory:true},{name:'root/file',directory:false,file:file('file')},{name:'skip',directory:false,file:file('skip')},{name:'bad',directory:false,file:file('bad')}]);
await until(()=>!q.pending);assert.deepEqual(q.batches[0].tasks.map(t=>t.state),['done','done','skipped','failed']);assert.equal(chunks.filter(c=>c[0]==='skip').length,0);
assert.ok(calls.findIndex(c=>c.operation==='upload.directory')<calls.findIndex(c=>c.name==='root/file'));assert.ok(calls.filter(c=>c.operation==='upload.start').every(c=>c.directory==='/first'));
fail=false;q.retry();await until(()=>!q.pending);assert.equal(q.batches[0].tasks[3].state,'done');q.clear();assert.equal(q.batches.length,0);
});
test('file concurrency changes without aborting active tasks and global chunks never exceed eight',async()=>{
let active=0,max=0;const pending=[];
const q=new FileUploadQueue({api:async p=>p.operation==='upload.start'?{id:p.name,chunkSize:4}:{status:'done'},chunk:async(id,o,b,signal)=>{active++;max=Math.max(max,active);try{await new Promise((resolve,reject)=>{pending.push(resolve);signal.addEventListener('abort',()=>reject(signal.reason),{once:true});});}finally{active--;}}});
q.setLimit(8);q.add('/target',{dev:1,ino:2},'keep',Array.from({length:12},(_,i)=>({name:String(i),directory:false,file:file(String(i))})));
await until(()=>active===8);assert.equal(q.batches[0].tasks.filter(t=>t.state==='uploading').length,8);q.setLimit(1);
for(let round=0;round<50&&q.pending;round++){pending.splice(0).forEach(f=>f());await wait();}
await until(()=>!q.pending);assert.equal(max,8);assert.ok(q.batches[0].tasks.every(t=>t.state==='done'));
});
test('cancel drains requests and releases chunk slots, allowing another batch',async()=>{
const q=new FileUploadQueue({api:async p=>p.operation==='upload.start'?{id:p.name,chunkSize:4}:{status:'done'},chunk:async(id,o,b,signal)=>{if(id==='first')await new Promise((resolve,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}));}});
q.setLimit(1);const first=q.add('/one',{dev:1,ino:1},'keep',[{name:'first',directory:false,file:file('first')}]);await wait();q.cancel(undefined,first.id);
q.add('/two',{dev:1,ino:2},'keep',[{name:'second',directory:false,file:file('second')}]);await until(()=>!q.pending);
assert.equal(q.batches[0].tasks[0].state,'cancelled');assert.equal(q.batches[1].tasks[0].state,'done');
const slots=new ChunkSlots(1),hold=await slots.acquire(new AbortController().signal),cancel=new AbortController();const blocked=slots.acquire(cancel.signal);cancel.abort();await assert.rejects(blocked);hold();(await slots.acquire(new AbortController().signal))();
});
test('a restored browser page can add a fresh batch after closing its old queue',async()=>{
const q=new FileUploadQueue({api:async p=>p.operation==='upload.start'?{id:p.name,chunkSize:4}:{status:'done'},chunk:async()=>{}});
q.close();q.reopen();q.add('/restored',{dev:1,ino:2},'keep',[{name:'new',directory:false,file:file('new',0)}]);
await until(()=>!q.pending);assert.equal(q.batches[0].tasks[0].state,'done');
});
test('batch file counts exclude directories, skips and failures and survive clearing finished rows',()=>{
const q=new FileUploadQueue({api:async()=>({}),chunk:async()=>{}});q.close();q.reopen();
const batch=q.add('/target',{dev:1,ino:2},'keep',[{name:'empty',directory:true,error:'fixture'},{name:'done',directory:false,file:file('done'),error:'fixture'},{name:'skip',directory:false,file:file('skip'),error:'fixture'},{name:'pending',directory:false,file:file('pending'),error:'fixture'}]);
batch.tasks[0].state='done';batch.tasks[1].state='done';batch.tasks[2].state='skipped';batch.tasks[3].state='waiting';
assert.deepEqual(uploadBatchFileCounts(batch),{uploaded:1,total:3});q.clear();assert.deepEqual(uploadBatchFileCounts(batch),{uploaded:1,total:3});q.close();
});
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';import {createRoot} from 'react-dom/client';
import {FileManager} from '../ui/src/file-manager';import {configureLanguage} from '../ui/src/i18n';import {Brand} from '../ui/src/icons';import '../ui/src/style.css';
const wait=(ms=30)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unknown)=>{for(let i=0;i<200;i++){if(f())return;await wait();}throw Error('Timed out '+document.body.innerText.slice(-600));};
const home='/home/steamos',dest=home+'/Downloads',calls:any[]=[],tickets=new Map<string,any>();let seq=0;
let prefs:any={view:'list',folders:{},uploadConcurrency:3};
window.fetch=async(url,init)=>{
const u=String(url);if(u==='/api')return new Response(JSON.stringify({result:true}));if(u.startsWith('/manager-api/file-upload/')){await wait(450);if(init?.signal?.aborted)throw new DOMException('cancel','AbortError');const parts=u.split('/'),offset=Number(parts.at(-1)),size=(init?.body as Blob).size;return new Response(JSON.stringify({result:{received:offset+size}}));}
if(u!=='/manager-api/files')throw Error('Unexpected '+u);const p=JSON.parse(String(init?.body));calls.push(p);let result:any;
if(p.operation==='preferences')result=[];
else if(p.operation==='view.preferences'){if(p.uploadConcurrency)prefs.uploadConcurrency=p.uploadConcurrency;result=prefs;}
else if(p.operation==='list'){const path=p.path||home;result={path,parent:'/',entries:p.hidden?[]:[{name:'Downloads',path:dest,directory:true,size:0},{name:'notes.txt',path:home+'/notes.txt',directory:false,size:123}],total:p.hidden?0:2,roots:[{name:'Home',path:home},{name:'Downloads',path:dest}]};}
else if(p.operation==='upload.preflight')result={directory:p.directory,rootIdentity:{dev:1,ino:2},items:p.items.map((e:any)=>({name:e.name,exists:e.name.endsWith('existing.txt')}))};
else if(p.operation==='upload.directory')result={status:'done',path:p.directory+'/'+p.name};
else if(p.operation==='upload.start'){if(p.conflict==='skip'&&p.name.endsWith('existing.txt'))result={status:'skipped',path:p.directory+'/'+p.name};else{const id=String(++seq);tickets.set(id,p);result={id,chunkSize:1024*1024};}}
else if(p.operation==='upload.finish'){const item=tickets.get(p.id);result={status:'done',path:item.directory+'/'+item.name};tickets.delete(p.id);}
else if(p.operation==='upload.cancel'){tickets.delete(p.id);result=true;}
else throw Error('Unexpected operation '+p.operation);
return new Response(JSON.stringify({result}));
};
const root=createRoot(document.getElementById('root')!);const render=(lang:string)=>{configureLanguage(lang,[]);root.render(<div className="app manager"><header className="shell-header"><Brand wordmark/><span>{lang==='zh-CN'?'文件':'Files'}</span></header><div className="workspace tools-workspace"><main className="content file-content"><div className="file-workspace-frame"><FileManager key={lang}/></div></main></div></div>);};
function drop(target:Element,folder=false){const data=new DataTransfer();data.items.add(new File(['fixture'],'existing.txt'));if(folder){const entry={name:'Project',isDirectory:true,createReader:()=>{let read=false;return {readEntries:(done:any)=>{if(read)return done([]);read=true;done([{name:'empty',isDirectory:true,createReader:()=>({readEntries:(done:any)=>done([])})},{name:'existing.txt',isFile:true,file:(done:any)=>done(new File(['fixture'],'existing.txt'))},{name:'assets.bin',isFile:true,file:(done:any)=>done(new File([new Uint8Array(8*1024*1024)],'assets.bin'))}]);}};}};Object.defineProperty(data,'items',{value:[{kind:'file',webkitGetAsEntry:()=>entry}]});}target.dispatchEvent(new DragEvent('dragover',{dataTransfer:data,bubbles:true,cancelable:true}));target.dispatchEvent(new DragEvent('drop',{dataTransfer:data,bubbles:true,cancelable:true}));}
const modal=()=>document.querySelector('.modal');const click=(label:string,parent:ParentNode=document)=>{const button=[...parent.querySelectorAll<HTMLButtonElement>('button')].find(b=>b.textContent?.trim()===label);if(!button)throw Error('No button '+label);button.click();};
(window as any).runInstallReviewChecks=async()=>{try{
for(const lang of ['en-US','zh-CN']){
render(lang);await until(()=>document.querySelector('.file-list-scroll')?.getAttribute('aria-busy')==='false');
// Files do not fall through to the blank area's destination.
drop(document.querySelector('[data-file-drop-block]')!);await wait(100);if(modal())throw Error('File accepted drop');
for(const selector of ['.file-sidebar [data-file-drop-directory]','.file-tree-entry [data-file-drop-directory]','.file-breadcrumbs [data-file-drop-directory]','.file-list-scroll']){
const target=document.querySelector<HTMLElement>(selector)!;drop(target);await until(modal);if(modal()!.querySelector('code')?.textContent!==target.dataset.fileDropDirectory)throw Error('Drop target mismatch');click(lang==='en-US'?'Cancel':'取消',modal()!);await until(()=>!modal());
}
const folder=document.querySelector<HTMLElement>('.file-table [data-file-drop-directory]')!;drop(folder,true);await until(modal);
if(modal()!.querySelector('code')?.textContent!==dest)throw Error('Folder target mismatch');
if(calls.some(p=>p.operation==='upload.start')&&lang==='en-US')throw Error('Upload before confirmation');
if(modal()!.querySelectorAll('input[type="radio"]').length!==3||modal()!.querySelector('select'))throw Error('Conflict choices must be visible');
const choices=modal()!.querySelectorAll<HTMLInputElement>('input[type="radio"]');if(!choices[0].checked)throw Error('Keep both must be default');choices[2].click();await wait();if(!modal()!.querySelector('[role="status"]'))throw Error('Missing overwrite warning');choices[0].click();await wait();
await wait(1400);console.log('FRAMELY_PREVIEW_INSTALL_UPLOAD_CONFIRM_'+(lang==='en-US'?'EN':'ZH'));await wait(200);
click(lang==='en-US'?'Start upload':'开始上传',modal()!);await until(()=>!modal());
if(document.querySelector('.file-upload-popover'))throw Error('Queue opened without a click');
document.querySelector<HTMLButtonElement>('.file-upload-toggle')!.click();await until(()=>document.querySelector('.file-upload-task.uploading'));
if(!document.querySelector('.file-upload-batch-count')?.textContent?.includes('2'))throw Error('Missing total file count');
if(document.querySelector<HTMLButtonElement>('.file-command-bar button')?.disabled)throw Error('Uploads lock workspace');
await wait(1000);console.log('FRAMELY_PREVIEW_INSTALL_UPLOAD_QUEUE_'+(lang==='en-US'?'EN':'ZH'));await wait(250);
await until(()=>document.querySelectorAll('.file-upload-task.done').length===4);
if(document.querySelector('.file-upload-batch-count')?.textContent!==(lang==='en-US'?'Uploaded 2 / 2 files':'已上传 2 / 2 个文件'))throw Error('Incorrect finished file count');
document.body.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true}));await until(()=>!document.querySelector('.file-upload-popover'));
const uploads=calls.filter(p=>p.operation==='upload.start');if(uploads.some(p=>p.directory!==dest))throw Error('Upload destination changed');
}
console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+String(e)+' '+(e as Error).stack);}};
console.log('FRAMELY_VIEW_READY');
+18
View File
@@ -0,0 +1,18 @@
#include <signal.h>
static volatile sig_atomic_t stopped=0;
#include "gamepad_direct.h"
#include <cassert>
#include <sstream>
int main(){
std::istringstream info("[slot 0]\nVID=0x28de\nPID=0x11e0\n[slot 1]\nVID=0x0001\nPID=0xf001\n[slot 2]\nVID=0x045e\nPID=0x028e\n");auto rows=read_steam_pad_info(info);
input_id id{BUS_USB,0x28de,0x11ff,1};
assert(direct_slot(id,"Microsoft X-Box 360 pad 0",rows)==0);
assert(direct_slot(id,"Microsoft X-Box 360 pad 1",rows)==-1);
assert(direct_slot(id,"Microsoft X-Box 360 pad 2",rows)==2);
assert(direct_slot(id,"Microsoft X-Box 360 pad 0 other",rows)==-1);
id.vendor=0x045e;assert(direct_slot(id,"Microsoft X-Box 360 pad 0",rows)==-1);
FramelyDirectControl c{200,1,1};assert(framely_direct_enabled(c,100));assert(framely_direct_rumble(c,100));
c.rumble=0;assert(framely_direct_enabled(c,100));assert(!framely_direct_rumble(c,100));
c.rumble=1;c.enabled=0;assert(!framely_direct_rumble(c,100));
c.enabled=1;assert(!framely_direct_enabled(c,200));assert(!framely_direct_rumble(c,201));
}
+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);}})();
+39
View File
@@ -0,0 +1,39 @@
const wait=(ms=50)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unknown)=>{for(let i=0;i<220;i++){if(f())return;await wait();}throw Error('Timed out '+document.body.innerText.slice(-600));};
history.replaceState(null,'','/manager');let language='en-US',created=false;const home='/home/steamos',input:string[]=[];
const plugin={manifest:{id:'demo',name:'Frame Mic Tuner',version:'1.0.0',description:'Adjust microphone gain on your Frame.',author:'Community',ui:{windows:{}}},enabled:true,favorite:false,order:0};
const database:any={language,plugins:{demo:plugin},sources:[],safeMode:false,launcher:{primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']},networkPanel:{enabled:true,port:15915,passwordEnabled:true},proxy:{http:'',github:''}};
const files=[{name:'Downloads',path:home+'/Downloads',directory:true,size:0,mode:493,uid:1000,gid:1000},{name:'notes.txt',path:home+'/notes.txt',directory:false,size:123,mode:420,uid:1000,gid:1000}];
class Socket {static OPEN=1;readyState=0;onopen:any;onmessage:any;onclose:any;onerror:any;constructor(){setTimeout(()=>{this.readyState=1;this.onopen?.();this.reply(btoa('steamos@frame:~$ '));},50);}reply(data=''){this.onmessage?.({data:JSON.stringify({writable:true,exited:false,data})});}send(value:string){const v=JSON.parse(value);if(v.input)input.push(atob(v.input));this.reply();}close(){this.readyState=3;this.onclose?.();}}
(window as any).WebSocket=Socket;
window.fetch=async(url,init)=>{let result:any=true;const u=String(url),p=JSON.parse(String(init?.body??'{}'));
if(u==='/manager-api/files'){
if(p.operation==='preferences')result=[];else if(p.operation==='view.preferences')result={view:'list',folders:{},uploadConcurrency:3};
else if(p.operation==='list')result={path:p.path||home,parent:'/',total:p.hidden?1:2,roots:[{name:'Home',path:home},{name:'Downloads',path:home+'/Downloads'}],entries:p.hidden?files.filter(f=>f.directory):files};else throw Error('files '+p.operation);
}else if(u==='/manager-api/terminal'){if(p.operation==='create')created=true;result=p.operation==='create'?{id:'session',name:'steamos'}:created?[{id:'session',name:'steamos',controlled:true,exited:false}]:[];
}else{const {method}=p;
if(method==='status'){database.language=language;result={version:'0.6.0',agreement:{version:1,accepted:true},database,running:[],notifications:[],inbox:[]};}
else if(method==='language.list')result={packs:[],invalidFiles:[]};else if(method==='ui.events')result={cursor:0,events:[]};else if(method==='network.status')result={passwordConfigured:true,localOrigin:'http://localhost:15915',ips:[],listener:{}};
else if(method==='catalog.start')result={job:'catalog'};else if(method==='apk.cleanup.list')result=[];
else if(method==='apk.list.start')result={job:'apk'};else if(method==='job.status')result=p.params?.job==='catalog'?{phase:'done',result:[]}:{phase:'done',result:{apps:[{id:'wolvic/com.igalia.wolvic',context:'wolvic',metadata:{name:'Wolvic',package:'com.igalia.wolvic',version:'1.0',activities:['Main'],declaredActivities:['Main']},installed:true,size:1024}],containers:[],warnings:[],roots:[],available:true}};
}return new Response(JSON.stringify({result}));};
const button=(text:string,parent:ParentNode=document)=>{const node=Array.from(parent.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent?.trim()===text);if(!node||!node.getClientRects().length)throw Error('Missing visible button '+text);return node;};
const section=(key:string)=>document.querySelector<HTMLButtonElement>(`.manager-switcher button:nth-child(${({plugins:1,apk:2,terminal:3,files:4,settings:5} as any)[key]})`)!.click();
const shot=async(name:string)=>{await wait(1200);console.log('FRAMELY_PREVIEW_INSTALL_MOBILE_'+name);await wait(200);};
const noOverflow=()=>{if(document.documentElement.scrollWidth>innerWidth+1)throw Error('Page horizontal overflow');const content=document.querySelector<HTMLElement>('.content');if(content&&content.scrollWidth>content.clientWidth+2)throw Error('Content overflow '+location.hash+' '+content.scrollWidth+'/'+content.clientWidth);};
(window as any).runInstallReviewChecks=async()=>{try{
await until(()=>document.querySelector('.plugin-row'));const mobile=innerWidth<768;
if(mobile){const nav=document.querySelector('.manager-switcher')!.getBoundingClientRect();if(nav.bottom<innerHeight-4||nav.top<innerHeight-120)throw Error('Navigation not at thumb reach');if(document.querySelector('.workspace>nav')!.getBoundingClientRect().height)throw Error('Desktop subnav still visible');}
await shot('PLUGINS_EN');noOverflow();
if(mobile){document.querySelector<HTMLButtonElement>('[aria-label="Manage Frame Mic Tuner"]')!.click();await until(()=>document.querySelector('.manage-dialog'));const rect=document.querySelector('.manage-dialog')!.getBoundingClientRect();if(Math.abs(rect.bottom-innerHeight)>2)throw Error('Dialog is not a bottom sheet');await shot('PLUGIN_ACTIONS_EN');document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await until(()=>!document.querySelector('.manage-dialog'));}
section('settings');await until(()=>document.querySelector('.network-password'));
if(mobile){document.querySelector<HTMLButtonElement>('.mobile-page-picker>button')!.click();await until(()=>document.querySelector('.mobile-page-options'));button('Launcher',document.querySelector('.mobile-page-options')!).click();}else location.hash='launcher-settings';
await until(()=>document.querySelector('.launcher-settings'));await shot('SETTINGS_EN');section('files');await until(()=>document.querySelector('.file-list-scroll')?.getAttribute('aria-busy')==='false');
if(mobile){document.querySelector<HTMLButtonElement>('[aria-label="Browse folders"]')!.click();await until(()=>document.querySelector('.file-navigation-open'));await shot('FOLDERS_EN');button('Downloads',document.querySelector('.file-sidebar')!).click();await until(()=>!document.querySelector('.file-navigation-open'));await until(()=>document.querySelector('.file-table input[aria-label="notes.txt"]'));button('Filters and view').click();await until(()=>document.querySelector('.mobile-sheet'));if(!document.querySelector('.mobile-sheet .file-sort'))throw Error('Missing sort controls');document.querySelector<HTMLButtonElement>('.mobile-sheet header button')!.click();button('Create and upload').click();await until(()=>document.querySelector('.mobile-sheet'));if(!button('Upload folder',document.querySelector('.mobile-sheet')!))throw Error('Missing folder upload');document.querySelector<HTMLButtonElement>('.mobile-sheet header button')!.click();button('Select').click();document.querySelector<HTMLInputElement>('.file-table input[aria-label="notes.txt"]')!.click();await until(()=>document.querySelector('.file-mobile-selection'));button('Actions',document.querySelector('.file-mobile-selection')!).click();await until(()=>document.querySelector('.mobile-sheet'));await shot('FILE_ACTIONS_EN');document.querySelector<HTMLButtonElement>('.mobile-sheet header button')!.click();}
await shot('FILES_EN');noOverflow();section('terminal');await until(()=>document.querySelector('.terminal-manager'));document.querySelector<HTMLButtonElement>('.terminal-tabbar>button')!.click();await until(()=>document.querySelector('.terminal-connection')?.textContent==='Interactive');
if(mobile){button('Ctrl+C').click();button('Tab').click();await until(()=>input.includes('\x03')&&input.includes('\t'));}await shot('TERMINAL_EN');section('apk');await until(()=>document.querySelector('.apk-row'));await shot('APK_EN');noOverflow();
language='zh-CN';await until(()=>document.documentElement.lang==='zh-CN');await shot('APK_ZH');section('files');await until(()=>document.querySelector('.file-list-scroll'));await shot('FILES_ZH');section('settings');await until(()=>document.querySelector('.launcher-settings'));await shot('SETTINGS_ZH');noOverflow();
for(const page of ['settings','about','updates','notification-settings','sources','apk-settings','apk-containers','apk-cleanup']){location.hash=page;await wait(350);noOverflow();if(!document.querySelector('.content'))throw Error('Missing page '+page);}
if(mobile&&visualViewport){Object.defineProperty(visualViewport,'height',{value:innerHeight-300,configurable:true});visualViewport.dispatchEvent(new Event('resize'));await wait();if(getComputedStyle(document.querySelector('.manager-switcher')!).display!=='none')throw Error('Keyboard did not hide navigation');delete (visualViewport as any).height;visualViewport.dispatchEvent(new Event('resize'));await wait();}
console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e+' '+(e as Error).stack);}};
void import('../ui/src/main');console.log('FRAMELY_VIEW_READY');
+5 -4
View File
@@ -4,6 +4,7 @@ import {createRoot} from 'react-dom/client';
import {NotificationCenter,type NotificationEntry} from '../ui/src/notifications';
import {configureLanguage} from '../ui/src/i18n';
import '../ui/src/style.css';
import '../ui/src/notification-surfaces.css';
const root=createRoot(document.getElementById('root')!);
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));
const plugin={manifest:{id:'capture.clip',name:'Clip capture',description:'Capture your session',version:'1.0.0',author:'Framely',ui:{windows:{}}},enabled:true,favorite:false,order:0};
@@ -58,19 +59,19 @@ async function until(check:()=>boolean){for(let i=0;i<60;i++){if(check())return;
if(!entries[0].inInbox)throw Error('floating close removed saved message');
render(true);await wait();button('移除通知').click();await until(()=>!!document.querySelector('.inbox-empty'));
entries=[entry('expiry',{expiresAt:Date.now()+350})];render(false);await wait(700);
if(document.querySelector('.toast')||!entries[0].inInbox)throw Error('automatic close removed inbox copy');
if(document.querySelector('.toasts')||!entries[0].inInbox)throw Error('automatic close left an empty popup or removed inbox copy');
entries=[entry('persistent',{expiresAt:null,notification:{id:'persistent',title:'Persistent status',body:'Stays visible until dismissed',durationMs:0}})];render(false);await wait();
if(!document.querySelector('.toast')||document.querySelector('[role=timer]'))throw Error('persistent notification has a countdown');
entries=[entry('long',{notification:{id:'long',title:'A recording with a long title is ready to review',body:'Recording details. '.repeat(80),actions:[{id:'open',label:'Open recording'},{id:'copy',label:'Copy path'},{id:'continue',label:'Continue recording'}]}})];render(false);await wait();
const compact=document.querySelector('.toast') as HTMLElement,message=compact.querySelector('.notification-message') as HTMLElement;
if(compact.getBoundingClientRect().height>185||message.scrollHeight<=message.clientHeight)throw Error('long notification expanded instead of scrolling inside compact popup');
if(compact.getBoundingClientRect().height>233||message.scrollHeight<=message.clientHeight)throw Error('long notification expanded instead of scrolling inside compact popup');
for(const action of compact.querySelectorAll('.toast-actions button')){const bounds=action.getBoundingClientRect();if(bounds.height<44||bounds.bottom>compact.getBoundingClientRect().bottom)throw Error('compact notification action is clipped or too small');}
entries=[entry('saved',{notification:{id:'saved',title:'Recording saved',body:'Your recording is ready. Open it whenever you are ready.',actions:[{id:'open',label:'Open recording'}]}}),entry('update',{plugin:'',pluginName:'Framely',update:{version:'0.4.3-preview.1'},notification:{id:'update',title:'Update',body:'New release',actions:[{id:'ignore',label:'Ignore'},{id:'open',label:'Open updates'}]}})];
configureLanguage('en-US',[]);render(true);await wait(180);
if(!document.body.textContent!.includes('Inbox')||!document.body.textContent!.includes('Open updates'))throw Error('English translation missing');
console.log('FRAMELY_PREVIEW_INSTALL_INBOX_EN');
render(false);await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_TOAST_EN');
if([...document.querySelectorAll('.toast')].some(card=>card.getBoundingClientRect().height>185))throw Error('floating notification exceeds compact height');
if([...document.querySelectorAll('.toast')].some(card=>card.getBoundingClientRect().height>233))throw Error('floating notification exceeds compact height');
if([...document.querySelectorAll('.toast-actions button')].some(b=>b.scrollWidth>b.clientWidth))throw Error('notification action overflowed');
// Mount the real quick menu and assert the navigation actually reaches the inbox.
root.unmount();await import('../ui/src/main');
@@ -80,7 +81,7 @@ async function until(check:()=>boolean){for(let i=0;i<60;i++){if(check())return;
if(document.querySelectorAll('.plugin-row').length!==1)throw Error('quick installed list includes disabled plugins');
const notifications=[...document.querySelectorAll('nav button')].find(b=>b.textContent?.startsWith('通知')) as HTMLButtonElement;
if(!notifications)throw Error('quick inbox tab missing');notifications.click();await wait(180);
if(document.querySelectorAll('.inbox-message').length!==2||document.querySelectorAll('nav button').length!==4)throw Error('quick inbox navigation broken');
if(document.querySelectorAll('.inbox-message').length!==2||document.querySelectorAll('nav button').length!==5)throw Error('quick inbox navigation broken');
if(!document.querySelector('[role=switch][aria-label="允许通知"]')||!document.querySelector('[aria-label="通知设置"]'))throw Error('quick notification controls missing');
console.log('FRAMELY_PREVIEW_INSTALL_QUICK_INBOX');
button('打开更新').click();await until(()=>!document.querySelector('.inbox-message')?.textContent?.includes('可更新'));
+1 -1
View File
@@ -1,7 +1,7 @@
#include "plugin_navigation.h"
#include <cassert>
int main() {
for (const auto& page : {"settings","updates","about","terminal","files","apk-containers","apk-settings"}) assert(manager_navigation_page(page));
for (const auto& page : {"casting","casting-picture","casting-receive","casting-devices","settings","updates","about","terminal","files","apk-containers","apk-settings"}) assert(manager_navigation_page(page));
assert(!manager_navigation_page("unknown"));
const std::string origin="http://localhost:19626";
assert(allow_plugin_navigation(origin+"/window",origin,true,true));
+12 -2
View File
@@ -2,13 +2,18 @@
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));
const until=async(f:()=>any)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,350));};
history.replaceState(null,'','/?fixture=1#plugin:test.quick');
let polls=0,opens=0;
let polls=0,opens=0,streaming=false;
const casting={source:'screen',codec:'h265',output:'eye',eye:'steamVR',width:1920,height:1200,fps:60,bitrateMbps:8,horizontalFov:null,centerX:.5,centerY:.5,systemAudio:true,microphone:false,airplay:false,dlna:false,receiverName:'Framely'};
window.fetch=async(_url,init)=>{
const {method}=JSON.parse(String(init?.body));let result:any=true;
if(method==='status'){polls++;result={version:'0.4.3-preview.2',agreement:{version:1,accepted:true},database:{language:'en-US',plugins:{'test.quick':{manifest:{id:'test.quick',name:'Quick fixture',version:'1',author:'Test',description:'',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:true,order:0}},sources:[],safeMode:false},running:[],notifications:[],inbox:[]};}
if(method==='status'){polls++;result={version:'0.4.3-preview.2',agreement:{version:1,accepted:true},database:{casting,language:'en-US',plugins:{'test.quick':{manifest:{id:'test.quick',name:'Quick fixture',version:'1',author:'Test',description:'',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:true,order:0}},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==='plugin.open')opens++;
else if(method==='cast.status')result={running:streaming};
else if(method==='cast.start')streaming=true;
else if(method==='cast.stop')streaming=false;
else if(method==='cast.settings.save'){const params=JSON.parse(String(init?.body)).params;if(params.width!==1920||params.codec!=='h265')throw Error('Receiver switch reset stream settings');Object.assign(casting,params);}
return new Response(JSON.stringify({result}));
};
const back=()=>document.querySelector<HTMLButtonElement>('.page-title button')!;
@@ -26,6 +31,11 @@ const back=()=>document.querySelector<HTMLButtonElement>('.page-title button')!;
window.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'}));await until(()=>!!document.querySelector('.workspace'));
const third=polls;await until(()=>polls>=third+2);
if(back()||location.hash||opens!==3)throw Error('Escape reopened plugin');
const streamTab=[...document.querySelectorAll<HTMLButtonElement>('nav button')].find(b=>b.textContent==='Streaming');if(!streamTab)throw Error('Streaming tab missing');streamTab.click();await until(()=>!!document.querySelector('.cast-quick'));
const start=[...document.querySelectorAll<HTMLButtonElement>('.cast-quick button')].find(b=>b.textContent==='Start streaming')!;await until(()=>!start.disabled);start.click();await until(()=>streaming&&document.querySelector('.cast-quick-stream button')?.textContent==='Stop streaming');
const receiver=document.querySelector<HTMLButtonElement>('button[role=switch][aria-label=AirPlay]')!;await until(()=>!receiver.disabled);receiver.click();await until(()=>casting.airplay&&receiver.getAttribute('aria-checked')==='true');
document.querySelector<HTMLButtonElement>('.cast-quick-stream button')!.click();await until(()=>!streaming);
if(document.documentElement.scrollWidth>innerWidth)throw Error('Quick panel overflows viewport');
console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}
};
+64
View File
@@ -0,0 +1,64 @@
"""Exercise the production Android guard with deterministic evdev-shaped data."""
import os
import pathlib
import platform
import shutil
import subprocess
import tempfile
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
@unittest.skipUnless(platform.machine() == 'aarch64' and shutil.which('gcc'), 'AArch64 syscall guard')
class DirectGuard(unittest.TestCase):
def test_passthrough_gating_expiry_and_rumble_cancellation(self):
with tempfile.TemporaryDirectory(prefix='framely-direct-', dir='/tmp') as tmp:
work = pathlib.Path(tmp)
paths = {"FRAMELY_EVENT_PATH": work/'event', "FRAMELY_CONTROL_PATH": work/'control', "FRAMELY_READY_PATH": work/'ready'}
lib = work/'guard.so'
subprocess.run(['gcc', '-shared', '-fPIC', '-nostdlib', '-fno-stack-protector', '-mno-outline-atomics', '-Wl,--hash-style=both', *[f'-D{k}="{v}"' for k,v in paths.items()], str(ROOT/'native/gamepad_grab.c'), '-o',str(lib)],check=True)
source=work/'test.c'
source.write_text(r'''
#define _GNU_SOURCE
#include <errno.h>
#include <assert.h>
#include <fcntl.h>
#include <linux/input.h>
#include <time.h>
#include <unistd.h>
#include <stdio.h>
#include "gamepad_direct_policy.h"
int *__errno(void){return &errno;}
static uint64_t now(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);return (uint64_t)t.tv_sec*1000000000ULL+t.tv_nsec;}
int main(int argc,char**argv){
int control=open(argv[2],O_RDWR|O_CREAT,0644);assert(control>=0);
struct FramelyDirectControl c={now()+2000000000ULL,1,1};assert(pwrite(control,&c,sizeof c,0)==sizeof c);
int seed=open(argv[1],O_RDWR|O_CREAT,0644);assert(seed>=0);struct input_event e={0};e.type=EV_KEY;e.code=BTN_A;e.value=1;
assert(pwrite(seed,&e,sizeof e,0)==sizeof e);close(seed);
int fd=open(argv[1],O_RDWR);assert(fd>=0);struct input_event events[64];
assert(read(fd,events,sizeof events)==sizeof e);assert(events[0].code==BTN_A&&events[0].value==1);
// Disabling returns a neutral snapshot once, then drains subsequent input.
c.enabled=0;assert(pwrite(control,&c,sizeof c,0)==sizeof c);lseek(fd,0,SEEK_SET);
assert(read(fd,events,sizeof events)==21*sizeof e);for(int i=0;i<21;i++)assert(events[i].value==0);
lseek(fd,0,SEEK_SET);assert(read(fd,events,sizeof events)==-1&&errno==EAGAIN);
c.enabled=1;c.expires_ns=now()+2000000000ULL;assert(pwrite(control,&c,sizeof c,0)==sizeof c);lseek(fd,0,SEEK_SET);
assert(read(fd,events,sizeof events)==sizeof e&&events[0].value==1);
// A missing session lease must fail closed.
c.expires_ns=now()-1;assert(pwrite(control,&c,sizeof c,0)==sizeof c);lseek(fd,0,SEEK_SET);
assert(read(fd,events,sizeof events)==21*sizeof e);
c.expires_ns=now()+2000000000ULL;c.rumble=0;assert(pwrite(control,&c,sizeof c,0)==sizeof c);
e.type=EV_FF;e.code=3;e.value=1;lseek(fd,0,SEEK_SET);assert(write(fd,&e,sizeof e)==sizeof e);
assert(pread(fd,&e,sizeof e,0)==sizeof e&&e.value==0);
c.rumble=1;assert(pwrite(control,&c,sizeof c,0)==sizeof c);e.value=1;lseek(fd,0,SEEK_SET);assert(write(fd,&e,sizeof e)==sizeof e);
c.enabled=0;assert(pwrite(control,&c,sizeof c,0)==sizeof c);usleep(80000);
assert(pread(fd,&e,sizeof e,sizeof e)==sizeof e&&e.type==EV_FF&&e.code==3&&e.value==0);
close(fd);close(control);return 0;
}
''')
executable=work/'test'
subprocess.run(['gcc','-rdynamic','-I'+str(ROOT/'native'),str(source),'-o',str(executable)],check=True)
subprocess.run([str(executable),str(paths['FRAMELY_EVENT_PATH']),str(paths['FRAMELY_CONTROL_PATH'])],env=dict(os.environ,LD_PRELOAD=str(lib)),check=True,timeout=10)
self.assertEqual(paths['FRAMELY_READY_PATH'].read_text(),'ready\n')
if __name__=='__main__':
unittest.main()
+4
View File
@@ -49,6 +49,10 @@ bool SDL_RumbleGamepad(void*,uint16_t,uint16_t,uint32_t){return true;}
self.assertIn('SDL_USED', steam.stderr)
self.assertNotIn('OPENVR_USED', steam.stderr)
self.assertNotIn('Invalid gamepad action', steam.stderr)
direct = subprocess.run(args, env=dict(env, FRAMELY_GAMEPAD_SOURCE='steam-direct', FRAMELY_GAMEPAD_READY=str(work)), input='', capture_output=True, text=True, timeout=10)
self.assertIn('No writable Steam Input output', direct.stderr)
self.assertNotIn('OPENVR_USED', direct.stderr)
self.assertNotIn('SDL_USED', direct.stderr)
frame = subprocess.run(args, env=dict(env, FRAMELY_GAMEPAD_SOURCE='frame'), input='', capture_output=True, text=True, timeout=10)
self.assertIn('OPENVR_USED', frame.stderr)
self.assertNotIn('SDL_USED', frame.stderr)
+82
View File
@@ -0,0 +1,82 @@
"""Keep an installer launched from the UI alive when the UI is stopped."""
import json
import os
import pathlib
import subprocess
import tempfile
import unittest
ROOT = pathlib.Path(__file__).parents[1]
class InstallHandoffTests(unittest.TestCase):
def run_handoff(self, cgroup, repair=False, launch_status=0):
source = (ROOT / 'packaging/install.sh').read_text()
block = source.split('# A terminal opened by Framely', 1)[1].split('cd "$base"', 1)[0]
with tempfile.TemporaryDirectory() as temp:
folder = pathlib.Path(temp)
log = folder / 'handoff.json'
# The package path intentionally includes spaces: arguments must survive.
package = folder / 'release package'
package.mkdir()
cat = folder / 'cat'
cat.write_text('#!/bin/sh\nprintf "%s\\n" "$TEST_CGROUP"\n')
runner = folder / 'systemd-run'
runner.write_text('''#!/usr/bin/env python3
import json, os, pathlib, sys
pathlib.Path(os.environ['TEST_HANDOFF_LOG']).write_text(json.dumps(sys.argv[1:]))
sys.exit(int(os.environ['TEST_LAUNCH_STATUS']))
''')
cat.chmod(0o755)
runner.chmod(0o755)
env = dict(os.environ, PATH=str(folder) + ':' + os.environ['PATH'],
TEST_CGROUP=cgroup, TEST_HANDOFF_LOG=str(log),
TEST_LAUNCH_STATUS=str(launch_status))
result = subprocess.run(
['bash', '-e', '-c', 'base=$1; steam_user=steamos; repair=$2\n'
+ '# A terminal opened by Framely' + block
+ '\necho FOREGROUND_INSTALL', 'install-handoff', str(package),
'true' if repair else 'false'],
env=env, capture_output=True, text=True, timeout=10)
return result, json.loads(log.read_text()) if log.exists() else None, package
def test_ui_and_plugin_terminals_hand_off_before_installing(self):
for cgroup in [
'0::/system.slice/framely-session.service',
'0::/system.slice/framely.service',
'0::/user.slice/user-1000.slice/user@1000.service/app.slice/framely-ui-recovery.service',
'0::/system.slice/framely-backend-demo.service/child',
'1:name=systemd:/system.slice/framely-session.service\n0::/other',
]:
with self.subTest(cgroup=cgroup):
result, args, package = self.run_handoff(cgroup)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn('FOREGROUND_INSTALL', result.stdout)
self.assertEqual(args[-3:], ['/usr/bin/bash', str(package / 'install.sh'), 'steamos'])
self.assertIn('--no-block', args)
self.assertIn('--property=StandardInput=null', args)
self.assertIn('--property=StandardOutput=journal', args)
self.assertIn('--property=StandardError=journal', args)
self.assertNotIn('--pipe', args)
self.assertNotIn('--wait', args)
def test_repair_keeps_arguments_and_launch_failure_stops(self):
result, args, _ = self.run_handoff('0::/system.slice/framely-session.service', repair=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(args[-2:], ['--repair', 'steamos'])
result, _, _ = self.run_handoff('0::/system.slice/framely-session.service', launch_status=1)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn('FOREGROUND_INSTALL', result.stdout)
def test_ssh_and_independent_update_workers_do_not_recurse(self):
for cgroup in [
'0::/user.slice/user-1000.slice/session-42.scope',
'0::/system.slice/framely-install-123-456.service',
'0::/system.slice/framely-update-123.service',
'0::/system.slice/framely-session.service-other',
]:
with self.subTest(cgroup=cgroup):
result, args, _ = self.run_handoff(cgroup)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIsNone(args)
self.assertIn('FOREGROUND_INSTALL', result.stdout)
+54
View File
@@ -0,0 +1,54 @@
"""Keep media packaging aligned with Cargo's default and overridden output paths."""
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class MediaBuildOutput(unittest.TestCase):
def test_copies_compiled_binaries_from_cargo_target_directory(self):
cargo = shutil.which('cargo')
self.assertIsNotNone(cargo)
for setting in (None, 'target/cargo', 'absolute'):
with self.subTest(target=setting), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'tools').mkdir()
source = (ROOT / 'tools/build-media.sh').read_text()
# Native dependencies are separate from the Cargo output-copy boundary.
(root / 'tools/build-media.sh').write_text(source.split('# Native dependencies')[0])
crate = root / 'media/capture'
(crate / 'src').mkdir(parents=True)
(crate / 'Cargo.toml').write_text('[package]\nname="capture-fixture"\nversion="0.1.0"\nedition="2021"\n')
(crate / 'src/main.rs').write_text('fn main() {}\n')
env = dict(os.environ)
env.pop('CARGO_TARGET_DIR', None)
env.pop('FRAMELY_SYSROOT', None)
if setting:
env['CARGO_TARGET_DIR'] = str(root / 'absolute-output') if setting == 'absolute' else setting
subprocess.run([cargo, 'generate-lockfile', '--manifest-path', str(crate / 'Cargo.toml')], env=env, check=True)
mock_bin = root / 'mock-bin'
mock_bin.mkdir()
wrapper = mock_bin / 'cargo'
wrapper.write_text('''#!/usr/bin/env python3
import json,os,pathlib,subprocess,sys
real=os.environ['TEST_REAL_CARGO']
if sys.argv[1]=='build':
result=subprocess.check_output([real,'metadata','--locked','--no-deps','--format-version','1','--manifest-path','media/capture/Cargo.toml'])
output=pathlib.Path(json.loads(result)['target_directory'])/'release'
output.mkdir(parents=True,exist_ok=True)
for name in ['framely-capture','framely-panel-grab']:(output/name).write_text('compiled '+name)
else:os.execv(real,[real,*sys.argv[1:]])
''')
wrapper.chmod(0o755)
env.update(TEST_REAL_CARGO=cargo, PATH=str(mock_bin) + os.pathsep + env['PATH'])
subprocess.run(['bash', str(root / 'tools/build-media.sh')], cwd=root, env=env, check=True)
for name in ('framely-capture', 'framely-panel-grab'):
self.assertEqual((root / 'media/bin' / name).read_text(), 'compiled ' + name)
if __name__ == '__main__':
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
import importlib.util
import json
import pathlib
import tarfile
import tempfile
import unittest
from unittest.mock import patch
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / 'tools/q2f-runtime.py'
SPEC = importlib.util.spec_from_file_location('q2f_runtime', SCRIPT)
RUNTIME = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(RUNTIME)
class Q2FRuntimeTests(unittest.TestCase):
def test_exact_cache_is_reused_and_tampering_is_rejected(self):
with tempfile.TemporaryDirectory() as directory:
base = pathlib.Path(directory)
identity = 'overport-test-123456789abc'
payload = base / 'payload'
(payload / 'java/bin').mkdir(parents=True)
for name in ('java/bin/java', 'overportcli.jar', 'overport-installed.json', 'OVR_PORT_LICENSE'):
(payload / name).write_text(name)
files = sorted(p for p in payload.rglob('*') if p.is_file())
sums = ''.join(f'{RUNTIME.digest(p)} {p.relative_to(payload)}\n' for p in files)
(payload / 'VERSION').write_text(identity + '\n')
(payload / 'SHA256SUMS').write_text(sums)
archive = base / f'framely-q2f-{identity}-linux-arm64.tar.gz'
with tarfile.open(archive, 'w:gz') as tar:
tar.add(payload, arcname=f'framely-q2f-{identity}')
descriptor = base / 'requirement.json'
descriptor.write_text(json.dumps({'schemaVersion': 1, 'id': identity,
'archive': archive.name, 'size': archive.stat().st_size,
'sha256': RUNTIME.digest(archive), 'url': 'https://example.invalid/q2f'}))
copies = []
def copy(_requirement, destination):
destination.write_bytes(archive.read_bytes())
copies.append(destination)
with patch.object(RUNTIME, 'download', copy):
cached = RUNTIME.prepare(descriptor, base / 'storage')
self.assertEqual(cached, RUNTIME.prepare(descriptor, base / 'storage'))
self.assertEqual(len(copies), 1)
(cached / 'overportcli.jar').write_text('changed')
with self.assertRaisesRegex(ValueError, 'checksum mismatch'):
RUNTIME.prepare(descriptor, base / 'storage')
def test_archive_rejects_link_and_escape(self):
with tempfile.TemporaryDirectory() as directory:
base = pathlib.Path(directory)
identity = 'overport-test-123456789abc'
archive = base / 'unsafe.tar.gz'
for index, member in enumerate((f'framely-q2f-{identity}/../escape', f'framely-q2f-{identity}/link')):
with tarfile.open(archive, 'w:gz') as tar:
info = tarfile.TarInfo(member)
info.type = tarfile.SYMTYPE if member.endswith('/link') else tarfile.REGTYPE
info.size = 0
tar.addfile(info)
with self.assertRaisesRegex(ValueError, 'Unsafe'):
RUNTIME.extract(archive, base / f'target-{index}', identity)
if __name__ == '__main__':
unittest.main()
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
cd "$base"
# SteamOS Frame uses FFmpeg 7 (libavcodec.so.61). CI's FFmpeg may have
# a different ABI. Build only the matching development API for linking;
# never bundle or use these minimal libraries for runtime decoding.
if [[ $(pkg-config --modversion libavcodec | cut -d. -f1) != 61 ]]; then
api="$base/media/source/ffmpeg-build-api"
mkdir -p "$api"
pushd "$api" >/dev/null
../ffmpeg-7.0/configure --prefix="$api/install" --disable-everything --disable-autodetect --disable-programs --disable-doc --disable-asm --disable-static --enable-shared
make -j"${FRAMELY_BUILD_JOBS:-4}"
make install
popd >/dev/null
export PKG_CONFIG_PATH="$api/install/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
fi
meson setup media/source/gst-libav-build media/source/gst-libav-1.24.13 --prefix=/usr --libdir=lib --buildtype=release -Dtests=disabled -Ddoc=disabled
ninja -C media/source/gst-libav-build -j"${FRAMELY_BUILD_JOBS:-4}"
# Installing to staging strips build-directory RPATHs from the plugin.
DESTDIR="$base/media/source/gst-libav-install" meson install -C media/source/gst-libav-build --no-rebuild
cp media/source/gst-libav-install/usr/lib/gstreamer-1.0/libgstlibav.so media/bin/
cp media/source/gst-libav-1.24.13/COPYING media/bin/LICENSE.gst-libav
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
cd "$base"
export CARGO_HOME=${CARGO_HOME:-/tmp/framely-cast-cargo}
(
if [[ -n ${FRAMELY_SYSROOT:-} ]]; then
export PATH="$FRAMELY_SYSROOT/usr/bin:$PATH"
export PKG_CONFIG_PATH="$FRAMELY_SYSROOT/usr/lib/aarch64-linux-gnu/pkgconfig:$FRAMELY_SYSROOT/usr/share/pkgconfig"
export PKG_CONFIG_SYSROOT_DIR="$FRAMELY_SYSROOT"
export LIBCLANG_PATH="$FRAMELY_SYSROOT/usr/lib/llvm-19/lib"
export LD_LIBRARY_PATH="$FRAMELY_SYSROOT/usr/lib/aarch64-linux-gnu:$FRAMELY_SYSROOT/usr/lib/llvm-19/lib:${LD_LIBRARY_PATH:-}"
export BINDGEN_EXTRA_CLANG_ARGS="-I$FRAMELY_SYSROOT/usr/lib/llvm-19/lib/clang/19/include -I$FRAMELY_SYSROOT/usr/include -I$FRAMELY_SYSROOT/usr/include/aarch64-linux-gnu"
export RUSTFLAGS="${RUSTFLAGS:-} -L native=$FRAMELY_SYSROOT/usr/lib/aarch64-linux-gnu"
fi
cargo build --release --locked --manifest-path media/capture/Cargo.toml
)
mkdir -p media/bin
capture_target=$(cargo metadata --locked --no-deps --format-version 1 --manifest-path media/capture/Cargo.toml | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')
cp "$capture_target/release/framely-capture" "$capture_target/release/framely-panel-grab" media/bin/
# Native dependencies use the target system's GStreamer and GUPnP libraries.
python3 tools/fetch-media.py
bash tools/build-gst-libav.sh
python3 media/patch-uxplay.py media/source/UxPlay-1.73.7
cmake -S media/source/UxPlay-1.73.7 -B media/source/uxplay-build -DNO_X11_DEPS=ON -DCMAKE_BUILD_TYPE=Release
cmake --build media/source/uxplay-build -j"${FRAMELY_BUILD_JOBS:-4}"
cp media/source/uxplay-build/uxplay media/bin/
g++ -std=c++17 -O2 -shared -fPIC media/native/airplay_sink.cpp -o media/bin/libgstframely.so $(pkg-config --cflags --libs gstreamer-app-1.0 gstreamer-video-1.0)
g++ -std=c++17 -O2 -Inative/vendor media/native/receiver.cpp -o media/bin/framely-receiver $(pkg-config --cflags --libs gupnp-1.6 gstreamer-app-1.0 gstreamer-video-1.0)
mkdir -p media/bin/upnp
cp media/upnp/*.xml media/bin/upnp/
cp media/source/UxPlay-1.73.7/LICENSE media/bin/LICENSE.uxplay
cp media/source/UxPlay-1.73.7/lib/llhttp/LICENSE-MIT media/bin/LICENSE.llhttp
+1 -1
View File
@@ -10,7 +10,7 @@ g++ -std=c++17 -O2 -I"$cef" -I"$openvr" -I"$base/native" -I"$out" "$base/native/
# Independent of CEF; both binaries ship in the core/update archive.
g++ -std=c++17 -O2 -I"$openvr" "$base/native/gamepad.cpp" -L"$openvr/lib/linuxarm64" -lopenvr_api -ldl -pthread -Wl,-rpath,'$ORIGIN' -o "$out/framely-gamepad"
gcc -shared -fPIC -nostdlib -fno-stack-protector -Wl,--hash-style=both "$base/native/gamepad_grab.c" -o "$out/libframely-gamepad-grab.so"
gcc -shared -fPIC -nostdlib -fno-stack-protector -mno-outline-atomics -Wl,--hash-style=both "$base/native/gamepad_grab.c" -o "$out/libframely-gamepad-grab.so"
# Android GLES layer uses loader-supplied symbols; no host glibc dependency.
gcc -shared -fPIC -nostdlib -fno-stack-protector -fvisibility=hidden -Wl,-z,defs,--hash-style=both,-soname,libFramelyFBOCompat.so "$base/native/fbo_compat.c" -o "$out/libFramelyFBOCompat.so"
+12
View File
@@ -109,6 +109,18 @@ def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_l
else:
steam_home = pathlib.Path(steam_home)
apk = steam_home / '.local/share/framely/apk-manager'
storage_file = steam_home / '.local/share/framely/storage.json'
if storage_file.is_file() and not storage_file.is_symlink() and storage_file.stat().st_uid == steam_home.stat().st_uid:
try:
with storage_file.open('rb') as source:
raw = source.read(65537)
if len(raw) > 65536:
raise ValueError('storage setting exceeds diagnostic size limit')
configured = json.loads(raw).get('apk')
if isinstance(configured, str) and pathlib.Path(configured).is_absolute():
apk = pathlib.Path(configured)
except (OSError, ValueError, TypeError) as error:
report['warnings'].append(f'apk: invalid storage setting: {error}')
try:
with (apk / 'state.json').open('rb') as source:
raw = source.read(16 * 1024 * 1024 + 1)
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Fetch immutable, checksum-verified upstream media dependencies."""
import hashlib,io,json,tarfile,urllib.request
from pathlib import Path
base=Path(__file__).resolve().parent.parent
pins=json.loads((base/'media/dependencies.json').read_text())
for name,pin in pins.items():
payload=urllib.request.urlopen(pin['url'],timeout=120).read()
if hashlib.sha256(payload).hexdigest()!=pin['sha256']:
raise SystemExit(f'{name}: upstream archive checksum mismatch')
target=base/'media'/('bin' if name=='mediamtx' else 'source')
target.mkdir(exist_ok=True)
with tarfile.open(fileobj=io.BytesIO(payload),mode='r:*') as archive:
archive.extractall(target,filter='data')
if name=='mediamtx':(target/'mediamtx').chmod(0o755)
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more