Compare commits

...
Author SHA1 Message Date
toorux 1e62eefb85 chore: prepare 0.8.0 preview.2 release 2026-10-12 22:45:46 +08:00
toorux d5a039dad9 feat(q2f): integrate FrameBridge into Quest conversion runtime 2026-10-12 22:35:08 +08:00
toorux fd3666f931 Defer Quest APK conversion until installation 2026-10-12 20:14:56 +08:00
toorux 76eba81eff Use supported Java version in preview release workflow 2026-10-12 08:16:04 +08:00
toorux 3427786053 Prepare 0.8.0 preview.1 release 2026-10-12 08:12:12 +08:00
toorux 2e5496a2bd Make Quest conversion an optional APK install switch 2026-10-12 08:02:35 +08:00
toorux cdb24fd31d Add optional q2f runtime and Quest APK conversion 2026-10-12 01:56:07 +08:00
toorux 3039c9ef0e Improve Quest permission UI and pending interface fixes 2026-10-12 01:40:22 +08:00
toorux 41137e1d26 Add per-app custom Steam names for sideloaded APKs 2026-10-11 23:01:32 +08:00
toorux d3628b0bcf Show storage usage details and refine storage settings for 0.7.1 2026-10-11 12:21:10 +08:00
toorux a9d61df041 Align storage settings with manager design 2026-10-11 11:17:37 +08:00
toorux c647aa28a0 Handle restricted overlay directories in storage settings and migration 2026-10-11 11:07:08 +08:00
toorux 6cb052f732 Allow same-version plugin replacement and configure storage locations 2026-10-10 23:29:38 +08:00
toorux 33941cd3f4 Preserve Android host folders when Lepton recreates storage links 2026-10-10 22:17:33 +08:00
toorux 1291925884 Protect APK data during package-manager container cleanup too 2026-10-10 20:27:03 +08:00
toorux ae35a7c487 Preserve installed APK data during Lepton 3 prefix cleanup 2026-10-10 20:12:12 +08:00
toorux 2a5e77a1c1 Support Lepton 3 APK overlay directory hooks without replacing app data 2026-10-10 19:41:03 +08:00
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
toorux e8e5ab3d77 Release v0.6.0 2026-10-07 20:00:50 +08:00
toorux dc1bd07145 Release APK operation locks explicitly and isolate integration tests 2026-10-07 20:00:50 +08:00
toorux 4005216a42 Enlarge file manager icons across all grid sizes 2026-10-07 19:33:44 +08:00
toorux 425983a5fb Redesign media viewer with floating bottom controls and blank-space dismissal 2026-10-07 19:32:54 +08:00
toorux cb67ac5607 Add fullscreen media viewer and download unsupported files on open 2026-10-07 19:10:55 +08:00
toorux 6cb70b8098 Add lazy image and video thumbnails to file icon views 2026-10-07 18:59:36 +08:00
toorux 79db4a80fc Show complete folder names in file sidebar tree 2026-10-07 18:44:54 +08:00
toorux 6a0e302fd6 Remember folder sorting and add global file icon views 2026-10-07 18:43:37 +08:00
toorux cbf285d957 Update bilingual README feature overview 2026-10-07 18:37:10 +08:00
toorux a3d751541a Release v0.5.1-preview.4 2026-10-07 18:12:30 +08:00
toorux 9c7dc06bad Add opt-in per-app framebuffer compatibility fix 2026-10-07 18:07:23 +08:00
toorux be36694719 feat(gamepad): add configurable trigger button threshold defaulting to 80 percent 2026-10-07 17:43:35 +08:00
toorux 786c8b7928 Increase upload chunks to 16 MiB 2026-10-07 16:43:15 +08:00
toorux 2c7863dcfb Release Framely 0.5.1-preview.3 2026-10-07 16:36:13 +08:00
toorux 7e374a669a Emit Android L2 and R2 button events alongside trigger axes 2026-10-07 16:27:04 +08:00
toorux b1d0c739b2 Recover renamed Steam APK shortcuts after partial registration 2026-10-07 16:21:27 +08:00
toorux 73fa970021 Allocate independent APK contexts around legacy occupied names 2026-10-07 16:01:03 +08:00
toorux 73d2d4289b Release Framely 0.5.1-preview.2 2026-10-07 15:54:58 +08:00
toorux 3b7382e1a0 Pin installed release version in update notification tests 2026-10-07 15:49:00 +08:00
toorux d24aa708a7 Reduce launcher rendering scale to 1.5x 2026-10-07 15:37:38 +08:00
toorux c64bdfb374 Release Framely 0.5.1-preview.1 2026-10-07 15:25:20 +08:00
toorux 1fc2e32f26 Handle empty Lepton sm output when checking Android external storage 2026-10-07 15:24:19 +08:00
toorux 3d2899fb6e Use quiet Steam keyboard clicks for UI sound feedback 2026-10-07 14:32:57 +08:00
toorux 6589f85ec6 List named Remote Play hosts in redesigned localized launch chooser 2026-10-07 14:32:50 +08:00
toorux e8c406ffca Disable UI sound feedback by default while preserving preferences 2026-10-07 14:31:56 +08:00
toorux 16fb76c579 Move compact plugin install button before manager tabs 2026-10-07 14:16:47 +08:00
toorux c75896541c Release Framely 0.5.0-preview.2 2026-10-07 14:12:48 +08:00
toorux 36e1c04758 Fix native host poll regression test request order 2026-10-07 14:11:15 +08:00
toorux 882b22aa62 Release Framely 0.5.0-preview.1 2026-10-07 13:57:02 +08:00
toorux 782b6ab419 Use consistent typed placeholders for missing application icons 2026-10-07 13:50:31 +08:00
toorux b22d7e9fc3 Make retained-container APK uninstall readiness and cleanup retry-safe 2026-10-07 13:45:23 +08:00
toorux accae9cfbf Include APK and Lepton Android logs in diagnostic exports 2026-10-07 13:35:35 +08:00
toorux 70862a79d8 ui(apk): simplify installation review and update confirmation 2026-10-07 13:27:06 +08:00
toorux 386c131a66 feat(apk): offer exclusive container deletion during uninstall 2026-10-07 13:24:12 +08:00
toorux 9b5e25cdf9 Add configurable UI haptic and Steam sound feedback 2026-10-07 13:21:14 +08:00
toorux dec35d7529 Launch flat APK tasks in fullscreen without restarting games 2026-10-07 13:09:30 +08:00
toorux 45de17f617 Fix Steam gamepad startup and device event processing 2026-10-07 13:09:23 +08:00
toorux 7c130038f6 Clean owned container artifacts and remove Steam library artwork 2026-10-07 13:04:14 +08:00
toorux 3ce1a9142f Finish APK installation runtime before native Steam launch 2026-10-07 12:28:09 +08:00
toorux 78010a93d8 feat(gamepad): bridge Steam Input and Android rumble 2026-10-07 12:14:56 +08:00
toorux d2531168fa chore(release): bump core and installer preview versions 2026-10-07 11:57:25 +08:00
toorux fe7d0f6df5 fix(gamepad): match Xbox Android X and Y scan codes 2026-10-07 11:53:21 +08:00
toorux fbd3f9cfa3 feat(launcher): offer local and Remote Play launch choices 2026-10-07 11:46:11 +08:00
toorux 6a50b4a74b feat(apk): make persistent Steam entries optional 2026-10-07 11:45:58 +08:00
toorux 0359f24879 feat(installer): add persistent system and custom proxy settings 2026-10-07 11:35:35 +08:00
toorux d83397ac20 feat(launcher): show delayed two-stage icon hold progress 2026-10-07 11:35:35 +08:00
toorux 6d2b4ca0a0 fix(apk): label containers by app and offer close action during install 2026-10-07 11:35:35 +08:00
toorux 77a10fbdd3 fix(input): give Dock entry priority over launcher gaze presses 2026-10-07 11:25:51 +08:00
toorux 9405d4798d fix(dock): reject stale hold timing and draw blue border progress 2026-10-07 11:22:03 +08:00
toorux 550ca817e5 fix(dock): correct texture orientation and separate hold feedback timing 2026-10-07 11:15:52 +08:00
toorux de65f8dbef fix(dock): stabilize hold textures and refine progress ring 2026-10-07 11:10:42 +08:00
toorux 3a76271878 fix(launcher): use compositor fades and reset interrupted visibility 2026-10-07 03:15:41 +08:00
toorux ce3b460fcb feat(dock): use click and hold entry gestures with progress ring 2026-10-07 03:02:01 +08:00
toorux 09320554bb release: prepare v0.4.5-preview.1 2026-10-07 02:57:29 +08:00
toorux 5a69f95cc7 fix(apk): isolate gamepad identities and route native input independently 2026-10-07 02:57:26 +08:00
toorux 96986f533e fix(apk): release native gamepad after confirmed launch exit 2026-10-07 02:36:05 +08:00
toorux 5a2bb2dacd fix(apk): retain gamepad and storage ownership across native launch 2026-10-07 02:28:27 +08:00
toorux 0ec7afeafa fix(apk): use data-preserving native Steam launches and app artwork 2026-10-07 02:15:02 +08:00
toorux e205d3bae6 Avoid syncing unchanged APK inventory state 2026-10-07 02:00:47 +08:00
toorux efe5f9b3e9 docs(apk): record verified Wayland close limitation of Steam wrapping 2026-10-07 01:40:32 +08:00
toorux dfce767894 fix(apk): allow Steam cold launch after transient container removal 2026-10-07 01:36:59 +08:00
toorux 17af2ebfd5 fix(apk): use Steam-compatible temporary Devkit responses 2026-10-07 01:32:42 +08:00
toorux d31915e4ee Optimize Android resource storage and persist shader cache with Steam launch integration 2026-10-07 01:25:43 +08:00
toorux d180622a53 Optimize APK and file manager uploads with concurrent chunks 2026-10-07 00:50:36 +08:00
toorux df9969ee58 fix(apk): reject stale running state after container processes exit 2026-10-07 00:36:39 +08:00
toorux fe7edfea3f refactor(apk): move global preferences into dedicated settings page 2026-10-07 00:35:33 +08:00
toorux 82e1e66047 feat(apk): add conservative container lifecycle controls 2026-10-07 00:25:00 +08:00
toorux fcaaa561ce fix(launcher): restore Dock windows and add bounded UI scheduling priority 2026-10-07 00:02:06 +08:00
toorux 831f3707d3 fix(apk): reuse hidden containers and hand off launched windows promptly 2026-10-07 00:00:15 +08:00
toorux 98bb7cfa6c perf(apk): reuse verified installations and batch launch checks 2026-10-06 23:50:28 +08:00
toorux d1d55ee391 fix(apk): wait for Android boot and storage before app startup 2026-10-06 23:25:54 +08:00
toorux 541066da6d Keep unavailable APK launcher icons visible across container handoffs 2026-10-06 22:38:50 +08:00
toorux bdc2148619 Harden gamepad cleanup and isolate concurrent APK tests 2026-10-06 22:32:19 +08:00
toorux 0871352331 Extract high-density APK launcher icons 2026-10-06 22:31:16 +08:00
toorux ca5beaf95e Guard Lepton storage setup and preserve partial game updates 2026-10-06 22:27:49 +08:00
toorux 127976d8b7 Require independent containers for new APK applications 2026-10-06 22:26:19 +08:00
toorux 435f8cc839 Reuse running APK sessions and preserve input after launch failure 2026-10-06 22:25:04 +08:00
toorux c88da85414 Retain Lepton gamepads through application handoffs 2026-10-06 22:22:36 +08:00
toorux 5d713d4838 Inspect Frame-local APK selections without browser upload 2026-10-06 22:14:30 +08:00
toorux 45c12b26b3 Preserve APKs and downloaded resources across Lepton container restarts 2026-10-06 22:10:03 +08:00
toorux 651663ad1d Add opt-in Frame controller input for Lepton APKs 2026-10-06 21:52:39 +08:00
toorux 26643ed11d Restore subtle translucent backgrounds across manager pages and terminal 2026-10-06 20:46:27 +08:00
toorux 1b95f3a14a Keep file quick access fixed above independently scrolling folder tree 2026-10-06 20:39:08 +08:00
toorux e71ba84f1d Highlight installer management action and open panel after first installation 2026-10-06 20:30:54 +08:00
toorux e9384c0cfc Fix file sidebar scrolling and navigation with persistent resizing and context menus 2026-10-06 20:30:51 +08:00
toorux 0c0f23dee0 Redesign terminal workspace and add copy and paste shortcuts 2026-10-06 20:30:41 +08:00
toorux 3a9dc0a5a5 fix: fill manager content with the file workspace 2026-10-06 20:12:07 +08:00
toorux b925856276 refactor: redesign file manager as a compact desktop workspace 2026-10-06 20:10:21 +08:00
toorux 8769717242 fix: separate file quick access and tree in a horizontal layout 2026-10-06 20:02:47 +08:00
toorux 32c3992f95 docs: document manager terminal and file workflows in both languages 2026-10-06 19:57:15 +08:00
toorux 948836c99a fix: handle file navigation and transfer boundaries safely 2026-10-06 19:57:03 +08:00
toorux ae096ba3a1 fix: show terminal close errors inside confirmation 2026-10-06 19:56:57 +08:00
toorux 2664d953cd feat: add user file management, transfers, editing and media preview 2026-10-06 19:47:24 +08:00
toorux e32d3d9155 fix: keep local terminal usable without a network connection 2026-10-06 19:47:22 +08:00
toorux abe7f6e49d feat: add persistent user terminal sessions to manager 2026-10-06 19:45:43 +08:00
toorux 1df96e1de7 refactor: organize manager navigation into five sections 2026-10-06 19:12:15 +08:00
toorux 590ce561eb fix: crop all launcher artwork to centered circles 2026-10-06 18:54:49 +08:00
toorux f937260387 fix: improve APK parsing, container launches and window orientation 2026-10-06 18:54:20 +08:00
toorux fb169e9cdb feat: refine launcher glass and startup feedback with APK display modes 2026-10-06 18:07:44 +08:00
toorux 88261cd5ae feat: add gaze calibration and refine launcher appearance and APK startup 2026-10-06 17:21:55 +08:00
toorux f102d78804 chore: release 0.4.4-preview.1 2026-10-06 01:50:03 +08:00
toorux 7719ddd9a8 Merge feat/apk-manager into main 2026-10-06 01:45:35 +08:00
toorux a04c445210 feat: provide native plugin scrolling and refine launcher interactions 2026-10-06 01:45:35 +08:00
toorux 57d70f807b feat: add Frame file picker and refine launcher drag paging 2026-10-06 01:19:20 +08:00
toorux 142fd2a345 fix: prioritize the middle row in launcher layouts 2026-10-06 00:59:29 +08:00
toorux bf73601f9c fix: harden sideload management and add launcher install shortcut 2026-10-06 00:56:16 +08:00
toorux dd990dd401 Merge main into feat/apk-manager 2026-10-06 00:11:29 +08:00
toorux 66fe29b024 chore: release v0.4.3-preview.6 2026-10-06 00:06:26 +08:00
toorux 85017b2c54 fix: isolate launcher operations and bound application handoffs 2026-10-06 00:05:29 +08:00
toorux 5e0bb582f0 feat: add Lepton APK installation and management 2026-10-05 23:52:32 +08:00
toorux 4fb5eeb5a7 Update README screenshots and installation instructions 2026-10-05 22:39:19 +08:00
toorux 3bbc502481 Fix batch install test ownership for CI runners 2026-10-05 22:12:54 +08:00
toorux 0037d4f0ba Release 0.4.3-preview.4 with smoother launcher and page scrolling 2026-10-05 22:02:55 +08:00
toorux 0d19314106 Release 0.4.3-preview.3 with launcher search and UI refinements 2026-10-05 21:04:49 +08:00
toorux e2348280c1 Clear uninstall safe mode guard after successful cleanup and reinstall 2026-10-05 19:28:34 +08:00
toorux 148bb96ab6 Release 0.4.3-preview.2 with spatial launcher and diagnostic exports 2026-10-05 19:22:32 +08:00
toorux c59fab881a Release 0.4.3-preview.1 with notification inbox and headset interaction fixes 2026-10-05 12:53:07 +08:00
toorux 4108d4e5be Show update confirmation in a modal and release 0.4.2-preview.12 2026-10-05 01:05:10 +08:00
toorux 1c6e36e63b Hide disabled apps in quick panel and release 0.4.2-preview.11 2026-10-05 00:45:57 +08:00
toorux 799433e041 Let plugins close their own surfaces and provide window chrome 2026-10-05 00:44:05 +08:00
toorux e02fde75f4 Automate verified updates and detect installer system proxies 2026-10-05 00:16:01 +08:00
toorux c4f672cba7 Embed installer icons and remove external assets from archives 2026-10-04 23:51:37 +08:00
toorux e54689bff6 Split CEF runtime packages and release preview updates 2026-10-04 23:32:36 +08:00
toorux fb1f67f39f Refresh app icons and add plugin library shortcut 2026-10-04 22:57:32 +08:00
351 changed files with 42016 additions and 699 deletions

No files matched your search

+78 -6
View File
@@ -10,7 +10,33 @@ concurrency:
group: framely-release-${{ github.ref }}
cancel-in-progress: false
jobs:
framebridge:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Build pinned FrameBridge adapter from source
run: |
mkdir -p target/q2f-framebridge/source target/q2f-framebridge/artifacts/{arm64-v8a,armeabi-v7a}
curl -fsSL --retry 3 -o target/q2f-framebridge/source.tar.gz https://github.com/spoopyghosty0/frameport/archive/80ef9ca6a77eed1ca6b2cc6f746d9897e4178b4e.tar.gz
echo 'ea90ea8dd2f0fe49d54d2df5cc61c1876c9562e55fb40fce2651b1d5594dab7c target/q2f-framebridge/source.tar.gz' | sha256sum -c
tar -xzf target/q2f-framebridge/source.tar.gz -C target/q2f-framebridge/source --strip-components=1
FRAMEPORT_ARTIFACTS_OUT="$PWD/target/q2f-framebridge/artifacts" python3 target/q2f-framebridge/source/native/build.py --only adapter
for abi in arm64-v8a armeabi-v7a; do
test -s "target/q2f-framebridge/artifacts/$abi/libopenxr_loader_generic.so"
readelf -W --dyn-syms "target/q2f-framebridge/artifacts/$abi/libopenxr_loader_generic.so" | grep xrGetInstanceProcAddr
done
mkdir -p target/framebridge-upload/artifacts/{arm64-v8a,armeabi-v7a}
cp target/q2f-framebridge/source.tar.gz target/framebridge-upload/
for abi in arm64-v8a armeabi-v7a; do
cp "target/q2f-framebridge/artifacts/$abi/libopenxr_loader_generic.so" "target/framebridge-upload/artifacts/$abi/"
done
- uses: actions/upload-artifact@v4
with:
name: framely-framebridge
path: target/framebridge-upload/
if-no-files-found: error
frame:
needs: [framebridge]
runs-on: ubuntu-24.04-arm
env:
CARGO_HOME: ${{ github.workspace }}/target/framely-cargo
@@ -29,7 +55,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 +80,38 @@ jobs:
key: cef-linux-arm64-154.0.32-682c378
- name: Fetch pinned CEF
run: test -d target/cef || bash tools/fetch-cef.sh
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
architecture: aarch64
- uses: actions/download-artifact@v4
with:
name: framely-framebridge
path: target/q2f-framebridge
- name: Prepare pinned q2f inputs
run: |
mkdir -p target/q2f-input
curl -fsSL --retry 3 -o target/q2f-input/cli-jar.zip https://github.com/ovrport/app/releases/download/1.2.3/cli-jar.zip
python3 - <<'PY'
import zipfile
with zipfile.ZipFile('target/q2f-input/cli-jar.zip') as source:
name = 'overportcli-1.2.3-all.jar'
with source.open(name) as data, open('target/q2f-input/' + name, 'xb') as output:
import shutil
shutil.copyfileobj(data, output)
PY
curl -fsSL --retry 3 -o target/q2f-input/libraries.zip https://files.crx.moe/ovrport/tracker/releases/download/3.4.3-23204ea/libraries.zip
curl -fsSL --retry 3 -o target/q2f-input/LICENSE https://raw.githubusercontent.com/ovrport/app/c790854/LICENSE
curl -fsSL --retry 3 -o target/q2f-input/source.tar.gz https://github.com/ovrport/app/archive/c790854.tar.gz
sha256sum -c <<'EOF'
307d58a69a5c9722f912d1b6566b0dd1e0ebe1440c6d1e56cab6cb883d74fef2 target/q2f-input/overportcli-1.2.3-all.jar
6cec6c176e1aeee8d9ec4f9b4e3982f71da7b44a36a293249620073a06f00e5e target/q2f-input/libraries.zip
2db2c0f597a8be01d9742180019627b4c23fd0213f04f5034fbad53f0d296eb3 target/q2f-input/source.tar.gz
EOF
jlink --strip-debug --no-man-pages --no-header-files --compress=2 \
--add-modules java.base,java.datatransfer,java.xml,java.prefs,java.desktop,java.logging,java.net.http,jdk.crypto.ec,jdk.unsupported \
--output target/q2f-input/java
- name: Validate
run: |
npm ci
@@ -60,11 +124,19 @@ jobs:
env:
RELEASE_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.ref_name }}
Q2F_JAVA_HOME: target/q2f-input/java
Q2F_CLI_JAR: target/q2f-input/overportcli-1.2.3-all.jar
Q2F_LIBRARIES_ZIP: target/q2f-input/libraries.zip
Q2F_LICENSE: target/q2f-input/LICENSE
Q2F_SOURCE_ARCHIVE: target/q2f-input/source.tar.gz
Q2F_FRAMEBRIDGE_SOURCE: target/q2f-framebridge/source.tar.gz
Q2F_FRAMEBRIDGE_ARM64: target/q2f-framebridge/artifacts/arm64-v8a/libopenxr_loader_generic.so
Q2F_FRAMEBRIDGE_ARM32: target/q2f-framebridge/artifacts/armeabi-v7a/libopenxr_loader_generic.so
run: |
bash tools/package-release.sh target/cef
mkdir -p release-assets
archive=$(find release -maxdepth 1 -name 'framely-*-linux-arm64.tar.gz' -print -quit)
cp "$archive" 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/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 +165,8 @@ jobs:
RELEASE_TAG: ${{ github.ref_name }}
run: |
cd release-assets
sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json > SHA256SUMS
printf 'Framely %s\n\nInstall on Frame over SSH using the repository install.sh.\n\nSHA256SUMS verifies downloaded archives; each Framely archive also includes an internal SHA256SUMS.\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 +174,5 @@ jobs:
latest=false
fi
gh release create "$RELEASE_TAG" --verify-tag --title "Framely $RELEASE_TAG" --notes-file release-notes.txt --draft --latest=false --prerelease="$prerelease"
gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json
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
+611 -11
View File
@@ -8,6 +8,26 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[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"
@@ -79,12 +99,43 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bincode"
version = "2.0.0-rc.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb50c5a2ef4b9b1e7ae73e3a73b52ea24b20312d629f9c4df28260b7ad2c3c4"
dependencies = [
"bincode_derive",
"serde",
]
[[package]]
name = "bincode_derive"
version = "2.0.0-rc.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a45a23389446d2dd25dc8e73a7a3b3c43522b630cac068927f0649d43d719d2"
dependencies = [
"virtue",
]
[[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"
@@ -106,6 +157,24 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.5.1"
@@ -113,6 +182,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -122,6 +193,12 @@ 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 = "chunked_transfer"
version = "1.5.0"
@@ -183,6 +260,12 @@ dependencies = [
"libc",
]
[[package]]
name = "crawdad"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fbd1ecd2ed790e11c8fbe034f9b3e7687404818d1bdfd8218d26ec645ec7c5"
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -208,6 +291,21 @@ dependencies = [
"typenum",
]
[[package]]
name = "csv-core"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
dependencies = [
"memchr",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
@@ -241,6 +339,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -263,6 +367,36 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror 1.0.69",
"winapi",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -276,7 +410,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"miniz_oxide 0.9.1",
"zlib-rs",
]
@@ -291,24 +425,33 @@ dependencies = [
[[package]]
name = "framely"
version = "0.4.2-preview.8"
version = "0.8.0-preview.2"
dependencies = [
"anyhow",
"base64",
"clap",
"flate2",
"hex",
"image",
"libc",
"pbkdf2",
"rand",
"pinyin",
"portable-pty",
"rand 0.8.8",
"semver",
"serde",
"serde_json",
"sha2",
"tar",
"tempfile",
"tiny_http",
"tungstenite",
"unicode-normalization",
"ureq",
"url",
"vibrato",
"zip",
"zstd",
]
[[package]]
@@ -332,6 +475,18 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -340,7 +495,16 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
]
[[package]]
@@ -370,6 +534,22 @@ dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
@@ -480,6 +660,32 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -487,7 +693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.17.1",
]
[[package]]
@@ -502,6 +708,22 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "libc"
version = "0.2.189"
@@ -532,6 +754,16 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -542,6 +774,37 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[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 = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -570,6 +833,52 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pinyin"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e225f595052d9c46045755be4b8d7950b6d9f3c33e0c0b74ba58f11bbfa8c64b"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
@@ -597,6 +906,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.47"
@@ -606,6 +927,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -619,8 +946,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@@ -630,7 +967,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@@ -642,6 +989,44 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[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 = "ring"
version = "0.17.14"
@@ -662,7 +1047,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"bitflags 2.13.2",
"errno",
"libc",
"linux-raw-sys",
@@ -753,6 +1138,28 @@ dependencies = [
"zmij",
]
[[package]]
name = "serial2"
version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
dependencies = [
"cfg-if",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -764,6 +1171,22 @@ dependencies = [
"digest",
]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "2.0.1"
@@ -833,6 +1256,16 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -846,13 +1279,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[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",
"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]]
@@ -888,6 +1341,29 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"sha1",
"thiserror 2.0.21",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.20.1"
@@ -900,6 +1376,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -936,6 +1421,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -954,12 +1445,41 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vibrato"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17bb75519548ea63e35dd02e9bc3cc672a25423a9b8757bd035f5387771eb4cd"
dependencies = [
"bincode",
"bincode_derive",
"crawdad",
"csv-core",
"hashbrown 0.12.3",
"regex",
]
[[package]]
name = "virtue"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b60dcd6a64dd45abf9bd426970c9843726da7fc08f44cd6fcebf68c21220a63"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -978,6 +1498,28 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -1066,6 +1608,21 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.4"
@@ -1188,7 +1745,7 @@ dependencies = [
"flate2",
"indexmap",
"memchr",
"thiserror",
"thiserror 2.0.21",
"zopfli",
]
@@ -1215,3 +1772,46 @@ dependencies = [
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.1.0+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+10 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.4.2-preview.8"
version = "0.8.0-preview.2"
edition = "2021"
license = "AGPL-3.0-only"
@@ -20,11 +20,17 @@ url = "2"
ureq = { version = "2.12", features = ["json"] }
zip = { version = "2.4", default-features = false, features = ["deflate"] }
pbkdf2 = "0.12.2"
[dev-dependencies]
pinyin = "0.11"
vibrato = { version = "=0.5.0", default-features = false }
zstd = "0.13"
unicode-normalization = "0.1"
portable-pty = { version = "0.9", default-features = false }
tungstenite = { version = "0.28", default-features = false, features = ["handshake"] }
tar = { version = "0.4", default-features = false }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
tempfile = "3"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
[profile.release]
lto = "thin"
strip = true
+32 -7
View File
@@ -7,11 +7,33 @@
[简体中文](README.zh-CN.md)
A React plugin manager for Steam Frame, with a Rust core service, a separate CEF/OpenVR host, a network management panel, and a desktop installer. Manage plugins, sources, windows, notifications, and updates.
> Framely is still under rapid development. Features and interfaces may change between preview releases. Android app and game compatibility depends on Lepton and the app itself.
Framely provides a unified spatial launcher for Steam Frame and a management panel available both inside the headset and in a PC browser.
- **Unified launcher**: launch Android apps in Lepton, Linux desktop applications, local Steam games, and games available through Steam Remote Play.
- **APK sideloading and management**: install, update, and uninstall APKs, manage application containers and data, and provide controller input mapping and rumble feedback for Android games that support controllers.
- **Plugin installation and management**: install and manage Framely plugins, browse the plugin library, and extend Frame with additional features.
- **Remote management from your PC**: access Frame's management panel in a PC browser to manage files, APKs, and plugins. Upload, download, and edit files, and preview common image and video formats.
- **Web terminal**: use an interactive terminal running on Frame, execute shell commands, and connect to other devices with `ssh`. The web terminal does not require an SSH connection to Frame.
Framely uses a Rust core service and a separate CEF/OpenVR host, with a desktop installer for setup. It also manages plugin sources, windows, notifications, and updates.
![Framely spatial launcher](docs/images/launcher.png)
![Framely plugin library](docs/images/plugin-library.png)
## Installation
The target device is a Linux ARM64 Steam Frame. Download the `Framely Installer` for your computer from [Releases](https://github.com/SteamFramelyHomebrew/framely/releases), connect to the device, and install Framely. Alternatively, run this in the Frame SSH terminal to install the latest stable release:
The target device is a Linux ARM64 Steam Frame. Enable developer mode and SSH on Frame, connect your computer and Frame to the same network, and prepare the `steamos` account password.
### Desktop installer
Download the package for your computer from the [Framely Installer release](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.10). Linux packages are `.tar.gz`; Windows and macOS packages are `.zip`. Extract and launch the installer, enter the Frame IP and SSH port, verify the SSH fingerprint, and connect with the `steamos` account. Select the Framely version, then confirm installation. Enable testing releases to install a Preview version. Installer and device versions are independent.
### Command installation
Run the following command **in Frame's SSH terminal** to install the latest stable release:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
@@ -20,10 +42,12 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
For a Preview release, specify an actual published tag:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.2-preview.3
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.5-preview.2
```
Runtime packages include CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Open the Framely Dock icon in the SteamVR Dashboard for the quick menu, then use Settings → Manage plugins to open the manager.
Replace the example tag with the published version you want to install. Enter the sudo password when prompted. See the [installation guide](docs/user-guide/installation.md) for updates, repair, and uninstallation.
First installation uses the complete offline package with CEF; updates use the smaller core package and reuse CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Click the Framely Dock icon in the SteamVR Dashboard to open the launcher, or hold it for 600 ms to open the quick panel; a border progress indicator appears while holding. Open the manager from the launcher’s Settings button.
The network panel uses port `15915` by default. Visit `http://DEVICE_IP:15915` on your phone or computer. When no password is configured, the first visit requires setting and confirming a password before login. The device password and panel password are independent.
@@ -33,6 +57,7 @@ Start at the [documentation index](docs/README.md):
- [Install, update, repair, roll back, and uninstall](docs/user-guide/installation.md)
- [Basic usage](docs/user-guide/basic-usage.md) · [Install and manage plugins](docs/user-guide/plugins.md)
- [APK Manager](docs/user-guide/apk-manager.md) · [Space launcher](docs/launcher.md)
- [Network management panel](docs/user-guide/network-panel.md)
- [Plugin development through publishing](docs/developer-guide/README.md) · [Publish plugins and sources](docs/developer-guide/publishing.md)
- [SDK API](docs/developer-guide/sdk.md) · [Manifest configuration](docs/developer-guide/manifest.md) · [Lifecycle](docs/plugin-lifecycle.md)
@@ -68,8 +93,8 @@ python3 -m unittest discover -s tests -p 'test_*.py'
See [release maintenance](docs/releases.md) for CEF builds, integration tests, and publishing commands. Plugins support `steamos` and `root`; hash verification is not a security review, so check the source and runtime user.
## License
## License and acknowledgments
Original Framely code, including the core, installer, SDK, template, and repository examples, uses **AGPL-3.0-only**; see [LICENSE](LICENSE). Third-party dependencies and vendored files retain their respective licenses.
Framely is licensed under **AGPL-3.0-only**; see [LICENSE](LICENSE). Third-party projects retain their own licenses.
AGPL allows commercial use. Distribution requires complete corresponding source under the license; modified software that interacts with users over a network must also offer source to those users. Release source corresponds to the repository's `v<VERSION>` or `installer-v<VERSION>` tags. Redistributors must provide modifications and build scripts matching their binaries.
Thanks to Lepton for the Android runtime, [OVRPort](https://github.com/ovrport/app) for Quest APK conversion, and [FramePort's FrameBridge](https://github.com/spoopyghosty0/frameport) for OpenXR graphics and controller support. The optional q2f package includes the OVRPort and FrameBridge licenses and corresponding source.
+32 -7
View File
@@ -7,11 +7,33 @@
[English](README.md)
Steam Frame 的 React 插件管理器:Rust 核心服务、独立 CEF/OpenVR 宿主、网络管理面板和电脑端安装器。可管理插件、来源、窗口、通知和更新。
> Framely 目前仍在快速迭代中,功能和界面可能随预览版本调整。安卓应用与游戏的兼容性取决于 Lepton 和应用本身。
Framely 为 Steam Frame 提供统一的空间启动台,以及可在头显内和 PC 浏览器中使用的管理面板。
- **统一启动入口**:启动 Lepton 中的安卓应用、Linux 桌面程序、Steam 本地游戏和 Steam 远程畅玩游戏。
- **APK 侧载与管理**:安装、更新和卸载 APK,管理应用容器与数据;为支持手柄的安卓游戏提供手柄输入映射和振动反馈。
- **插件安装与管理**:安装和管理 Framely 插件,通过插件库发现并扩展功能。
- **PC 网页远程管理**:在 PC 浏览器中访问 Frame 的管理面板,管理设备文件、APK 和插件,支持文件上传、下载、编辑及常见图片和视频预览。
- **网页终端**:通过管理面板操作 Frame 的交互式终端,支持 Shell 命令和使用 `ssh` 连接其他设备,无需先通过 SSH 连接 Frame。
Framely 使用 Rust 核心服务和独立的 CEF/OpenVR 宿主,并提供电脑端安装器,也可管理插件来源、窗口、通知和更新。
![Framely 空间启动台](docs/images/launcher.png)
![Framely 插件库](docs/images/plugin-library.png)
## 安装
目标为 Linux ARM64 Steam Frame。电脑端从 [Releases](https://github.com/SteamFramelyHomebrew/framely/releases) 下载对应平台的 `Framely Installer`,连接设备后安装本体。也可在 Frame 的 SSH 终端安装最新正式版:
目标设备为 Linux ARM64 Steam Frame。先在 Frame 上启用开发者模式与 SSH,确保电脑和 Frame 在同一网络,并准备好 `steamos` 账户密码。
### 安装器安装
从 [Framely Installer 发行下载页](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.10) 下载适合电脑平台的安装器。Linux 提供 `.tar.gz`,Windows 和 macOS 提供 `.zip`。解压并启动安装器,填写 Frame 的 IP 和 SSH 端口,核对 SSH 指纹后使用 `steamos` 账户连接。选择 Framely 版本,再确认安装。安装 Preview 版本时需启用测试版本;安装器与 Frame 本体的版本号相互独立。
### 命令安装
**在 Frame 的 SSH 终端中**执行以下命令,安装最新正式版:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
@@ -20,10 +42,12 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
安装 Preview 时指定实际已发布的标签:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.2-preview.3
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.5-preview.2
```
运行包包含 CEF,设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。打开 SteamVR Dashboard 的 Framely Dock 图标进入快捷菜单,从“设置 → 管理插件”打开管理窗口。
将示例标签替换为需要安装的已发布版本,并在提示时输入 sudo 密码。更新、修复和卸载步骤见[安装指南](docs/zh-CN/user-guide/installation.md)。
首次安装使用包含 CEF 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 CEF 在线下载。设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。在 SteamVR Dashboard 中点击 Framely Dock 图标打开启动台,长按 600 毫秒打开快捷面板,按住期间显示边框进度;点击启动台右侧的设置按钮打开管理面板。
网络面板默认端口 `15915`,手机/电脑访问 `http://设备IP:15915`。未配置密码时首次访问必须设置并确认密码,保存后登录。设备密码与面板密码相互独立。
@@ -33,6 +57,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
- [安装、更新、修复、回滚与卸载](docs/zh-CN/user-guide/installation.md)
- [基本使用](docs/zh-CN/user-guide/basic-usage.md) · [插件安装与管理](docs/zh-CN/user-guide/plugins.md)
- [APK 管理](docs/zh-CN/user-guide/apk-manager.md) · [空间启动台](docs/zh-CN/launcher.md)
- [网络管理面板](docs/zh-CN/user-guide/network-panel.md)
- [插件开发到发布完整流程](docs/zh-CN/developer-guide/README.md) · [发布插件和插件源](docs/zh-CN/developer-guide/publishing.md)
- [SDK API](docs/zh-CN/developer-guide/sdk.md) · [Manifest 配置](docs/zh-CN/developer-guide/manifest.md) · [生命周期](docs/zh-CN/plugin-lifecycle.md)
@@ -68,8 +93,8 @@ python3 -m unittest discover -s tests -p 'test_*.py'
CEF 构建、集成测试和发布命令见[发行维护](docs/zh-CN/releases.md)。插件支持 `steamos` 和 `root`;哈希校验不代表安全审核,应核对来源和运行用户。
## 许可证
## 许可证与致谢
Framely 原创代码,包括本体、安装器、SDK、模板和仓库示例,采用 **AGPL-3.0-only**,全文见 [LICENSE](LICENSE)。第三方依赖和 vendored 文件保留各自许可证。
Framely 采用 **AGPL-3.0-only**,详见 [LICENSE](LICENSE)。第三方项目保留各自的许可证。
AGPL 允许商业使用;分发需按许可提供完整对应源码,修改后通过网络与用户交互还须向这些用户提供源码。发行源码对应本仓库的 `v<版本>` 或 `installer-v<版本>` 标签;重新分发者须提供与自己二进制匹配的修改和构建脚本。
感谢 Lepton 提供安卓运行环境、[OVRPort](https://github.com/ovrport/app) 提供 Quest APK 转换工具,以及 [FramePort 的 FrameBridge](https://github.com/spoopyghosty0/frameport) 提供 OpenXR 画面与手柄适配。可选 q2f 包附有 OVRPort 和 FrameBridge 的许可证及对应源码。
+2 -2
View File
@@ -1,8 +1,8 @@
# Framely branding assets / 品牌资源
The pixel window sprite is the shared identity for Framely and its installer. `framely-mark.svg` and `framely-logo.svg` are dark artwork on transparent backgrounds; `*-light.svg` variants are for dark surfaces. `framely-app-icon.svg` is the application tile on sky blue. The wordmark uses vector paths and needs no font installation.
The pixel window sprite is the shared identity for Framely and its installer. `framely-mark.svg` and `framely-logo.svg` are dark artwork on transparent backgrounds; `*-light.svg` variants are for dark surfaces. `framely-app-icon.svg` uses a charcoal tile with a sky blue sprite. Square marks center the window body, allowing the left tail to extend outward; the native Dock sprite is 10% smaller. The wordmark uses vector paths and needs no font installation.
像素窗口小精灵是本体和安装器的统一标识。`framely-mark.svg` 和 `framely-logo.svg` 为透明底深色版本,`*-light.svg` 用于深色背景;`framely-app-icon.svg` 为浅蓝底应用图标。字标已转成路径,无需安装字体。
像素窗口小精灵是本体和安装器的统一标识。`framely-mark.svg` 和 `framely-logo.svg` 为透明底深色版本,`*-light.svg` 用于深色背景;`framely-app-icon.svg` 为黑底蓝色应用图标。正方形图标以窗口主体居中,尾巴向左伸出;原生插件入口中的图形缩小 10%。字标已转成路径,无需安装字体。
After editing the SVGs, regenerate PNGs, the Windows ICO and native Dock artwork from the repository root, then commit the generated files together:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

+3 -3
View File
@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32" role="img" aria-labelledby="title desc" color="#17191c">
<title id="title">Framely</title>
<desc id="desc">Charcoal pixel window sprite on a sky blue application tile.</desc>
<desc id="desc">Sky blue pixel window sprite on a charcoal application tile, centered on its window body.</desc>
<g shape-rendering="crispEdges">
<rect width="32" height="32" rx="3" fill="#93c5ed" shape-rendering="geometricPrecision"/>
<path fill="#17191c" fill-rule="evenodd" transform="translate(3.4 4) scale(1.2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
<rect width="32" height="32" rx="3" fill="#17191c" shape-rendering="geometricPrecision"/>
<path fill="#93c5ed" fill-rule="evenodd" transform="translate(1.6 4) scale(1.2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 658 B

After

Width:  |  Height:  |  Size: 687 B

+2 -2
View File
@@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="384" height="384" viewBox="0 0 24 24" role="img" aria-labelledby="title desc" color="#93c5ed">
<title id="title">Framely</title>
<desc id="desc">Sky blue pixel window sprite for dark backgrounds.</desc>
<desc id="desc">Pixel window sprite with two square eyes and a raised hand.</desc>
<g shape-rendering="crispEdges">
<path fill="currentColor" fill-rule="evenodd" transform="translate(1.5 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
<path fill="currentColor" fill-rule="evenodd" transform="translate(0 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 548 B

After

Width:  |  Height:  |  Size: 555 B

+1 -1
View File
@@ -2,6 +2,6 @@
<title id="title">Framely</title>
<desc id="desc">Pixel window sprite with two square eyes and a raised hand.</desc>
<g shape-rendering="crispEdges">
<path fill="currentColor" fill-rule="evenodd" transform="translate(1.5 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
<path fill="currentColor" fill-rule="evenodd" transform="translate(0 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 557 B

After

Width:  |  Height:  |  Size: 555 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 189 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 B

After

Width:  |  Height:  |  Size: 278 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 B

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 B

After

Width:  |  Height:  |  Size: 480 B

+73
View File
@@ -0,0 +1,73 @@
Copyright 2000, 2001, 2002, 2003 Nara Institute of Science
and Technology.
Copyright 2023, LegalOn Technologies, Inc.
All Rights Reserved.
Use, reproduction, and distribution of this software is permitted.
Any copy of this software, whether in its original form or modified,
must include both the above copyright notice and the following
paragraphs.
Nara Institute of Science and Technology (NAIST),
the copyright holders, disclaims all warranties with regard to this
software, including all implied warranties of merchantability and
fitness, in no event shall NAIST be liable for
any special, indirect or consequential damages or any damages
whatsoever resulting from loss of use, data or profits, whether in an
action of contract, negligence or other tortuous action, arising out
of or in connection with the use or performance of this software.
A large portion of the dictionary entries
originate from ICOT Free Software. The following conditions for ICOT
Free Software applies to the current dictionary as well.
Each User may also freely distribute the Program, whether in its
original form or modified, to any third party or parties, PROVIDED
that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear
on, or be attached to, the Program, which is distributed substantially
in the same form as set out herein and that such intended
distribution, if actually made, will neither violate or otherwise
contravene any of the laws and regulations of the countries having
jurisdiction over the User or the intended distribution itself.
NO WARRANTY
The program was produced on an experimental basis in the course of the
research and development conducted during the project and is provided
to users as so produced on an experimental basis. Accordingly, the
program is provided without any warranty whatsoever, whether express,
implied, statutory or otherwise. The term "warranty" used herein
includes, but is not limited to, any warranty of the quality,
performance, merchantability and fitness for a particular purpose of
the program and the nonexistence of any infringement or violation of
any right of any third party.
Each user of the program will agree and understand, and be deemed to
have agreed and understood, that there is no warranty whatsoever for
the program and, accordingly, the entire risk arising from or
otherwise connected with the program is assumed by the user.
Therefore, neither ICOT, the copyright holder, or any other
organization that participated in or was otherwise related to the
development of the program and their respective officials, directors,
officers and other employees shall be held liable for any and all
damages, including, without limitation, general, special, incidental
and consequential damages, arising out of or otherwise in connection
with the use or inability to use the program or any product, material
or result produced or otherwise obtained by using the program,
regardless of whether they have been advised of, or otherwise had
knowledge of, the possibility of such damages at any time during the
project or thereafter. Each user will be deemed to have agreed to the
foregoing by his or her commencement of use of the program. The term
"use" as used herein includes, but is not limited to, the use,
modification, copying and distribution of the program and the
production of secondary products from the program.
In the case where the program, whether in its original form or
modified, was distributed or delivered to or received by a user from
any person, organization or entity other than ICOT, unless it makes or
grants independently of ICOT any specific warranty to the user in
writing, such person, organization or entity, will also be exempted
from and not be held liable to the user for any such damages as noted
above as far as the program is concerned.
÷÷
+7
View File
@@ -0,0 +1,7 @@
This software includes a binary version of data from
http://jaist.dl.sourceforge.net/project/mecab/mecab-ipadic/2.7.0-20070801/mecab-ipadic-2.7.0-20070801.tar.gz,
where the connection ids are remapped using CORE data in BCCWJ (except the PN category)
https://clrd.ninjal.ac.jp/bccwj/.
+21
View File
@@ -0,0 +1,21 @@
# Offline launcher search data
`ipadic.dic.zst` is the IPADic 2.7.0 dictionary compiled for Vibrato 0.5.0.
Vibrato is pinned to the matching reader format; upgrade the reader and dictionary together.
Source: https://github.com/daac-tools/vibrato/releases/download/v0.5.0/ipadic-mecab-2_7_0.tar.xz
Source archive SHA-256: `4764f983b7c3a9e1cb6a5ee945e00558efd812980e0dad61224f63ee3b0475d9`
Dictionary SHA-256: `82a6da70bb4a17be70f20ff44f650f9ad1d2b0b4fcb2f39c17fc797f92d0ab75`
Chinese pinyin uses rust-pinyin 0.11.0 and pinyin-data 0.15.0 (MIT); see `pinyin-LICENSE`.
Vibrato license notices are included as `vibrato-LICENSE-*`.
Keep `COPYING` and `NOTICE` with the dictionary. Release packaging copies this directory to
`share/search`, includes it in core updates and offline packages, and records its checksum.
The dictionary is loaded lazily by the session's search worker. No remote service is used.
中文:此目录包含 Vibrato 0.5.0 格式的 IPADic 2.7.0 离线日语读音词典。读入器与词典必须一起升级。
发布包复制到 `share/search`,包含于本体更新包和完整离线包,并纳入校验。
请保留 `COPYING` 和 `NOTICE`。词典由会话搜索线程按需加载,不使用远程服务。
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2016 mozillazg
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.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 legalforce-research
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.
+6 -1
View File
@@ -8,8 +8,10 @@ Framely manages plugins on Steam Frame through device services, a SteamVR interf
1. [Install, update, repair and uninstall](user-guide/installation.md): desktop installer, SSH, Preview builds, local packages and rollback.
2. [Basic usage](user-guide/basic-usage.md): Dock, manager, enabling plugins, favorites and safe mode.
3. [Install and manage plugins](user-guide/plugins.md): store, sources, subscriptions, local packages and updates.
3. [Install and manage plugins](user-guide/plugins.md): plugin library, sources, subscriptions, local packages and updates.
4. [Network panel](user-guide/network-panel.md): address, first password setup, login and phone/desktop access.
5. [APK Manager](user-guide/apk-manager.md): sideload installation, updates, containers, retained data and cleanup.
6. [Terminal and files](user-guide/terminal-and-files.md): user-owned terminal sessions, file operations, recycle bin, transfers and previews.
## Plugin development and publishing
@@ -33,3 +35,6 @@ Framely manages plugins on Steam Frame through device services, a SteamVR interf
- [Framely releases and updates](releases.md)
- [Desktop installer](../installer/README.md)
- [Space launcher](launcher.md)
- [Launcher actions and host compatibility](developer-guide/launcher.md)
+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 环境。
+4
View File
@@ -82,3 +82,7 @@ Inspect Framely status and systemd logs. Do not mix debug messages into backend
Default GitHub Releases use tag `v<version>` and asset `<id>-<version>.framely`; source may omit `downloadUrl`. Community registration derives it automatically. Prepare a temporary packaged Manifest containing the final URL as shown in [publishing](publishing.md), because current `framely pack` does not read Git remotes. Set an explicit URL for custom downloads. Publish the Release/assets with usage, runtime-user details, changes and actual test coverage, then host a catalog or register a fixed source commit. Database registration also checks expected whole-package hashes and field compatibility.
For updates: bump version → regenerate the release Manifest (update explicit custom URLs if used) → rebuild/verify → device test → new Release → update the source pin. Never replace old assets.
## Launcher actions and host compatibility
[Launcher actions and host compatibility](launcher.md)
+73
View File
@@ -0,0 +1,73 @@
# Launcher, actions and host compatibility
[简体中文](../zh-CN/developer-guide/launcher.md) · [Manifest](manifest.md) · [SDK](sdk.md)
Available from Framely **0.4.3-preview.2**. Existing plugins need no changes: a short press opens their quick page and a long press offers Quick panel, Manage plugin and Uninstall plugin. A missing quick page disables that menu item. Uninstall always asks for confirmation.
## Host version requirement
```json
"engines": {"framely": ">=0.4.3-preview.2 <0.5.0"}
```
`engines.framely` is a mandatory host compatibility check, separate from `apiVersion` (the protocol version). It is optional for existing plugins and required when using launcher declarations. A declared launcher feature must have an explicit version floor of `0.4.3-preview.2` or newer.
Ranges use the same parser as plugin dependencies: exact versions (`0.4.3` or `=0.4.3`), comparisons, whitespace/comma intersections, `^`, `~`, and wildcards. `||` and hyphen ranges are not supported. A normal range excludes preview versions; to include a preview, explicitly name its major/minor/patch and prerelease in a comparator. For example `>=0.4.3-preview.2 <0.5.0` matches later 0.4.3 previews and compatible stable releases, but does not automatically include 0.4.4 previews. Build metadata does not affect matching.
The host checks before installation side effects, updates, version rollback, and runtime startup. Incompatible versions remain visible in the library but cannot be installed; dependency resolution skips them. Downgrading Framely preserves incompatible plugins and their enabled preferences while blocking execution. Published older hosts reject the unknown fields rather than run a new-format package; their error messages cannot be retroactively upgraded.
## Entry behavior
```json
"ui": {
"quickPage": "page.js",
"windows": {"main": {"entry": "page.js", "title": "Example"}},
"launch": {
"launcher": {"type": "window", "window": "main"},
"quickPanel": {"type": "quickPage"},
"manager": {"type": "window", "window": "main"}
}
}
```
Omitted entry behavior opens the plugin's quick page. The manager's separate management button is unaffected. The built-in **Quick panel** menu action always opens the quick page regardless of entry configuration. Window references must name a declared window; a `quickPage` target requires a declared quick page.
`plugin.open` retains its existing backend-start meaning. It does not apply entry navigation.
## Long-press actions
Declare buttons in order; Framely renders them above its three built-in actions. At most 16 custom actions are allowed, with unique valid IDs and nonempty labels up to 120 bytes. Labels are developer-provided text; choose localized content in your published package as appropriate.
```json
"launcherActions": [
{"id": "open", "label": "Open window", "target": {"type": "window", "window": "main"}},
{"id": "connect", "label": "Connect", "target": {"type": "backend", "method": "connect", "params": {"profile": "default"}}},
{"id": "notify", "label": "Send notification", "target": {"type": "frontend", "entry": "actions.js"}}
]
```
A backend target requires a backend and receives `{params, context}`; reserved `framely.*` methods are prohibited. A frontend target names a hashed payload bundle. It runs in a dedicated sandbox only when invoked, without rendering the quick page, and uses the existing scoped SDK capabilities. Frontend actions have a 90-second host deadline; failures stay visible for retry. Actions do not gain plugin management or arbitrary host-command privileges.
Build a separate `actions.ts` bundle (the scaffold's build command does this automatically):
```ts
import {registerLauncherActions, framely} from '@framely/sdk';
registerLauncherActions({
notify: async context => {
await framely.notifications.send({
id: 'hello', title: 'Hello', body: `From ${context.source}`, inbox: true,
});
},
});
```
## Startup context
```ts
const initial = await framely.ui.launchContext.get();
const unsubscribe = framely.ui.launchContext.onChanged(context => {
console.log(context.source, context.trigger, context.actionId);
});
```
`source` is `launcher`, `quickPanel`, or `manager`; `trigger` is `shortPress` or `menuAction`; custom menu actions additionally include `actionId`. Initial context is available after loading, and subsequent activations of a reused page/window send `ui.launch`. Frontend action callbacks receive context directly; backend actions receive it with their parameters. A backend `onStart` lifecycle context also includes `launchContext` when startup was caused by an entry launch. Old calls without a known entry use the quick-panel default.
+6 -2
View File
@@ -39,7 +39,7 @@ Leave source `files` empty. `framely pack` scans payload and supplies hashes. At
| `screenshots` | Default empty; up to eight payload PNG/JPEG paths |
| `authorUrl` / `documentationUrl` / `homepage` | Optional HTTP/HTTPS links, no embedded credentials |
| `downloadUrl` | Optional fixed-version HTTPS package URL; GitHub database source may omit it for repository/version/ID derivation, but community packages need the resolved URL; see publishing |
| `publish` | Optional external store image configuration |
| `publish` | Optional external plugin library image configuration |
| `backend` / `lifecycle` / `ui` | Optional backend/hooks/pages |
| `dependencies` / `optionalDependencies` / `conflicts` | Default empty maps |
| `exclusiveResources` | Default empty array; matching names prevent simultaneous enabling |
@@ -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` |
@@ -96,3 +96,7 @@ Dependencies use ranges or `{version, source}`, with source pointing to an HTTPS
Manifest JSON is limited to 256 KiB and payload paths to 512 bytes. Payload paths allow ASCII letters/digits and `/._-+` only. Absolute paths, empty segments, `.`/`..`, symlinks and undeclared archive files are forbidden. Successful validation is not a safety review; users still assess runtime privileges.
`backend.uiVisibilityEvents` (boolean, default false) subscribes to manager-only `framely.ui.visibility` RPCs. Reply promptly. Delivery does not depend on mounted pages and requires a host supporting visibility events.
## Launcher actions and host compatibility
[Launcher actions and host compatibility](launcher.md)
+2 -2
View File
@@ -77,7 +77,7 @@ Automatic URLs always require a Release digest, even when a hash is supplied, an
### Icons and window compatibility
For the community database, set top-level `icon: "icon.png"` and include the same PNG path in source and payload. The database derives a GitHub Raw URL from the pinned repository/commit/path and verifies it against the packaged icon; `publish.icon` is unnecessary. Self-hosted catalogs use fixed HTTPS `publish.icon` / `publish.screenshots`. See [store metadata](../plugin-development.md).
For the community database, set top-level `icon: "icon.png"` and include the same PNG path in source and payload. The database derives a GitHub Raw URL from the pinned repository/commit/path and verifies it against the packaged icon; `publish.icon` is unnecessary. Self-hosted catalogs use fixed HTTPS `publish.icon` / `publish.screenshots`. See [plugin library metadata](../plugin-development.md).
The community database supports window `entry`, `title`, `dockIcon`, `localWeb`, `width`, `height`, `widthMeters`. Pixel width is an integer 640–2560, height an integer 360–1440, and physical width follows the core's float32 validation for 0.4–4.0 meters. Omitted dimensions normalize to 1600×900 and 3 meters, matching pack output. Custom dimensions participate in source/package comparison; undeclared changes are rejected. The template's default standalone window passed actual packaging and database validation. Registration still requires whole-package hash, ownership and other field checks.
@@ -92,7 +92,7 @@ mkdir -p target/plugin-catalog
cargo run --locked -- catalog --name 'My source' --base-url https://example.org/packages --packages ./packages --output ./target/plugin-catalog/catalog.json
```
Replace `example.org` with your HTTPS address. The CLI computes whole-package SHA256 and generates JSON; it does not upload packages or copy images. Without external `publish.icon`, the local CLI does not convert a packaged icon into a store URL.
Replace `example.org` with your HTTPS address. The CLI computes whole-package SHA256 and generates JSON; it does not upload packages or copy images. Without external `publish.icon`, the local CLI does not convert a packaged icon into a plugin library URL.
**Upload the entire generated directory, not only `catalog.json`:**
+41 -4
View File
@@ -25,7 +25,8 @@ registerPlugin({QuickPage, windows: {main: MainWindow}});
| `framely.windows.open(key)` | Declared key; asynchronous result | Open a declared window |
| `framely.windows.close(key)` | Key; asynchronous result | Close a window without disabling the backend |
| `framely.notifications.send(notification)` | Notification; asynchronous result | Send or replace by ID |
| `framely.notifications.remove(id)` | Notification ID; asynchronous result | Withdraw notification |
| `framely.notifications.remove(id)` | Notification ID; asynchronous result | Close the popup and remove the inbox copy |
| `framely.notifications.dismiss(id)` | Notification ID; asynchronous result | Close the popup, retaining the inbox copy |
| `framely.dependencies()` | `Promise<DependencyStatus[]>` | Query declared dependency state |
| `framely.language.get()` | `Promise<{preference, language}>` | Language preference and effective language |
| `framely.onEvent(callback)` | Unsubscribe function | Receive forwarded events |
@@ -43,14 +44,38 @@ try {
```tsx
await framely.notifications.send({
id: 'task', title: 'Done', body: 'File processed', durationMs: 8000,
id: 'task', title: 'Done', body: 'File processed', inbox: true, durationMs: 8000,
actions: [{id: 'open', label: 'Open', icon: '↗'}],
});
```
`id`, `title`, `body` are required. Optional `image` accepts PNG/JPEG data URLs or HTTPS, up to a 1 MiB string. At most three actions, each with `id`, `label`, `icon`. `durationMs` is 1000–60000; omission defaults to 8000 ms. Notification/action IDs follow core ID rules; action IDs must be unique within a notification. Title is at most 160 bytes, body 4096, nonempty action label 80, and action icon 16. Each plugin may send at most ten notifications per ten seconds; the global queue permits 64 concurrent notifications. Updating an existing ID does not occupy another slot.
`id`, `title`, `body` are required. `inbox` defaults to `false`: with popups enabled, only notifications explicitly sent with `inbox: true` appear in the quick panel's Notifications tab. Inbox messages survive popup expiry, closing the popup, plugin disable/restart, and Framely restart. They are removed by an action's policy, explicit removal, or plugin uninstall. Restored inbox messages do not replay as popups.
Buttons invoke backend `notification.action` with `{id, action}` and forward a page event of the same name. UI-only plugins can listen for the event. Reuse an ID to update; remove it to withdraw.
| Field | Default | Behavior |
| --- | --- | --- |
| `inbox` | `false` | Save the message in the inbox; user popup settings may also require saving it |
| `durationMs` | `8000` | Popup countdown in milliseconds; `1000-60000`, or `0` to stay open until dismissed |
| `actions[].closeOnClick` | `true` | Close the popup after this action succeeds |
| `actions[].removeFromInboxOnClick` | `true` | Remove the inbox copy after this action succeeds |
User notification settings take priority over sender preferences. If the global popup switch or this plugin's popup switch is off, Framely suppresses the popup and saves the message in the inbox even when `inbox` is false. Reading the inbox clears the launcher unread badge but leaves messages and actions available. Plugins cannot override popup or badge settings.
The two action policies are independent. For a status button that keeps the message, set both to `false`:
```tsx
await framely.notifications.send({
id: 'capture', title: 'Recording', body: 'Recording is in progress.',
inbox: true, durationMs: 0,
actions: [{id: 'status', label: 'View status',
closeOnClick: false, removeFromInboxOnClick: false}],
});
```
Popups display the remaining auto-close countdown; inbox entries show their received time. Closing a popup keeps its saved inbox copy. `remove(id)` withdraws both copies; `dismiss(id)` closes only the popup. Reuse the same ID to replace a message and restart its countdown. Failed actions leave the message available for retry. Buttons use their text `label`; the legacy `icon` string remains optional for compatibility.
Optional `image` accepts PNG/JPEG data URLs or HTTPS, up to a 1 MiB string. At most three actions, each with required `id` and `label`. Notification/action IDs follow core ID rules; action IDs must be unique within a notification. Title is at most 160 bytes, body 4096, nonempty action label 80, and optional action icon 16. Each plugin may send at most ten notifications per ten seconds; the global queue permits 64 simultaneous popups and 256 saved inbox messages. When full, sending a new message fails; remove older messages first. Replacing an existing ID does not occupy another slot.
Buttons invoke backend `notification.action` with `{id, action}` and forward a page event of the same name after a successful backend response. UI-only plugins can listen for the event. A disabled plugin's saved messages remain readable and removable, but their action buttons are disabled until it is enabled again.
## React hooks
@@ -117,3 +142,15 @@ Development preview simulates windows, notifications and some bridge operations.
`framely.ui.getVisibility()` returns `{ known, captureObscured, pageVisible, sequence, sessionId }`. `framely.ui.onVisibilityChanged(callback, onError?)` subscribes and immediately queries the current snapshot; it returns an unsubscribe function. `useVisibility()` provides the same fields and `error`. Capture visibility combines the actual visibility of the quick panel, manager and every plugin window, excluding notifications and dock icons. Remote browser pages have `pageVisible: false` and cannot report headset visibility.
Declare `backend.uiVisibilityEvents: true` to receive the reserved JSON-line RPC `framely.ui.visibility` at startup and on changes, independently of mounted pages. Reply with the request id. The backend snapshot includes `known`, `captureObscured`, `sequence` and `views`; `pageVisible` is frontend-only. A native heartbeat missing for more than two seconds makes `known` false. Capture backends should pause on `!known || captureObscured`. Existing backends do not receive this RPC without opting in. Node/TS can use `registerVisibility` from `@framely/sdk/visibility`; Python supports `serve(dispatch, visibility=callback)`.
`framely.ui.close()` closes the current native surface: the quick panel or the current plugin window. It does not stop the backend or recording. Requires a host with this bridge method.
Standalone plugin windows display the plugin page without a host title bar. Plugins provide their own title, navigation and close button using `framely.ui.close()` or `framely.windows.close(id)`. Quick panels retain host navigation.
## Launcher actions and host compatibility
[Launcher actions and host compatibility](launcher.md)
### Host page scrolling
The native CEF host installs vertical trigger-drag scrolling whenever a frame finishes loading, including existing plugin pages, nested frames and `localWeb` windows without the SDK. The shared document guard prevents duplicate installation by the host bootstrap or SDK. Laser-targeted stick scrolling also works in these windows through native wheel input. It selects the scrollable ancestors under the pointer and chains at their boundaries. Controls such as inputs, textareas, editable content and sliders are excluded. Use `data-framely-no-scroll-drag` on an element (or ancestor) whose vertical dragging is handled by the plugin; native `draggable="true"` elements are also excluded. No new manifest field or plugin rebuild is required.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

+57
View File
@@ -0,0 +1,57 @@
# Space launcher
[简体中文](zh-CN/launcher.md)
Click the Framely Dock icon with any supported click input to open the launcher. Hold it for **600 milliseconds** to open the quick panel; a border progress indicator shows the hold duration. Releasing after a long press does not also open the launcher. This replaces the primary/upper-trigger split and the trigger-swap setting.
The launcher is a native Dock dashboard panel, 3.6 meters wide, with the previous 2-meter height retained. Steam places and curves it and switches from the current Dock panel when it opens. It closes when the Dock is hidden. UI drawing is capped at 60 FPS independently of the headset refresh rate to limit pixel readback and texture upload load. This is a drawing limit, not a guaranteed measured frame rate. The launcher uses a 4320 × 2400 texture (3× the logical resolution), retains mipmap filtering for edge quality and uploads only changed regions. App ordering and search results are cached during drag animations. The icon area always reserves space for six columns and three rows, regardless of the number of apps. Each page holds up to 13 icons. Fill the first row with four icons, the second with five, then the third with four. The second row starts half a slot to the left; the third aligns with the first. Partial rows retain those starting positions: nine icons are 4 / 5, ten are 4 / 5 / 1. Collections of up to four icons use one centered row. The Done button uses the same translucent surface, border and highlights as the category selector. The visible gap beside the category selector is wider than the gap between adjacent icon artwork. The icon area is centered independently of the category selector, with a fixed 570-pixel height. All three row positions stay fixed, including when only one or two rows are populated. Button and joystick page changes slide and fade over 300 ms. Category changes slide vertically and fade in over 360 ms, with the category selector’s active surface sliding to the selected item; reduced-motion mode skips these transitions. Switching to another Dock panel replaces the launcher normally. Framely does not change other windows’ transparency or input settings. Closing the launcher returns to Steam’s Dock panel. Steam uses local cover artwork first and falls back to logos or initials. The right capsule opens the plugin library or management settings. Use the left category selector for Favorites, All, Plugins, Steam, Lepton or Desktop apps, the bottom dots to change pages, and the star on hover to favorite apps. Favorited apps show a filled blue star on hover; click it again to remove the favorite without changing the icon order. The transparent surface curves around you; only icons, names and categories are normally visible. Disabled plugins are hidden; safe mode prevents third-party plugin launch. Steam lists installed apps from the Frame's local libraries, not account-only or remote-streaming apps.
Press and release may drift by up to 36 UI pixels without changing the initial hit. Icon and category hit areas are larger than the artwork. Deliberate movement cancels stabilization. Hold a plugin icon with the primary trigger for 400 ms to open its action menu; ordinary jitter keeps the hold active. It contains developer actions plus Quick panel, Manage plugin and Uninstall plugin. Press **B** or click an unused area to close the launcher. During icon arrangement, the first press or click exits arrangement and keeps the launcher open. Hold the primary trigger and swipe horizontally: the current page follows your movement while the adjacent page slides into view. Release to snap to that page, or return to the current page if the drag is too short or reaches the boundary. By default the action menu closes ten seconds after the pointer leaves; returning cancels the timer. Configure the switch and delay (1–300 seconds) in **Manager → Launcher**. Uninstall requires confirmation and keeps plugin data.
Keep holding for **800 ms** to enter icon arrangement. Drag any icon (including Framely itself, which opens the management panel) onto another position; icons shift and automatically fill the 4 / 5 / 4 slots without gaps. The left drag edge is to the right of the category selector; the selector itself does not trigger paging. Hold a dragged icon at either edge of the icon area for 650 ms to turn a page; continue holding to move across further pages. Release to save the order, or choose **Done** to finish arranging. Closing or hiding the Dock cancels an unfinished drag. Manual icon order is preserved across favorites, search, refreshes and reopening. Favoriting never moves icons to the front; the Favorites category filters the same order. Apps without a saved position are appended in name order.
Either controller's stick switches categories vertically and pages horizontally. A gesture uses only its dominant axis; return below the neutral threshold before the next navigation step. This prevents a diagonal or simultaneous stick input from performing both actions.
Desktop applications come from the session user's XDG application directories, including Flatpak exports; hidden entries and duplicate Steam game shortcuts are omitted. Steam-installed APK applications appear in the Lepton category and launch through Steam. Sideloaded Lepton apps are discovered from installed APKs in each context's package database; empty contexts and Android system packages are excluded. These launch in their existing user-owned context. Missing Android labels/artwork fall back to the package name and initials. Downloaded but uninstalled APK files are not included. Enumeration does not start containers.
Steam launch feedback confirms handoff to Steam rather than game readiness. Plugin-library entries can specify `engines.framely`; update Framely if your version does not satisfy the shown range.
## Categories and favorites
The manager has a dedicated **Launcher** page for trigger assignment, menu timeout and the categories included in **All**. Plugins (including Framely), Steam, Lepton and Desktop apps are included by default. Turning a category off only removes it from All; its own category and Favorites remain accessible. Turning every category off leaves All empty.
Every app type can be favorited using the star shown on hover. Existing plugin and Steam favorites are preserved. Disabled plugins stay hidden; favorites for temporarily unavailable apps are retained. Each opening clears the search and resets pagination. Once local enumeration completes, Favorites opens if any favorite is currently available; otherwise All opens. Removing the final favorite while viewing Favorites shows an empty state without switching categories until the next opening.
## Search
Click the capsule at the top to open the VR keyboard. Search stays within the selected category and preserves its ordering. Clear the field to restore its apps. While the keyboard is open, launcher stick navigation and background dismissal are paused; B closes the keyboard first. The icon canvas keeps its fixed size and position while searching.
Search accepts original names, English word initials, Chinese pinyin and Japanese romaji / syllable initials. Matching ignores case, width, tone marks, spaces and punctuation; it supports prefixes and continuous fragments. Examples: `toushi`, `tiaose` and `tsts` find **透视调色**; `sakura` and `skr` find **さくら**; `toukyou` finds **東京**; `hla` finds **Half-Life: Alyx**. Chinese alternate character readings are supported within a bounded index; uncommon names or compound readings may differ from the intended pronunciation.
Indexes are built asynchronously and cached on the device. Chinese uses offline pinyin data; Japanese uses the bundled Vibrato / IPADic reading dictionary. Han names receive both Chinese and Japanese candidates regardless of interface language. Unknown dictionary readings retain their original text. Original-name search remains available before phonetic indexing completes. No app names are sent to an external search service.
Session API: `launcher.search.index` accepts `{names: string[]}` and returns a name-keyed map of `{terms: string[]}`. `launcher.favorite` accepts `{key, favorite}` with the same stable keys as launcher ordering. Plugin and Steam keys update their existing favorites; `framely`, `desktop:` and local `lepton:` keys use `launcherFavorites`. `launcher.settings.save` now includes `allCategories` (`plugin`, `steam`, `lepton`, `desktop`); omitted fields migrate to all four categories, while an explicit empty array means none. Search dictionaries and notices are included in both core updates and offline packages under `share/search`.
## Gaze and controller confirmation
When the laser is not pointing at the launcher, valid eye tracking can select an icon, category or action button. A target becomes highlighted after 120 ms of stable gaze with a light controller pulse. Fixation jitter is lightly filtered, nearby icon gaps accept the nearest icon, and short tracking dropouts retain hover for up to 280 ms. A dropout during a press still cancels that press. Pointing the laser at the launcher restores laser interaction immediately; Steam window positioning controls and the VR keyboard pause gaze input. This uses launcher laser focus as the fallback rule, rather than assuming Steam’s private per-hand gamepad mode flags are available through OpenVR.
Use the left controller’s lower D-pad button (the position requested as “A”), right A, or either primary trigger to confirm. Short press opens the selected item; holding an icon for 400 ms opens its menu and 800 ms enters arrangement mode. The target is locked until release, so moving your eyes does not drag or switch the pressed icon. Arrange icons with the laser and joystick afterwards. Losing eye tracking or disconnecting the confirming controller cancels the press; buttons must be released before another press can begin. Unsupported or inactive tracking does not fall back to head direction.
### Gaze calibration
In the Frame management panel, open **Launcher → Eye tracking calibration → Start calibration**. You can also use the eye calibration button above Settings in the launcher’s right rail. A simulated launcher uses the same Dock surface, icon layout, search capsule and side rails. Start the guided sequence, then look naturally at the highlighted target; no confirmation press is needed. It samples 13 icons, six category controls, four shortcuts and search, then repeats five targets to validate the correction. Allow about one minute with uninterrupted tracking. Tracking loss pauses collection; excessive jitter asks you to retry. B or Cancel exits without saving.
You can turn or move your head to see peripheral targets. Each frame pairs the measured gaze with the direction from the current eye origin to the highlighted target, using the head pose at the same nominal sampling time. The target point is located using Steam's curved-overlay intersection, and is checked again as the head or window moves. Head movement itself is not treated as fixation jitter.
Paired directions are averaged in short time slices before fitting; each target has equal weight, and robust reweighting limits the influence of mis-fixated targets. Validation measures the median residual direction for each fixation, preserving per-frame head coordinates. A single spike cannot invalidate calibration.
The result shows RMS target bias and the worst target bias in degrees. Each target also shows its 95th percentile frame error and uncorrected median error, separating systematic bias from sensor noise. Calibration summaries and paired training blocks are recorded in the session log for diagnosis. **Save calibration** persists a correction of head-relative yaw and pitch. At runtime, the corrected direction is rotated into world space with the current headset pose, preserving the eye origin before intersecting the Dock surface. It does not change laser input or the system tracker. Validation compares against highlighted target directions, not against native gaze as a reference. Failed validation cannot overwrite the previous calibration. Old viewport-coordinate calibrations are ignored; run calibration again after this update. The correction handles systematic angular bias and gain; nonlinear tracking errors may remain. OpenVR does not expose the eye camera's exact exposure timestamp, so fast motion may still have timing error.
After substantial changes to headset fit or the window distance, run calibration again.
Press feedback appears only after holding for 200 ms. The Dock entry uses a blue border indicator and opens the quick panel at 600 ms. Launcher icons show a translucent white circular progress border: the halfway tick opens the menu at 400 ms; continuing to 800 ms enters icon editing. Release or cancellation removes the indicator.
### Remote Steam games
The Steam category also shows apps installed on a connected Remote Play computer. A small Remote Play badge appears at the bottom right of their icons. The list refreshes in the background; disconnected hosts disappear, and each App ID has one icon. If both local and remote installations are available, clicking it opens a choice between launching locally and Remote Play. Favorites and manual ordering remain shared for that game. Clicking a remote icon asks Steam to stream from its current host and checks availability again before launching. Artwork uses the local Steam cache, with a placeholder when unavailable. The integration uses the running Steam client UI; if that interface is unavailable, local games remain usable.
+2 -2
View File
@@ -79,9 +79,9 @@ Manifest supports payload PNG icon ≤1 MiB/1024×1024, details/tags/changelog a
}
```
The fragment above shows explicit URLs; GitHub registration source may omit `downloadUrl`, deriving repository, `v<version>` and `<id>-<version>.framely`. The packaged Manifest still requires the final URL; current pack does not derive it. See [publishing](developer-guide/publishing.md) for temporary-Manifest commands. Community icons normally use top-level `icon`, with derived and verified pinned Raw URLs, without `publish.icon`. The local catalog CLI uses external `publish` images and does not export packaged image paths; absent external icons use the store default. Community registration pins submodule commits, obtains expected SHA256 from Release digests or source `downloadSha256`, then validates downloaded package hashes, declarations and payloads. Generated channels provide catalog/history JSON without mirroring packages/images. It does not execute author code or establish reproducible source/binary equivalence.
The fragment above shows explicit URLs; GitHub registration source may omit `downloadUrl`, deriving repository, `v<version>` and `<id>-<version>.framely`. The packaged Manifest still requires the final URL; current pack does not derive it. See [publishing](developer-guide/publishing.md) for temporary-Manifest commands. Community icons normally use top-level `icon`, with derived and verified pinned Raw URLs, without `publish.icon`. The local catalog CLI uses external `publish` images and does not export packaged image paths; absent external icons use the plugin library default. Community registration pins submodule commits, obtains expected SHA256 from Release digests or source `downloadSha256`, then validates downloaded package hashes, declarations and payloads. Generated channels provide catalog/history JSON without mirroring packages/images. It does not execute author code or establish reproducible source/binary equivalence.
Catalog CLI only emits JSON, preferring `downloadUrl` over base URL/filename and using external publish images. It does not copy resources. Stores filter keywords/tags/origins/states, retain session caches on failures and install reviewed bytes without downloading again after confirmation.
Catalog CLI only emits JSON, preferring `downloadUrl` over base URL/filename and using external publish images. It does not copy resources. The plugin library filters keywords/tags/origins/states, retains session caches on failures and installs reviewed bytes without downloading again after confirmation.
## Scaffold and preview
+4
View File
@@ -84,3 +84,7 @@ Restart defaults to `on-failure`; `never` reports failure without retrying. Limi
States include starting/running/stopping/stopped/recovering/failed. Core management is serialized. Normal SIGTERM/SIGINT attempts to stop plugins; forced termination or power loss cannot guarantee hooks. Support safe recovery on next startup.
The log view includes backend and lifecycle logs; each retains a current 2 MiB file and a preceding copy. Failed pre-registration installs log to `/var/lib/framely/logs/<id>.lifecycle.log`. User data is under `/var/lib/framely/data/<id>/<identity>/`, relative to any custom core state root.
## Launch context
[Launch context](developer-guide/launcher.md)
+9 -7
View File
@@ -38,8 +38,8 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven
Device and installer share the repository with independent versions/tags/workflows:
- Device: root Cargo version, `v<version>` (currently `v0.4.2-preview.8`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.6`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
- Device: root Cargo version, `v<version>` (currently `v0.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,18 +47,18 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
```bash
# After committing and pushing the intended source:
git tag v0.4.2-preview.8
git push origin v0.4.2-preview.8
git tag v0.7.1
git push origin v0.7.1
# Independent installer release:
git tag installer-v0.4.1-preview.6
git push origin installer-v0.4.1-preview.6
git tag installer-v0.4.1-preview.10
git push origin installer-v0.4.1-preview.10
```
Prerelease suffixes create GitHub Prereleases without Latest. Installer releases are never Latest. Only stable device releases become Latest, keeping default installer/update URLs on the device product. The desktop installer filters device archive names and does not mistake ARM64 installer packages for device runtimes. Download desktop installers from their explicit installer tags.
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 runtime archive, `framely-release.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. 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.
+206
View File
@@ -0,0 +1,206 @@
# 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)
[Documentation](../README.md) · [Launcher](../launcher.md)
APK Manager installs and manages sideloaded Lepton applications on Steam Frame. Use the header switch to move between Plugin Manager and APK Manager in the headset or authenticated network panel. Visiting the device IP opens Plugin Manager by default. APK Manager has its own Apps, Containers and Data cleanup navigation. It is a file-based manager: it has no store, recommendations, download sources, or automatic APK updates.
## Requirements and discovery
Install Lepton through Steam first. Framely uses the Steam session user's Lepton and Podman; it does not install Android globally or operate as root. Lepton compatibility varies: Google services, vendor APIs and DRM may be unavailable.
Open **APK Manager → Refresh**. Discovery reads the standard Lepton context directories, saved Framely records and running containers' compatibility-data labels. It does not start containers during scanning. Steam-managed APKs appear in the container view; use Steam for their updates and removal.
For data stored elsewhere, open **Containers → Additional data locations** and add the `baked` directory containing `data_overlay/system/packages.xml`. Enter the original Lepton context name when it differs from the enclosing directory. Names cannot conflict with containers belonging to other data locations. Paths must belong to the session user. Removing a scan location does not delete its data. Custom tools can use layouts Framely cannot discover; missing locations must be registered explicitly.
Applications are identified by context plus package name. The same package in two containers is treated as two applications. A context name or location change can therefore create a different identity.
## Install and update
During APK installation, choose **Display mode → Flat window / VR app**. The initial selection follows APK detection; updates keep your previous window override. Flat mode displays the Android window, while VR mode hides it and requires the app itself to support VR. A successful installation saves the choice; failed installations preserve the previous setting.
Choose **Install APK**, select an individual `.apk`, and review its name, package, version, minimum Android SDK and target container. Inside Frame, the selected APK is inspected directly on the device without a browser upload. A private local snapshot is retained for the confirmation step; the original file is not changed. Uploads from the network panel are staged on Frame's persistent filesystem, not its limited `/tmp`. Transfer shows actual bytes; subsequent work shows named stages instead of invented percentages.
Inside Frame, file inputs in both management panels use Framely’s built-in file picker, including APKs, plugin packages and language files. It opens Downloads by default, remembers the last selected folder, and provides a lazily loaded folder tree for Home, temporary files and mounted storage, with folder navigation, path entry, search, hidden files and file-type filtering. Select a file and confirm; B/Escape cancels the picker without closing the panel. External browsers continue using their system file picker. The launcher’s **Install APK** shortcut opens APK Manager.
Framely installs packages into separate Android package directories even when they share a container. Container restarts preserve downloaded resources in Android external storage, including unfinished download fragments and update checkpoints. Unrecognized Lepton media setup hooks are rejected before they can discard those files. Whether a game resumes its download still depends on its own updater. Older single-APK mounts are repaired from the saved APK when available, and legacy OBB resource links are migrated before replacement without deleting their original target. This prevents future loss; resources already deleted by an older startup cannot be reconstructed from the APK alone.
Launcher icons use the highest-density packaged raster resource instead of the first resource-table entry. Existing installations are re-inspected when the panel/launcher refreshes; no APK reinstall is needed. PNG, WebP and JPEG icons retain their original bytes.
Each new app uses its own independent container. Existing shared containers remain discoverable; their existing packages can still be updated or managed, but new package identities cannot be added. Framely does not automatically split, migrate or delete older shared containers or their data. Steam-managed containers remain excluded.
Installation runs in a headless background container without opening the Android desktop, regardless of the selected display mode. After installation, choose **Open** if desired; installation does not automatically launch the APK. An app with no detected launch entry remains manageable but does not appear in the launcher. Opening an already visible app reuses its running container and virtual gamepad. A headless or fully hidden display, a physical window-size change, or first attaching a new gamepad requires a restart because Lepton cannot safely perform those display or device transitions in place.
To update, open the app's management dialog and choose **Install newer APK**. Package identity and version are checked; Android enforces signing compatibility during replacement. Downgrades are refused, and signature failures never trigger an uninstall/reinstall workaround. Same-package replacement retains data. Updating may stop the target container, affecting its other applications.
Before replacing an existing installation, Framely saves a container-data snapshot while it is stopped. Scratch overlay work directories, runtime sockets and FIFOs are excluded. Backups include a SHA-256 integrity inventory checked before restoration. Backup failure aborts replacement. Snapshots are recovery material, not a guarantee that every Android data migration is reversible. One-click restore supports sideloaded containers with a single recorded application and a matching data location, including external containers; shared snapshots require manual recovery. Restoration creates another snapshot of the current state and requires launching the app to verify saved data afterward.
Once a modifying operation starts, cancellation is disabled until its actual outcome is known. Uploads and preparation can be cancelled. Switching panels does not stop a submitted task; reopening APK Manager resumes its progress display. Interrupted restores are recovered from a local transaction journal before the next modifying operation. After interruption, use **Reconcile installed state** before retrying or deleting data. An unreadable container displays Installation state unknown, preserves the previous record and disables launching, reinstallation and deletion until its actual state is reconciled.
## Launch and window display
The launcher uses declared MAIN/LAUNCHER, television launcher and recognized VR entries, plus the running Android package manager's resolved launcher entry. Disabled packages/components are excluded. A launch entry does not guarantee that an app will run successfully.
Flat apps use Lepton’s full-display boot path to avoid the Gamescope window-creation failure in per-app mode. A headless container from installation, or a closed display, is restarted when needed; this also stops other apps in the same container. VR apps keep background boot. After ActivityManager reports success, Framely checks that the application and Android display service stay alive before reporting completion. Framely keeps Lepton’s development-context data handling instead of switching an existing container to APK bake mode. A temporary launch adapter uses the installed Lepton libraries without editing Steam’s installation; an unsupported entry script reports an error before starting a cold container. Manually choosing **Start container** still opens its desktop.
Window handling is automatic by default. **Advanced settings → Show Android window** is a troubleshooting override, applied on container restart. It does not convert a flat application into VR or a VR application into a flat application. Lepton development contexts may mount their APK at a different path after a restart. Framely re-registers the same signed APK when Android can no longer resolve it, preserving data and reporting failures. Advanced settings also allow selecting an enabled, declared Activity when a nonstandard app needs a different entry.
Launcher favorites, search and manual ordering continue to use stable context/package identities. Updating and refreshing do not reorder icons. Uninstalling hides an icon without deleting its favorite/order record; reinstalling in the same context restores it. Long-press a local Lepton icon to open APK management or removal confirmation.
## Close, uninstall and clean data
**Close app** force-stops only the selected package. **Stop container** affects every app inside it. All non-Steam sideloaded containers with a verified data location support deletion after confirmation of affected apps and permanent data loss. Standard Lepton context directories are removed as a whole; registered external locations remove only the baked directory, preserving unrelated files in the parent. Uninstalling an app keeps its container unless **Delete container** is enabled.
Uninstallation retains saved data by default. Enable **Delete app data** to remove it. If the container contains only this app, this also selects **Delete container** by default; you can turn it off to keep the container. Shared containers and unknown application states cannot use this option. The backend checks the inventory again before deleting. Container deletion also removes the associated APK cache and Steam library entry, icon and artwork, while keeping historical backups. Retained apps appear under **Uninstalled, data retained**, where you can reinstall or remove retained data with the same container option.
Android may require the original signed APK to remove an already uninstalled package's retained state. Framely keeps the original APK when it manages an uninstall. If an externally uninstalled app has no recoverable APK, supply its original APK through reinstallation before removing its retained state. Framely does not bypass signing checks or delete arbitrary directories to simulate success.
**Data cleanup** lists retained data, old APKs, recovery snapshots and expired temporary files. Saved data and backups are not selected by default. Unidentified data is displayed without an automatic deletion action. Each selected item is checked again before removal; a reinstalled application cannot be cleaned as an uninstalled one. Batch results report actual released file bytes and individual failures. File-byte counts may differ from physical disk usage due to compression, reflinks and shared storage.
## Logs and recovery without the panel
Use the app's **Logs → Export logs** to save its operation log. Detailed Android errors remain in logs. Review logs before sharing them.
As the Steam session user, the installed binary also provides these commands:
```bash
/var/lib/framely/current/bin/framely apk list
/var/lib/framely/current/bin/framely apk inspect --file /path/to/app.apk
/var/lib/framely/current/bin/framely apk install '{"ticket":"REVIEW_TICKET","approve":true}'
/var/lib/framely/current/bin/framely apk launch '{"app":"CONTEXT/PACKAGE"}'
/var/lib/framely/current/bin/framely apk logs '{"app":"CONTEXT/PACKAGE"}'
/var/lib/framely/current/bin/framely apk cleanup-list
```
Reviews expire after 15 minutes. APKs, operation records and recovery material are stored in `~/.local/share/framely/apk-manager`; managed Lepton data lives under `~/.local/share/lepton/contexts`. Framely uninstallation retains these user-owned directories. Back them up before removing Framely; the APK CLI requires a compatible Framely binary, while Lepton remains independently installed through Steam.
Start Framely-managed contexts through the APK panel or the `framely apk launch` command above. These launch paths preserve downloaded resources and attach persistent storage; a raw Lepton launch does not apply Framely's storage hooks.
### Downloaded resources and shader cache
On the next container start after upgrading, Framely moves existing Android shared storage to `baked/external` inside the context and mounts it directly, outside the Android data overlay. The move preserves partial downloads and checkpoints without copying their contents. If both storage locations already contain data or an unexpected link exists, startup stops and retains both locations for recovery. Already-running containers use the new layout after stopping and launching again. Registered Steam compatibility contexts retain their existing external-storage layout.
Each context also stores Mesa's shader cache in `baked/shadercache`, mounted at `/data/shaders`. Cache entries survive container restarts, allowing compatible shaders to be reused; first-time compilation still occurs. Managed context backups include both directories and restore downloaded resources and cache entries together with application data.
Do not invoke Lepton's global cleanup commands as an application-specific uninstall.
## Supported formats
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
In **Manage app → Advanced settings → Window orientation**, choose **Automatic**, **Landscape**, or **Portrait**. The choice is saved per app and applied at its next launch. Forced portrait uses a tall window rather than rotating content sideways. Changing orientation may restart the shared container and stop other apps in it. VR apps ignore this setting. Flat APK startup also explicitly selects fullscreen and applies it to the created Android task, avoiding a restored freeform desktop window. This affects startup only; later manual resizing remains available.
Moonlight V+ 12.12.12’s connection-creation screen currently crashes on the tested Lepton build because Android’s clipboard service is absent. This is distinct from the window-startup failure. Framely does not modify or re-sign the APK; a compatible Lepton runtime or an app-side fallback is required.
### Optional gamepad input
**APK → Settings → Gamepad input** is off by default, including after upgrading an existing installation. Enable it to forward Steam Input as a standard Android gamepad for APKs opened through Framely. Steam Input is the default source and follows Steam’s controller configurations, including external controllers recognized by Steam. **Frame direct** retains the original OpenVR bindings for compatibility. Close and restart a container to change its source. It includes ABXY, the D-pad and diagonals, both sticks and stick clicks, shoulder buttons, analog triggers, Select and Start. Applications must support Android gamepad input; this does not map controls to touchscreen gestures or provide Android VR controller tracking; supported games can request gamepad rumble.
Enabling applies on the next application launch. Framely may restart its container to attach the input device, stopping other apps in that container. Disabling stops forwarding immediately. Only one Framely-launched APK receives input at a time. Switching apps, closing an app, transient process-query failures and foreground changes neutralize input without removing the virtual pad. Each running container retains its device for reuse; container stop, disabling the feature or session-service restart releases it. After a service restart, launch the APK again to reconnect. Keep the intended Android window selected in Dock; the bridge does not change Steam's physical-controller bindings.
The bridge runs as the Steam session user and requires access to `/dev/uinput`, active SteamVR and the supported Lepton startup hooks. It does not modify Steam's installed Lepton files. Only its virtual event node and read-only Android key layout are mounted into the target container, where Android claims it exclusively. If this claim fails, no input is forwarded and the launch reports an error. Disable the option to use the normal launch path. Bridge errors are recorded in `~/.local/share/framely/apk-manager/logs/gamepad.log`; key presses are not logged. The native helper and Android adapter ship in both offline and core update packages.
Native Steam launches select the input target before startup acknowledgement. Forwarding begins only after Android claims the virtual device and the target application becomes foreground. Frame direct helpers have separate temporary SteamVR identities. Steam Input helpers do not register OpenVR action sets, because that registration can suppress Steam virtual-gamepad enumeration in SDL. Returning to an already running application reselects its input target.
When this option is enabled, launch APKs through the management panel or launcher. A one-shot CLI launch cannot own the persistent input bridge and reports an error instead. For read-only diagnostics, run `framely apk input-status '{"app":"CONTEXT/PACKAGE"}'` as the Steam session user. It reports Android process, foreground and lifecycle status without input events or Steam tokens; it does not prove that the application handles gamepad events.
**Gamepad rumble** can be disabled independently and takes effect immediately. Linux `FF_RUMBLE` effects are acknowledged and sent back to the current source controller. Input source changes, Android foreground loss, disconnects and bridge shutdown stop feedback; stale effects are not resumed. Rumble intensity is capped at half strength and each request at ten seconds. Games must use Android gamepad vibration; phone vibration and Android VR haptics are not forwarded.
Steam Input loads SDL3 from Steam’s bundled ARM64 runtime and reads Steam’s virtual gamepads, never the physical devices directly. Framely’s output has a distinct device identity and is excluded to prevent a feedback loop. If the Steam runtime is unavailable, launch reports an error; choose Frame direct explicitly instead of silently bypassing Steam’s mappings. With multiple source controllers, the most recently active controller supplies input and receives rumble; they are not merged.
### 可选手柄输入
**APK → 设置 → 手柄输入**默认关闭,旧配置升级也保持关闭。开启后默认使用 Steam Input,沿用 Steam 的手柄配置,支持 Frame 手柄及 Steam 已识别的外接手柄。可选择 **Frame 直连(兼容模式)**保留原有 OpenVR 输入。切换来源需关闭容器后重新启动;关闭输入立即生效。
支持 ABXY、十字键及斜向、双摇杆及按压、肩键、模拟扳机、Select 与 Start;应用必须原生支持 Android 游戏手柄,不模拟触摸或 VR 控制器追踪。同时仅一个目标 APK 接收输入,Android 前台状态丢失、查询失败时清零,但保留虚拟设备以便恢复。
**手柄震动**可独立关闭并立即生效。支持游戏请求的 `FF_RUMBLE`,回传给当前输入手柄;切换来源、失去 Android 前台状态、断连或退出时停止,不恢复旧效果。强度上限为一半,单次请求最长十秒;不转发手机振动或 Android VR 触觉。多个手柄以最近产生有效输入的手柄为来源,不合并输入。
桥接以 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.
Monitoring pauses during APK mutations and startup checks. A container restart, uncertain process query, or another active app in a legacy shared container prevents stopping. Steam-managed containers are left to Steam. Shutdown uses normal Podman stop with a 10-second grace period; APKs, saves and downloads are retained. Reopening a stopped container takes a cold Android start; with both switches disabled a healthy running container is reused.
### SteamOS window-close limitation
On Frame Gamescope `3.16.28-76-ge383171f`, both window and Dock close controls log `Closing Wayland windows not supported yet.` for direct, non-Steam Lepton windows. The close request does not reach Android, so this switch cannot make these controls work on that path. Use **Launch through Steam** as described below, or the APK panel's **Stop container** action. Framely does not infer close intent from focus changes or this unscoped log message.
## Native Steam / Lepton launches
**Launch through Steam** defaults to on. Framely registers launchable sideloaded APKs with Steam's native Lepton compatibility tool. Existing opt-outs remain disabled, and Steam-managed titles are not registered again. Registration does not launch or restart applications. Steam must be awake; unavailable registration is retried in the background.
Steam entries show the APK's application name and icon. Framely generates matching library capsules, headers and hero images from the icon, without downloading artwork. The original launcher icon, order and favorites stay intact; the explicit Steam App ID binding excludes duplicate entries from the launcher Steam category. APK updates refresh generated entry metadata.
The native runtime has a `steamlaunch-…` process/container name, while the APK keeps its existing data directory and Framely identity. No game-data copy, APK reinstall or system compositor modification is required. A private, per-user Lepton entry adapter retains resource storage, selected activity, window orientation, shader cache and optional controller forwarding. It prevents native Lepton from clearing an existing installation after a timestamp change or early exit. Unknown entry-script formats are refused before starting the application.
Close an already running container before switching its launch path. Framely does not restart a running game to assign Steam ownership. Old shared containers remain discoverable, but only one Steam launch may own their data at a time. Duplicate launches are blocked using an ownership lock that survives the native process launch.
On the tested Frame build, native Lepton ownership lets both the window's lower close button and the Dock close button show Steam's exit confirmation. Confirming exit stops the app and its container. Steam **Stop** and APK management **Stop container** remain available. This result was verified with an isolated flat APK; application-specific and VR behavior may differ. Direct, non-Steam Lepton windows still encounter that build's unsupported Wayland close path.
Entry metadata and the launch adapter are configured through Steam's existing local library UI API, reached through its local debugging endpoint on port 8080. Framely neither enables the endpoint nor changes Steam launch settings to enable it. If the API is unavailable or the shortcut target cannot be verified, registration reports an error and retries; it does not edit the live shortcuts database or silently switch to an unprotected launch. This Steam UI interface can change with Steam updates.
Disabling Steam launch removes only Framely's entry; close a native Steam app first. Uninstalling an APK stops its native container before removing its entry. APK data is removed only by the explicitly selected uninstall/data-cleanup operation. The same session service verifies process and container boot identities so an old ownership record cannot stop a newer application.
### Steam / Lepton 原生启动
**通过 Steam 启动**默认开启。Framely 使用 Steam 原生 Lepton 兼容工具注册可启动的侧载 APK,保留主动关闭的选择,跳过 Steam 已管理的应用。注册不会启动应用或重启容器;Steam 需要保持唤醒,不可用时会后台重试。
Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的库封面、横幅和背景图,不联网下载图片。启动台原有图标、排序和收藏不变,通过明确的 Steam App ID 绑定过滤 Steam 分类中的重复入口。APK 更新会刷新生成的入口信息。
原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。平面 APK 启动后会通过 Android 任务接口切换为全屏,避免恢复成桌面内的自由窗口;不会持续锁定用户之后的窗口调整。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。
切换启动路径之前,需要先关闭已经运行的容器;Framely 不会为了绑定 Steam 重启游戏。旧共享容器仍可识别,但同时只允许一个 Steam 启动占用其数据。跨进程保留的占用锁阻止重复启动及共享数据的并发启动。
独立平面 APK 的实机验证显示,窗口下方和 Dock 的关闭按钮都能弹出 Steam 退出确认,确认后应用和容器停止。也可使用 Steam 的“停止”或 APK 管理中的“停止容器”。具体应用及 VR 应用行为可能不同;直接启动的非 Steam Lepton 窗口仍受当前系统未实现 Wayland 关闭的限制。
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,确认 Android 卸载成功后再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
Container list headings show installed application names; the data path identifies the container. An APK update stops its container before backing up data. If stopping fails or a native Steam launch still owns it, the installation dialog offers **Close container** with confirmation. Closing interrupts running apps and retains their saves. The reviewed APK remains available so installation can be retried after closing.
### Steam entry retention
**APK → Settings → Keep installed APKs in Steam** defaults to on. Turning it off removes idle Framely-owned entries and disables automatic registration. Launching an app whose **Launch through Steam** option is enabled registers its entry on demand; after the native launch process and container finish, the entry and its temporary Devkit copy are removed. Installed APKs, containers, saves and downloaded game data are retained. Running entries are protected. Failed or interrupted starts are cleaned up after a startup grace period, with retries when Steam is unavailable. Turning retention back on resumes automatic registration.
### Uninstalling while keeping the container
Keeping application data uses Android PackageManager readiness instead of requiring user unlock or external storage mounting. Deleting data still waits for those conditions. Framely boots a stopped container without showing its desktop and repairs only the selected APK registration if needed. It reports startup, PackageManager, unlock, storage and container-exit failures separately. Steam entries are removed after Android confirms successful uninstall; Android success is saved first so a Steam cleanup retry does not uninstall again. Failed startup or uninstall preserves the application record, Steam binding and application data. The error dialog offers Retry and Export logs. A container that cannot boot still requires diagnosis; Framely does not silently delete it or edit Android package databases.
L2/R2 digital button activation is adjustable in **APK Manager → Settings**, from 1% to 100%, with an 80% default. Both triggers use this threshold with either Steam Input or Frame direct input. Analog trigger travel is unaffected. Saving applies to running bridges immediately; release occurs five percentage points below the selected threshold (at 0% for thresholds below 5%).
## Per-app graphics compatibility
Under **Manage app → Advanced settings**, **Framebuffer compatibility fix** is off by default. Enable it only for games that play sound but show a black screen (tested with Rock Kingdom on the current Mesa/Zink driver). Restart the app after changing it. The GLES layer validates framebuffer status after texture attachment, only in the selected application; other apps, system drivers, game files and saved data remain unchanged. Turning it off restores the normal rendering path on the next app launch. It is a targeted workaround, not a general graphics or performance enhancement.
+49 -5
View File
@@ -9,21 +9,65 @@
Click Framely in the SteamVR Dashboard Dock. First use requires accepting the terms and privacy statement; plugins do not automatically start before consent.
- **Favorites**: open favorite plugin quick pages.
- **Installed**: browse local plugins and their pages.
- **Settings**: language, safe mode and the plugin manager entry.
- **Installed**: browse enabled local plugins and their pages.
- **Notifications**: view saved notifications and update reminders, and toggle floating notifications.
- **Settings**: choose an installed language, toggle safe mode, open the plugin manager or **Check for updates**. The update entry opens the manager’s Settings → Updates page. Install language files and download the language template from the manager’s Settings page.
The manager opens a separate large window with navigation for plugins, store, sources, settings and About. Installation, updates and source management happen here. A phone or computer can also use the [network panel](network-panel.md).
The manager opens a separate large window with five sections: Plugin, APK, Terminal, Files and Settings. Installation, updates and source management happen here. A phone or computer can also use the [network panel](network-panel.md).
## Plugins and windows
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
Settings provides language, network panel and proxy configuration. HTTP and GitHub proxies have independent switches; saving an address does not automatically enable it.
Safe mode pauses third-party plugins for troubleshooting; leave it after resolving the problem. About shows the device version, updates and terms. Revoking consent disables all plugins; accepting again does not automatically re-enable them.
Safe mode pauses third-party plugins for troubleshooting; leave it after resolving the problem. Settings → About shows the device version and terms; Settings → Updates manages updates. Revoking consent disables all plugins; accepting again does not automatically re-enable them.
In Settings → Updates, select the Stable or Testing update channel and check for updates. Choose **Download and install**, then confirm once: Framely downloads and verifies the release and automatically starts installation. Download and verification are displayed as separate stages, with byte progress for each. Download or verification failure stops installation and displays an error. Plugins and data remain; the Framely interface briefly closes and restarts.
Automatic update checks are enabled by default. Framely checks about 30 seconds after starting (once terms are accepted), then every 6 hours. In Settings → Updates, turn **Check for updates automatically** off or change the interval. Checks use the selected Stable/Testing channel and configured proxy, continue with the panel closed, and never download or install automatically. A newer version saves a notification to the inbox and shows a popup when notification settings allow it. **Ignore** dismisses that version's reminder; **Open updates** opens Settings → Updates. The same version is not repeatedly announced, including after restart. Switching the source or channel clears the old reminder.
Floating notifications follow the headset near the upper-right of the view. Their input mask matches the visible card; short button clicks tolerate small tracking movements.
Notification popups display an auto-close countdown when the sender sets a duration. Closing a popup keeps any saved inbox copy. Remove a saved message from Notifications with its trash button. Action buttons close and remove messages by default; senders can configure different behavior. Ordinary notifications are not saved unless the sender opts in, except when popups are disabled.
The **Allow notifications** switch at the top of Notifications controls all floating popups. When off, new notifications are saved in the inbox even if the sender did not request it; active popups are also moved to the inbox. Turning popups back on does not replay old messages.
In the manager, **Notification settings** provides global **Floating notifications** and **Launcher badge** switches, plus independent **Badge** and **Popup** switches for Framely itself (including update reminders) and every installed plugin, including disabled plugins. Global switches take priority and preserve each app’s preferences. The Framely Dock launcher badge counts unread inbox messages from sources allowed to show a badge, displays `99+` for larger counts, and disappears when you view the inbox. Hidden headset panels do not mark messages as read. Reading keeps messages and their actions available; it does not remove them. Settings and read state survive restart.
Log commands are in the [installation guide](installation.md). A working manager does not establish that all plugins work. Enable plugins individually and verify their sources and runtime users.
## Export logs
Open **Settings → Recovery and diagnostics → Export logs**. In the headset, the ZIP is saved in `~/Downloads` on Frame and the panel shows its path. From the network panel, the ZIP downloads to your browser instead. Collection includes recent Framely/plugin logs, CEF renderer logs, GPU evidence, service status and version information. Missing sources or failed commands are recorded in `report.json`; they do not prevent exporting the remaining information. Each source and the total archive input are bounded.
If the panel cannot open, connect to Frame using the installer and choose **Export logs** in its footer. Select where to save the ZIP on your computer. It collects directly over SSH, includes the installer’s current operation log, and works when Framely services are stopped or its state file is damaged. No release package selection is required.
Plugin configuration, inbox contents and password files are excluded. Common credential patterns in logs are masked; review application logs before sharing the archive. Nothing is uploaded automatically.
For terminal recovery without a running daemon:
```bash
sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs.zip
```
The output must be a new file. If the binary cannot run, an extracted release also includes `tools/export-diagnostics.py`, usable with `sudo python3 tools/export-diagnostics.py --output /tmp/framely-logs.zip`.
## Scrolling in VR
Point the laser at a page and move either controller's stick to scroll, without clicking or focusing the page first. Scrolling follows the element under the laser, including nested plugin panels. Keyboard input pauses page scrolling. Launcher stick navigation keeps its category/page controls.
Hold the primary trigger and move the laser up or down to drag scrollable page content. Small movements remain ordinary clicks; once a vertical drag starts, releasing does not activate the pressed button. Text inputs, sliders and explicit custom drag targets keep their own interactions. The launcher retains its icon arrangement and horizontal page-drag gestures.
Launcher blank-space clicks only dismiss the icon menu. Click outside the launcher window or press B to dismiss it (B exits arrangement first). Steam’s move/depth controls keep the window open and pause stick navigation until the stick returns to neutral. While dragging an icon in arrangement mode, horizontal stick input changes pages; vertical input does not change categories. The boundary rails remain an alternative: hold the pointer beyond a rail for 650 ms to move to the adjacent page.
## Launcher resource priority and closing
Settings → Launcher includes **Prioritize launcher resources**, enabled by default. While the launcher is open, Framely raises its native UI and CEF thread CPU priority to a bounded nice level of −5, then restores the previous priorities when closed or disabled. Games and terminal processes are excluded. This can help CPU contention; it does not reserve GPU capacity. The installed session service must include the matching priority limit.
Closing the launcher returns to the previously selected Dock window when it is still available. Starting an application hands focus to Steam instead of restoring the old window. APK windows are handed off after Android confirms the activity launch; startup health checks continue afterward. A running container with a hidden application window is reused rather than restarted solely because the previous game exited.
+16 -4
View File
@@ -6,7 +6,7 @@
## Before installation
The target is Linux ARM64 Steam Frame. The development baseline is SteamOS VR 0.4.2 and SteamVR build 20260928.6175029; other versions need validation. The runtime includes CEF, so users do not need Node.js or Rust on the device.
The target is Linux ARM64 Steam Frame. The development baseline is SteamOS VR 0.4.2 and SteamVR build 20260928.6175029; other versions need validation. The complete offline installation package includes CEF, so users do not need Node.js or Rust on the device.
Connect your computer and Frame to the same network. Enable developer mode and SSH on Frame, and prepare the `steamos` account password. Verify the SSH fingerprint. Installation uses sudo to create an account, systemd services and release directories. It does not automatically disable SteamOS read-only protection; it stops if required directories cannot be written.
@@ -40,14 +40,26 @@ Replace the example with an existing device release tag. An explicit tag selects
## Option 3: local archive
Download `framely-<version-and-build>-linux-arm64.tar.gz`, the external `SHA256SUMS`, and `bootstrap.py` from the same Release. Place them together on Frame:
Download `framely-<version-and-build>-offline-linux-arm64.tar.gz`, the external `SHA256SUMS`, and `bootstrap.py` from the same Release. Place them together on Frame:
```bash
python3 bootstrap.py install --archive "./framely-<version-and-build>-linux-arm64.tar.gz" --checksums ./SHA256SUMS
python3 bootstrap.py install --archive "./framely-<version-and-build>-offline-linux-arm64.tar.gz" --checksums ./SHA256SUMS
```
Angle brackets are placeholders; substitute the actual filename. The engine verifies, stages and extracts under `/home`, then checks internal hashes, avoiding the device's limited `/tmp`.
## Release packages and CEF
Each device release provides three archives:
- `framely-<version-and-build>-offline-linux-arm64.tar.gz`: complete offline package including CEF; always used for first installation.
- `framely-<version-and-build>-linux-arm64.tar.gz`: core package without CEF; selected automatically for updates by the desktop installer, SSH engine and in-app updater.
- `framely-cef-<runtime-id>-linux-arm64.tar.gz`: separate CEF runtime, available as an online download. `framely-cef.json` provides its URL, size and SHA256.
CEF is stored under `/home/.framely/cef/<runtime-id>`. Updates reuse the exact verified runtime; matching CEF files from older full installations migrate automatically. If the required runtime is missing or a release changes CEF, the core installation downloads the separate matching CEF archive over HTTPS and verifies its size, SHA256 and internal checksums. A complete offline package supplies the runtime without downloading it.
Each release refers to its own CEF version, so rollback retains the previous runtime. Repair verifies it; uninstall removes the CEF cache with the programs. Local first installation must use the offline package. Local updates can use the core package when its required CEF is already installed; otherwise runtime recovery needs network access.
## Verify installation
```bash
@@ -60,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
+3 -1
View File
@@ -24,7 +24,7 @@ Change passwords from authenticated settings or the device's native manager. If
## Available operations
Manage sources and subscriptions, browse the store, upload local packages, confirm installation, enable/disable plugins, change settings and check device updates. Backends always run on Frame; uploading a file transfers it from your phone/computer.
Manage sources and subscriptions, browse the plugin library, upload local packages, confirm installation, enable/disable plugins, change settings and check device updates. Backends always run on Frame; uploading a file transfers it from your phone/computer.
VR windows, controller vibration and SteamVR keyboard need headset validation; a browser cannot replace those interactions. Some external links open in the device's default browser.
@@ -35,3 +35,5 @@ Change the port or switch in Settings → Network panel. Password/authentication
Restarting the session service also invalidates old logins. Saving identical settings retains the session. Incorrect password checks are rate-limited; retry later when asked.
If unreachable, check changing device IPs, network reachability, enabled state and port. Settings and passwords live in `/home/.framely/state` and are retained by uninstall; reinstalling may still require the old password.
The top switcher provides Plugin, APK, Terminal, Files and Settings; the device address defaults to Plugin. [Terminal and files](terminal-and-files.md) provides user-owned shell sessions, uploads/downloads, editing, archives and previews. These tools use the same login and port.
+3 -3
View File
@@ -4,9 +4,9 @@
[Documentation](../README.md) · [Network panel](network-panel.md)
## Install from the store
## Install from the plugin library
1. Open **Store** in the manager. Current code initializes community stable/testing sources; availability depends on the actual service and device configuration. If no catalog is available, add a developer's complete HTTPS `catalog.json` URL in Sources.
1. Open **Plugin library** in the manager. Current code initializes community stable/testing sources; availability depends on the actual service and device configuration. If no catalog is available, add a developer's complete HTTPS `catalog.json` URL in Sources.
2. Filter by keywords, tags, source or installation state.
3. Open details and review author, description, changes, runtime user and dependencies. The recommended version is shown first; history loads on demand.
4. Install or inspect the selected version. Read the confirmation after download, verification and dependency resolution.
@@ -49,7 +49,7 @@ Uninstall through the management dialog. Hooks handle external cleanup and saved
| Symptom | Action |
| --- | --- |
| Empty store | Check enabled sources, connectivity and filters |
| Empty plugin library | Check enabled sources, connectivity and filters |
| Download/hash failure | Check network and URL; download again without bypassing verification |
| Missing history | The source may lack its history file; use latest or a trusted local old package |
| Cannot enable | Read dependency/conflict errors and install required plugins |
+93
View File
@@ -0,0 +1,93 @@
# Terminal and files
[简体中文](../zh-CN/user-guide/terminal-and-files.md)
[Documentation](../README.md) · [Network panel](network-panel.md)
## Manager navigation
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.
## Terminal
Create a terminal to open the configured Steam session user's login shell in their home directory, normally `steamos`. This is a real PTY: Ctrl-C, Tab completion, arrows, Unicode input, window resizing and full-screen programs such as Vim and top work. Use `sudo` inside the terminal when administrator privileges are needed; the panel password is separate from the user's sudo password.
You can create up to 12 sessions, rename them, reconnect or explicitly close them. Changing pages, closing a browser or losing the network leaves the shell running. Restarting the user-session service or Framely ends the sessions; processes are not restored after restart. Closing a session requires confirmation and releases the shell, its session's child processes and PTY.
Only one connection can type in a session. Other connections are read-only; **Take control** transfers input ownership. While disconnected, input is disabled. Reconnection never replays queued keystrokes and restores only the retained output. Each session has a 2 MiB output buffer; a notice appears when older output was discarded. Replayed raw terminal output can lose earlier screen context after truncation.
Terminal input, sudo passwords and output are not written to Framely logs. Output is rendered as terminal characters, not HTML; terminal clipboard-write escape sequences are ignored. xterm.js and its fit addon are included with the offline UI, without CDN requests.
Use **Ctrl+Shift+C** to copy selected terminal text and **Ctrl+Shift+V** to paste. **Ctrl+C** still interrupts the running program; it is not the copy shortcut. The footer shows these shortcuts.
## Browse and organize files
Files opens the user's home directory and accesses the filesystem with that user's permissions. It never automatically elevates privileges. Permission changes are limited to ordinary Unix permission bits; use Terminal and `sudo` for administrator operations.
Use the address bar, breadcrumbs, parent button or expandable folder tree. Quick access includes Home, Downloads, mounted storage, the filesystem root, the recycle bin and your saved folders. Add the current folder using the star button; rename, reorder or remove its shortcut with the adjacent controls. Unavailable folders keep their saved shortcut and display the access error when opened.
Drag the divider between the sidebar and file list to resize it; the width is remembered in this browser and limited automatically on narrow windows. Quick access stays in a fixed-height upper section. The folder tree fills the remaining sidebar height; both lists scroll independently, with their headings kept visible. Opening a folder in the file list or address bar expands the tree to its location and highlights it.
The file list provides hidden-file visibility, sorting, checkboxes for multiple selection, current-folder filtering and cancellable recursive filename search. Recursive search does not follow directory links. Lists are paged in groups of 200; recursive results are limited to 5,000 matches and 500,000 visited entries. Media contents are not scanned in the background.
**Actions** provides copy, cut, paste, rename, delete, properties, permission editing, compression, extraction and opening files. Right-click a file or folder to open a menu at the pointer; right-clicking a selected item preserves the multi-selection. Right-click the empty list area for new file/folder, paste, upload and refresh actions. Escape or clicking elsewhere closes the menu. Symbolic links have an arrow marker and their target in Properties. Copy preserves links; deleting a link does not delete its target. Special filesystem objects cannot be copied or archived.
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
Delete defaults to **Move to recycle bin (30 days)**. Permanent deletion requires an explicit choice and confirmation. Framely keeps trash on the source filesystem: home-directory trash or a private `.framely-trash-UID` directory at the storage mount. If that storage cannot support trash, the operation reports an error and does not fall back to permanent deletion.
The recycle bin supports restoring with an unused name or keeping both copies, permanent removal and clearing all tracked items. Restore needs the original parent directory to exist. Framely cleans expired records about hourly while the session service runs, and only removes items recorded in its own recycle bin. Unavailable storage is retained until accessible again. Other applications' trash is unaffected.
## Transfer and edit
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.
Opening an ordinary file attempts the UTF-8 editor, limited to 5 MiB. Invalid UTF-8, binary data and larger files cannot be edited; use Download instead. Saving checks a content revision. External changes require reloading the file or explicitly confirming overwrite. Unsaved changes require confirmation before closing the editor.
## Archives and media
Create ZIP, TAR or TAR.GZ archives; the output filename selects the format. Choose a destination and conflict behavior when extracting. Archive operations reject absolute paths, parent traversal, symbolic/hard links and special files; extracted contents are never executed. Archive limits are 50,000 entries and 64 GiB expanded data. Cancelling removes staging files and leaves already committed items intact.
Select an image or video to preview it. PNG, JPEG, GIF, WebP, AVIF, BMP, MP4, WebM and MOV use the browser's native decoder; actual codec support depends on the browser. Video controls provide playback, seeking and fullscreen. Unsupported formats can be downloaded; there is no server-side transcoding. HTML and SVG are not opened as same-origin pages.
## Access and privacy
Terminal, file and transfer routes use the existing authenticated manager connection and port. Mutating routes and the terminal WebSocket require an exact matching Origin; plugin sandboxes cannot use them. The service runs as the configured Steam user, independently of the root daemon. Full previews and downloads read content after explicit selection; icon views read only visible media to generate lazy thumbnails. There is no background directory media scan. Test media are generated fixtures; development and validation do not read or upload private Arcturus recordings.
## Diagnostic export
**Settings → General → Export logs** and the installer’s **Export logs** use the same collector. The archive includes recent APK operation logs, Lepton’s saved Android logs, and a limited snapshot of running containers: Android boot, user unlock, storage mounting, PackageManager readiness and recent main/system/crash logs. Rootless containers are inspected as the configured Steam session user. Stopped containers are not started. App state is summarized without tokens or icons; APK files, saves, media and raw memory dumps are excluded. File and archive limits retain recent output; missing sources, truncation and command timeouts are listed in `report.json`. Logs can contain application information; review before sharing.
File sorting (name, size or modification time, including ascending/descending order) is remembered separately for each folder. The **View** menu switches between a list and small, medium or large icons; this choice applies to all folders. Preferences are saved on Frame and restored when reopening the panel in a browser or headset. Icon views lazily generate thumbnails for visible images and videos using browser decoding, with at most two concurrent jobs. Videos use a frame around one second. Thumbnails are cached in bounded memory while the page remains open and refreshed when file metadata changes. Scrolling out of view cancels pending work; unsupported formats or decoding failures retain the file-type icon. No FFmpeg or whole-folder media scan is used.
Clicking an image or video opens a full-panel viewer. Use the wheel or zoom buttons to zoom, drag the content to pan, and use Previous/Next or the arrow keys to browse the image/video files in the current displayed list. Fit to window resets the view; Esc closes it. Video playback controls remain available. If browser decoding fails, download the file from the viewer. UTF-8 text up to 5 MiB still opens in the editor; binary files, unsupported encodings and larger files download directly when clicked.
The viewer controls are grouped in a floating capsule centered at the bottom, with the filename below. Clicking blank space closes the viewer; clicking media or controls and dragging to pan do not.
+6 -1
View File
@@ -10,8 +10,10 @@ Framely 是 Steam Frame 的插件管理器,包含设备端服务、SteamVR 界
1. [安装、更新、修复与卸载](user-guide/installation.md):桌面安装器、SSH、Preview、本地包、回滚。
2. [基本使用](user-guide/basic-usage.md):Dock 入口、管理窗口、启停、收藏、安全模式。
3. [安装与管理插件](user-guide/plugins.md):商店、来源、订阅、本地包和更新。
3. [安装与管理插件](user-guide/plugins.md):插件库、来源、订阅、本地包和更新。
4. [网络管理面板](user-guide/network-panel.md):地址、首次密码、登录、手机/电脑使用。
5. [APK 管理](user-guide/apk-manager.md):侧载安装、更新、容器、保留数据与清理。
6. [终端与文件](user-guide/terminal-and-files.md):用户终端会话、文件操作、回收站、传输与预览。
## 插件开发与发布
@@ -35,3 +37,6 @@ Framely 是 Steam Frame 的插件管理器,包含设备端服务、SteamVR 界
- [Framely 发行与更新机制](releases.md)
- [电脑端安装器构建](../../installer/README.zh-CN.md)
- [空间启动台](launcher.md)
- [启动台操作与本体兼容](developer-guide/launcher.md)
+5 -1
View File
@@ -40,7 +40,7 @@ npm run dev
在浏览器打开 `http://127.0.0.1:5173`;修改页面后自动重载。预览只模拟窗口和通知,不启动 Python 后端,不拥有 Frame 权限;读取/保存按钮在预览中报出后端不可用是正常情况。不要据此声称设备功能已验收。
UI 与后端 API 见 [SDK 文档](sdk.md),字段、默认值和范围见 [Manifest 配置](manifest.md);窗口与商店资料补充见[插件开发参考](../plugin-development.md)。
UI 与后端 API 见 [SDK 文档](sdk.md),字段、默认值和范围见 [Manifest 配置](manifest.md);窗口与插件库资料补充见[插件开发参考](../plugin-development.md)。
## 4. 实现后端与生命周期
@@ -82,3 +82,7 @@ sudo /var/lib/framely/current/bin/framely install ./yourname.my-plugin-0.1.0.fra
GitHub Release 默认使用标签 `v<version>` 和附件 `<id>-<version>.framely`,源码清单可以省略 `downloadUrl`。社区登记会自动拼接地址;打包前按[发布指南](publishing.md)生成包含最终地址的临时清单,因为当前 `framely pack` 不会自己读取仓库地址。自定义下载地址再显式填写 `downloadUrl`。随后公开 Release、上传包,提供使用说明、运行用户、更新记录和实际测试范围,再自行托管插件源或登记固定源码提交。社区数据库还需校验整包预期哈希和字段兼容性。
后续更新:提升版本号 → 重新生成发布清单(自定义地址时更新地址)→ 构建打包并校验 → 实机验证 → 新 Release → 更新来源登记。不要替换旧版本附件。
## 启动台操作与本体兼容
[启动台操作与本体兼容](launcher.md)
+71
View File
@@ -0,0 +1,71 @@
# 启动台、操作与本体兼容要求
[English](../../developer-guide/launcher.md) · [Manifest](manifest.md) · [SDK](sdk.md)
从 Framely **0.4.3-preview.2** 开始支持。旧插件无需修改:短按默认打开快捷页面,长按显示“快捷面板、管理插件、卸载插件”。没有快捷页面时对应按钮置灰,卸载始终需要确认。
## 本体版本要求
```json
"engines": {"framely": ">=0.4.3-preview.2 <0.5.0"}
```
`engines.framely` 是强制执行的本体兼容条件;`apiVersion` 表示协议版本,两者独立。旧插件可以省略;使用启动台新增声明时必须填写,并明确约束最低版本为 `0.4.3-preview.2` 或更新版本。
与插件依赖共用范围解析:精确版本(`0.4.3` 或 `=0.4.3`)、比较符、空格或逗号连接的交集、`^`、`~` 和通配符。不支持 `||` 和连字符区间。普通范围不匹配预览版;需要在比较条件中显式写出对应主、次、补丁版本及预览标识。例如 `>=0.4.3-preview.2 <0.5.0` 接受后续 0.4.3 预览版和兼容正式版,但不会自动接受 0.4.4 预览版。构建标识不影响匹配。
安装、更新、降级安装和启动均检查兼容性;不兼容安装在执行钩子或替换文件之前被拒绝。插件库仍展示不兼容版本及要求,依赖解析跳过这些版本。本体降级后保留插件文件及启用偏好,但阻止执行。已发布的旧本体会拒绝未知清单字段,无法追溯增加友好升级提示。
## 不同入口的行为
```json
"ui": {
"quickPage": "page.js",
"windows": {"main": {"entry": "page.js", "title": "Example"}},
"launch": {
"launcher": {"type": "window", "window": "main"},
"quickPanel": {"type": "quickPage"},
"manager": {"type": "window", "window": "main"}
}
}
```
未配置的入口默认进入对应插件快捷页面。管理面板独立管理按钮不受影响。长按菜单内置的“快捷面板”始终进入快捷页,不受入口配置影响。窗口目标必须引用已声明窗口,快捷页目标必须声明快捷页面。现有 `plugin.open` 仍仅启动后台,不执行入口导航。
## 长按操作
自定义按钮按声明顺序显示在三个内置操作上方;最多 16 个,ID 合法且不重复,名称非空且不超过 120 字节。名称由插件提供,发布时自行选择合适的语言。
```json
"launcherActions": [
{"id": "open", "label": "打开窗口", "target": {"type": "window", "window": "main"}},
{"id": "connect", "label": "连接", "target": {"type": "backend", "method": "connect", "params": {"profile": "default"}}},
{"id": "notify", "label": "发送通知", "target": {"type": "frontend", "entry": "actions.js"}}
]
```
后台目标必须有后台,接收 `{params, context}`,禁止使用保留的 `framely.*` 方法。前端目标必须指向包内带哈希的脚本;点击时在独立沙箱中加载,不渲染快捷页面,可使用现有 SDK 能力。前端操作最多等待 90 秒,失败保留菜单并支持重试。操作不会取得插件管理或任意系统命令权限。
新模板会自动把 `actions.ts` 构建为 `actions.js`:
```ts
import {registerLauncherActions, framely} from '@framely/sdk';
registerLauncherActions({
notify: async context => {
await framely.notifications.send({
id: 'hello', title: '你好', body: `来自 ${context.source}`, inbox: true,
});
},
});
```
## 启动上下文
```ts
const initial = await framely.ui.launchContext.get();
const unsubscribe = framely.ui.launchContext.onChanged(context => {
console.log(context.source, context.trigger, context.actionId);
});
```
`source` 为 `launcher`、`quickPanel` 或 `manager`;`trigger` 为 `shortPress` 或 `menuAction`;自定义操作还包含 `actionId`。首次加载即可查询,复用页面或窗口时接收 `ui.launch` 事件。前端操作直接接收上下文,后台操作随参数接收。入口导致后台首次启动时,`onStart` 生命周期参数也包含 `launchContext`。没有已知来源的旧调用默认标记为快捷面板。
+6 -2
View File
@@ -39,7 +39,7 @@
| `screenshots` | 默认空数组,最多 8 个 payload 内 PNG/JPEG 路径 |
| `authorUrl` / `documentationUrl` / `homepage` | 可选 HTTP/HTTPS 地址,禁止 URL 内用户名密码 |
| `downloadUrl` | 可选,固定版本 HTTPS 包下载地址;GitHub 数据库源码可省略,按仓库/版本/ID 拼接,社区登记的包内需补全;见发布指南 |
| `publish` | 可选外部商店图片配置,见下文 |
| `publish` | 可选外部插件库图片配置,见下文 |
| `backend` / `lifecycle` / `ui` | 可选,配置后端、钩子和页面 |
| `dependencies` / `optionalDependencies` / `conflicts` | 默认空对象,见关系参考 |
| `exclusiveResources` | 默认空数组,禁止同时启用占用同名资源的插件 |
@@ -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 |
@@ -96,3 +96,7 @@
清单 JSON 最大 256 KiB,载荷路径最多 512 字节。载荷路径仅允许 ASCII 字母、数字及 `/._-+`,禁止绝对路径、空段、`.`、`..`、软链接和未声明的附加包文件。清单通过校验后,运行权限仍需用户判断;文件哈希不代表插件安全审核。
`backend.uiVisibilityEvents`(boolean,默认 false):订阅宿主专用 `framely.ui.visibility` RPC。后端必须及时回复;通知无需页面挂载。该字段需要支持可见性事件的新版宿主。
## 启动台操作与本体兼容
[启动台操作与本体兼容](launcher.md)
+2 -2
View File
@@ -77,7 +77,7 @@ cargo run --manifest-path "$FRAMELY_REPO/Cargo.toml" --locked -- verify "dist/$P
### 图标与窗口兼容
社区数据库的图标通常只需顶层 `icon: "icon.png"`:同一路径 PNG 同时提交到源码仓库和 `payload/`。数据库从登记的仓库、固定提交及路径生成 GitHub Raw 地址,并下载比较包内图标;不需要额外填写 `publish.icon`。自行托管目录的图片由 `publish.icon` / `publish.screenshots` 提供固定 HTTPS 地址。[商店资料参考](../plugin-development.md#商店资料与图标)
社区数据库的图标通常只需顶层 `icon: "icon.png"`:同一路径 PNG 同时提交到源码仓库和 `payload/`。数据库从登记的仓库、固定提交及路径生成 GitHub Raw 地址,并下载比较包内图标;不需要额外填写 `publish.icon`。自行托管目录的图片由 `publish.icon` / `publish.screenshots` 提供固定 HTTPS 地址。[插件库资料参考](../plugin-development.md#插件库资料与图标)
社区数据库支持窗口 `entry`、`title`、`dockIcon`、`localWeb`、`width`、`height`、`widthMeters`。像素宽度为整数 640–2560,高度为整数 360–1440,物理宽度按本体 float32 规则校验 0.4–4.0 米。源码省略尺寸时,数据库会按 1600×900、3 米补全,与 pack 输出一致;自定义尺寸也会参与源码和包内清单比较,未声明的变化会被拒绝。模板包含的默认独立窗口已通过实际打包和数据库校验;登记仍需完成整包哈希、归属及其他字段检查。
@@ -92,7 +92,7 @@ mkdir -p target/plugin-catalog
cargo run --locked -- catalog --name '我的插件源' --base-url https://example.org/packages --packages ./packages --output ./target/plugin-catalog/catalog.json
```
将 `example.org` 换成实际 HTTPS 地址。CLI 计算完整包的 SHA256并生成 JSON,不上传包、不复制图片。没有外部 `publish.icon` 时,本地 CLI 不会把包内图标转换成商店 URL。
将 `example.org` 换成实际 HTTPS 地址。CLI 计算完整包的 SHA256并生成 JSON,不上传包、不复制图片。没有外部 `publish.icon` 时,本地 CLI 不会把包内图标转换成插件库 URL。
**上传整个生成目录,不能只上传 `catalog.json`:**
+41 -4
View File
@@ -25,7 +25,8 @@ registerPlugin({QuickPage, windows: {main: MainWindow}});
| `framely.windows.open(key)` | 窗口 key;异步结果 | 打开已在 Manifest 声明的窗口 |
| `framely.windows.close(key)` | 窗口 key;异步结果 | 关闭窗口,不停用后端 |
| `framely.notifications.send(notification)` | 通知对象;异步结果 | 发送或按 ID 更新通知 |
| `framely.notifications.remove(id)` | 通知 ID;异步结果 | 撤回通知 |
| `framely.notifications.remove(id)` | 通知 ID;异步结果 | 关闭弹窗并移除收件箱副本 |
| `framely.notifications.dismiss(id)` | 通知 ID;异步结果 | 仅关闭弹窗,保留收件箱副本 |
| `framely.dependencies()` | `Promise<DependencyStatus[]>` | 查询本插件声明的依赖状态 |
| `framely.language.get()` | `Promise<{preference, language}>` | 获取语言偏好和当前生效语言 |
| `framely.onEvent(callback)` | 返回取消订阅函数 | 接收宿主转发的事件 |
@@ -43,14 +44,38 @@ try {
```tsx
await framely.notifications.send({
id: 'task', title: '完成', body: '文件已处理', durationMs: 8000,
id: 'task', title: '完成', body: '文件已处理', inbox: true, durationMs: 8000,
actions: [{id: 'open', label: '打开', icon: '↗'}],
});
```
`id`、`title`、`body` 必填。`image` 可选,接受 PNG/JPEG data URL 或 HTTPS URL,字符串最大 1 MiB。`actions` 最多 3 项,每项有 `id`、`label`、`icon`。`durationMs` 范围 1000–60000,省略时默认 8000 ms。`id` 和按钮 `id` 遵循本体 ID 规则,按钮 ID 在同一通知内不得重复;`title` 最大 160 字节、`body` 4096 字节、按钮 `label` 非空且最多 80 字节、`icon` 最多 16 字节。每插件每 10 秒最多发送 10 次,全局最多 64 条同时存在的通知;同 ID 更新不额外占队列位置。
`id`、`title`、`body` 必填。`inbox` 默认 `false`:浮窗启用时,只有明确设置 `inbox: true` 的通知才进入快捷面板的“通知”收件箱。收件箱消息在弹窗到期、关闭弹窗、插件停用或重启、本体重启后都会保留;按钮策略、主动移除或卸载插件会移除消息。重启后恢复的收件箱消息不会重新弹出。
按钮动作调用后端 `notification.action`,参数 `{id, action}`;同时转发 `notification.action` 页面事件。无后端插件可只监听页面事件。同一 ID 用于更新,调用 remove 撤回。
| 字段 | 默认值 | 行为 |
| --- | --- | --- |
| `inbox` | `false` | 保存到收件箱;用户浮窗设置也可要求保存 |
| `durationMs` | `8000` | 弹窗自动关闭倒计时,单位毫秒;支持 `1000-60000`,`0` 表示持续显示直到主动关闭 |
| `actions[].closeOnClick` | `true` | 此按钮操作成功后关闭弹窗 |
| `actions[].removeFromInboxOnClick` | `true` | 此按钮操作成功后移除收件箱副本 |
用户的通知设置优先于发送方偏好。全局浮窗或此插件的浮窗开关关闭时,Framely 不展示弹窗,并将消息转存到收件箱,即使 `inbox` 是 `false`。查看收件箱会清除启动图标的未读角标,但保留消息及按钮。插件不能覆盖用户的浮窗和角标设置。
两个按钮策略独立配置。例如查看状态后仍要保留消息,可将两个字段均设为 `false`:
```tsx
await framely.notifications.send({
id: 'capture', title: '录制中', body: '当前录制正在进行。',
inbox: true, durationMs: 0,
actions: [{id: 'status', label: '查看状态',
closeOnClick: false, removeFromInboxOnClick: false}],
});
```
弹窗显示剩余关闭时间,收件箱显示接收时间。关闭弹窗保留其收件箱副本。`framely.notifications.remove(id)` 同时撤回弹窗和收件箱消息;`framely.notifications.dismiss(id)` 仅关闭弹窗。重复发送同 ID 会替换消息并重新开始倒计时。按钮操作失败时保留消息供重试。按钮使用 `label` 文字显示,旧版 `icon` 字符串保持可选兼容。
`image` 可选,接受 PNG/JPEG data URL 或 HTTPS URL,字符串最大 1 MiB。`actions` 最多 3 项,每项必填 `id`、`label`。通知和按钮 ID 遵循本体 ID 规则,按钮 ID 在同一通知内不得重复;`title` 最大 160 字节、`body` 4096 字节、按钮 `label` 非空且最多 80 字节、可选 `icon` 最多 16 字节。每插件每 10 秒最多发送 10 次,全局最多 64 个同时显示的弹窗及 256 条收件箱消息。达到上限后,新消息发送会报错,请先移除旧消息;替换同 ID 不额外占用位置。
按钮调用后端 `notification.action`,参数为 `{id, action}`;后端调用成功后转发同名页面事件。纯前端插件可以监听该事件。插件停用时,其收件箱消息仍可查看和移除,操作按钮需重新启用插件后使用。
## React hooks
@@ -124,3 +149,15 @@ const off = framely.ui.onVisibilityChanged(state => {
});
// 页面卸载时调用 off()
```
`framely.ui.close()` 关闭当前原生界面:快捷页关闭 Framely 面板,插件窗口关闭自身;不会停止插件后端或当前录制。需要包含此 bridge 方法的新版宿主。
独立插件窗口直接展示插件页面,宿主不添加标题栏。插件应自行提供标题、导航和关闭按钮,可使用 `framely.ui.close()` 或 `framely.windows.close(id)`。快捷面板仍保留宿主导航。
## 启动台操作与本体兼容
[启动台操作与本体兼容](launcher.md)
### 本体页面滚动
原生 CEF 宿主在每个页面加载完成时安装主扳机纵向拖动滚动,覆盖现有插件页面、嵌套页面,以及不使用 SDK 的 `localWeb` 窗口。共用的页面标记防止本体桥接或 SDK 重复安装。激光指向的摇杆滚动也通过原生滚轮输入支持这些窗口。它选择指针下可滚动的祖先区域,并在边界继续滚动外层。输入框、文本域、可编辑内容和滑块保留自身交互。若插件需要自行处理纵向拖拽,请在对应元素或其祖先上设置 `data-framely-no-scroll-drag`;原生 `draggable="true"` 元素也会排除。无需新增清单字段或重新打包插件。
+59
View File
@@ -0,0 +1,59 @@
# 空间启动台
[English](../launcher.md)
使用任意支持的点击输入点击 Framely Dock 图标,打开启动台。长按 **600 毫秒** 打开快捷面板,按住期间显示边框进度;长按完成后松手不会再打开启动台。此操作替代原来的主扳机、上扳机分工及扳机互换设置。
启动台是 Dock 内宽 3.6 米、高度保留为 2 米的原生面板,由 Steam 管理位置和曲面;打开时通过 Dock 的面板切换机制替代当前面板,Dock 隐藏时关闭。界面绘制上限固定为 60 FPS,不跟随头显刷新率升高,以限制像素读回与纹理上传负担。这是绘制上限,并不保证实测达到该帧率。启动台使用 4320×2400 的纹理(逻辑分辨率的 3 倍),保留多级纹理过滤以平滑图标边缘,只上传发生变化的区域;拖动动画期间缓存应用排序和搜索结果。图标区域始终保留六列三行的空间,不随应用数量变化;每页最多 13 个图标,按第一排 4 个、第二排 5 个、第三排 4 个的顺序填充。第二排向左错开半格,第三排与第一排对齐;未填满的第二、三排也从相同槽位开始,不按数量重新居中分配。9 个为 4/5,10 个为 4/5/1;1–4 个使用一排居中排列。图标区域独立于分类器居中,高度固定为 570 个界面像素;只有一排或两排图标时,每排的槽位位置仍保持不变。“完成”按钮使用与分类器相同的半透明背景、边框和高光,分类器与图标之间的可见留白大于图标之间的水平留白。按钮与摇杆翻页采用 300 毫秒横向滑动与淡入动效,分类切换采用 360 毫秒纵向滑动与淡入动效,分类器的激活底块同步滑动到选中项,系统启用减少动态效果时跳过这些过渡动画。切换到其他 Dock 面板会正常替代启动台,Framely 不修改其他窗口的透明度或输入设置。关闭启动台后返回 Steam 的 Dock 面板。Steam 优先使用本地封面,缺图时回退到 Logo 或首字母。透明界面沿用户面前弯曲,显示应用图标、名称、分类器和顶部搜索框。右侧胶囊按钮打开管理面板的插件库或设置页。左侧切换收藏、全部、插件、Steam、Lepton 和桌面程序,底部圆点翻页,悬停图标显示收藏按钮,已收藏时显示蓝色实心星标,再次点击可取消收藏。停用插件不显示,安全模式阻止第三方插件启动。Steam 仅展示 Frame 本机各库已安装应用,不包含仅在账号库或串流电脑中的应用。
按下后的激光落点允许在 36 个界面像素内抖动,点击区域大于图标本身;明显拖动会取消落点稳定处理。主扳机按住插件图标 400 毫秒展开操作菜单,普通抖动不会取消长按,包含开发者按钮和“快捷面板、管理插件、卸载插件”。按 **B** 或点击空白区域关闭启动台;处于图标整理状态时,第一次操作先退出整理,启动台继续显示。按住主扳机横向拖动时,当前页随手移动,相邻页同步滑入;松手后吸附到相邻页,拖动距离不足或到达边界则回到当前页。操作菜单默认在指针离开 10 秒后关闭,重新移入取消计时。在 **管理面板 → 启动台** 调整自动关闭开关与时长(1–300 秒)。卸载需要确认,并保留插件数据。
继续按住到 **800 毫秒**进入图标整理。拖动任意图标到其他位置,其他图标按顺序移动并自动补齐 4/5/4 槽位,不留空洞。Framely 本身也在启动台中,点击打开管理面板,并可一起排列。向左拖动的翻页边缘位于分类器右侧,分类器本身不会触发翻页。拖到图标区域左右边缘停留 650 毫秒自动翻页,继续停留可跨更多页面。放手保存排列,点击“完成”退出整理。关闭启动台或隐藏 Dock 会取消尚未完成的拖动。手动排列在收藏、搜索、刷新和重新打开后保持不变。收藏不会将图标移到前面,收藏分类按相同顺序筛选;尚无保存位置的应用按名称追加到末尾。
任一手柄的摇杆上下切换分类、左右翻页。每次拨动只取幅度较大的一个方向,回到中位范围后才允许再次导航,避免斜向或两个摇杆同时触发分类与翻页。
桌面程序从会话用户的 XDG 应用目录和 Flatpak 导出入口读取,隐藏入口及重复的 Steam 游戏快捷方式不会显示。Steam 安装的 APK 应用出现在 Lepton 分类,通过 Steam 启动;侧载应用从 Lepton 各容器的已安装 APK 与包数据库读取,排除空容器和 Android 系统组件,并在原有用户容器中启动。缺少 Android 名称、图片时使用包名和首字母。仅下载但未安装的 APK 不会加入启动台,读取应用列表也不会启动容器。
Steam 启动成功表示请求已交给 Steam,并不代表游戏加载完成。插件库版本可声明 `engines.framely`;当前本体不满足显示的范围时,请先升级本体。
## 分类与收藏
管理面板新增独立的 **启动台** 页面,集中设置扳机分配、菜单自动关闭及“全部包含的分类”。默认包含插件(含 Framely)、Steam、Lepton、桌面程序。关闭某个分类只影响“全部”,其单独分类和收藏仍可访问;全部关闭时,“全部”显示空状态。
所有类型的应用均可用悬停显示的星标收藏,已有插件与 Steam 收藏保持不变。停用插件不显示,暂时不可用的应用仍保留收藏记录。每次打开会清空搜索并重置分页,但保持上次所选分类;当前 Framely 运行期间的分类选择会一直保留。仅本次运行首次打开时,本地应用枚举完成后,有可显示的收藏则进入收藏,否则进入全部。在收藏页取消最后一个收藏时保持收藏分类,重新打开也显示空状态,不自动切换分类。
## 搜索
点击顶部胶囊搜索框打开 VR 键盘。仅搜索当前分类,并保留原有排序;清空后恢复分类内容。键盘打开期间暂停摇杆导航及背景关闭,B先关闭键盘。搜索不会改变图标区域的固定大小或位置。
支持原文、英文单词首字母、中文拼音、日语罗马音及音节首字母。不区分大小写、全半角,忽略声调、空格与分隔符,支持前缀及连续片段。例如 `toushi`、`tiaose`、`tsts` 匹配“透视调色”;`sakura`、`skr` 匹配“さくら”;`toukyou` 匹配“東京”;`hla` 匹配“Half-Life: Alyx”。中文索引在有限大小内包含单字的其他读音,生僻名称或词组读音可能与实际读法不同。
索引在设备后台异步生成并缓存。中文使用离线拼音数据,日语使用随包提供的 Vibrato/IPADic 读音词典。汉字名称同时生成中文和日语候选,不依赖界面语言;词典无法识别的部分保留原文。读音索引尚未完成时仍可使用原文搜索,不会将应用名称发送至外部搜索服务。
会话接口:`launcher.search.index` 接收 `{names: string[]}`,返回名称对应的 `{terms: string[]}`。`launcher.favorite` 接收 `{key, favorite}`,使用与图标排列相同的稳定标识。插件和 Steam 仍更新原有收藏,`framely`、`desktop:` 和本地 `lepton:` 标识保存于 `launcherFavorites`。`launcher.settings.save` 新增 `allCategories`(`plugin`、`steam`、`lepton`、`desktop`);旧配置省略字段时默认全选,显式空数组表示全部不选。词典和许可说明位于发布包 `share/search`,同时包含于本体更新包和完整离线包。
已收藏应用的右上角显示常驻的蓝色实心星标,再次点击即可取消收藏;收藏状态变化不会改变图标排序。
## 眼追与手柄确认
激光未指向启动台时,有效眼追可以选中图标、分类和操作按钮。稳定注视 120 毫秒后高亮目标,并轻振手柄;注视坐标经过轻度滤波,图标边缘附近也可命中最近图标;小幅抖动不会立即切换焦点,短暂丢帧最多保留高亮 280 毫秒。按住期间追踪失效仍会取消按下。激光指向启动台后立即恢复激光交互,Steam 的窗口移动控件及 VR 键盘会暂停眼追。这里使用“激光未指向启动台”的切换规则,不假定公开 OpenVR 能读取 Steam 私有的左右手柄模式标志。
使用左手柄下方方向键(需求中称为“A”的位置)、右手柄 A 或任意手柄的主扳机确认。短按打开选中项,图标长按 400 毫秒打开菜单,800 毫秒进入排列模式。按下后锁定目标,眼睛移动不会拖动图标或切换正在按住的目标;随后使用激光和摇杆排列。眼追失效或确认手柄断连会取消当前按下,必须松开按键才接受下一次操作。追踪不可用时不会用头部朝向替代眼追。
### 眼追校准
在 Frame 内的管理面板打开“启动台 → 眼追校准 → 开始校准”。也可点击启动台右侧设置按钮上方的眼追校准入口。模拟界面沿用同一个 Dock 窗口、图标排列、搜索框和两侧胶囊。开始后自然注视依次高亮的目标,不需要按键确认。采集 13 个图标、6 个分类按钮、4 个快捷按钮和搜索框,再重复采集 5 个位置验证修正效果。连续追踪时约需一分钟;追踪失效会暂停采集,抖动过大会提示重试。B 或取消退出,不保存结果。
可以转头或移动头部来看清边缘目标。每帧都结合当时的头显姿态,把测得的注视方向与“当前眼睛起点到高亮目标”的方向配对。目标位置通过 Steam 实际曲面命中反求,并在头部或窗口移动后重新检查;头部移动本身不算注视抖动。
拟合前对短时间片内的成对方向一起平均,每个目标权重相同,并通过稳健重加权减小采错目标的影响。验证使用每次注视的方向残差中位数,始终保留逐帧头部坐标;单个尖峰帧不会否决整次校准。
结果显示各目标偏差的均方根和最差目标偏差,单位为度。各目标还显示 95% 帧误差以及未修正时的中位误差,区分持续偏差和追踪噪声。校准摘要与训练时间片数据会写入会话日志,便于定位问题。“保存校准”持久保存头显坐标系内偏航、俯仰角的修正。运行时使用当前头显姿态把修正方向转换到空间坐标,保留眼睛起点,再与 Dock 曲面求交。不会改变激光输入或系统眼追。误差参照高亮目标方向,不以原生眼追作为正确答案。验证失败不会覆盖原有校准;旧版窗口坐标校准不再应用,更新后请重新校准。此修正处理稳定角度偏移与增益,非线性追踪误差仍可能存在。OpenVR 未公开眼睛摄像头的准确曝光时间,快速移动时仍可能有时序误差。
明显调整头显佩戴方式或窗口距离后,请重新校准。
按住 200 毫秒后才显示进度反馈。Dock 入口使用蓝色边框进度,600 毫秒打开快捷面板。启动台图标使用半透明白色圆形边框进度:中点刻度对应 400 毫秒打开菜单,继续按至 800 毫秒进入图标编辑;松手或取消按压后移除进度。
### Steam 远程畅玩
Steam 分类也显示已连接远程畅玩电脑上安装的应用,图标右下角标有远程图标。列表后台刷新,断连的电脑不再展示;相同 App ID 只显示一个图标;本机和远程都可用时,点击弹窗选择本机启动或远程畅玩,收藏和手动排序保持同一份。点击远程图标会先重新检查可用状态,再让 Steam 从对应电脑串流。图片使用本机 Steam 缓存,缺图显示占位图。此功能读取正在运行的 Steam 客户端界面;该接口不可用时,本机游戏仍可使用。
+6 -6
View File
@@ -87,11 +87,11 @@ Framely 主页面和插件 iframe 自动为可交互控件提供轻微的手柄
振动由 OpenVR 的当前激光控制器执行(12 ms、120 Hz、幅度 0.15),宿主只接受当前可见且被激光命中的视图,限制相邻脉冲间隔为 80 ms。插件发出的反馈消息由 iframe 宿主绑定到实际视图,不能指定另一个窗口或手柄。
## 商店资料与图标
## 插件库资料与图标
Manifest 可声明 `icon`(载荷内 PNG,最多 1 MiB、1024×1024)、`details`、`tags`、`screenshots`(最多 8 个载荷内 PNG/JPEG 路径)、`changelog`。这些文件会和页面一样加入文件哈希清单。图标用于插件列表和插件独立窗口的原生 Dock 缩略图,省略时使用默认拼图图标。
清单还支持三个可选网页地址,未填写的入口不显示:`authorUrl`(作者主页)、`documentationUrl`(插件文档)、`homepage`(插件主页)。地址必须是 HTTP 或 HTTPS,可包含文档锚点,不接受带用户名密码的链接。这些字段会保留在安装后的清单,并由 `framely catalog` 导出到商店目录;作者主页链接直接显示在作者名称上,未提供时作者名称为普通文本;商店列表、详情、管理弹窗和安装确认使用相同行为。文档和插件主页保留独立入口,点击后由系统默认浏览器打开。
清单还支持三个可选网页地址,未填写的入口不显示:`authorUrl`(作者主页)、`documentationUrl`(插件文档)、`homepage`(插件主页)。地址必须是 HTTP 或 HTTPS,可包含文档锚点,不接受带用户名密码的链接。这些字段会保留在安装后的清单,并由 `framely catalog` 导出到插件库目录;作者主页链接直接显示在作者名称上,未提供时作者名称为普通文本;插件库列表、详情、管理弹窗和安装确认使用相同行为。文档和插件主页保留独立入口,点击后由系统默认浏览器打开。
```json
{
@@ -113,11 +113,11 @@ Manifest 可声明 `icon`(载荷内 PNG,最多 1 MiB、1024×1024)、`deta
}
```
上述为显式地址示例,`downloadUrl` 并非源码必填字段:GitHub 社区登记可按仓库、`v<version>` 和 `<id>-<version>.framely` 自动生成。包内仍需最终地址,当前 pack 不会自动读取 Git remote;按[发布指南](developer-guide/publishing.md)生成临时清单即可。社区图标通常只需 `icon`,数据库从固定源码提交生成并校验 Raw 地址;无需额外 `publish.icon`。本地 catalog CLI 使用 `publish` 外部图片,省略时不会导出包内图标/截图,商店采用默认图标。包内资源仍用于已安装插件。
上述为显式地址示例,`downloadUrl` 并非源码必填字段:GitHub 社区登记可按仓库、`v<version>` 和 `<id>-<version>.framely` 自动生成。包内仍需最终地址,当前 pack 不会自动读取 Git remote;按[发布指南](developer-guide/publishing.md)生成临时清单即可。社区图标通常只需 `icon`,数据库从固定源码提交生成并校验 Raw 地址;无需额外 `publish.icon`。本地 catalog CLI 使用 `publish` 外部图片,省略时不会导出包内图标/截图,插件库采用默认图标。包内资源仍用于已安装插件。
插件数据库只登记 submodule 固定提交,读取该提交的清单,补全默认下载地址,先取得 Release digest 或源码 `downloadSha256` 中的预期整包哈希,再下载并比对 SHA256、清单声明和载荷。发布分支提供各渠道目录及历史版本 JSON,不保存插件包或图片,不需要 entries 登记。数据库 CI 自动展示版本和运行用户变化,发布时禁止同 ID、版本更换 SHA256;不执行作者代码,不要求逐插件人工源码审核,也不验证源码与二进制可复现关系。
`framely catalog` 也只生成 JSON:下载 URL 优先使用 `downloadUrl`,没有该字段时采用 `--base-url` 加包文件名。图标与截图来自 `publish` 的外部 URL,不复制资源。商店支持关键词、标签、来源、已安装和可更新筛选;源暂时不可用时显示会话缓存。安装使用已经校验的同一份包,不在确认后重新下载。
`framely catalog` 也只生成 JSON:下载 URL 优先使用 `downloadUrl`,没有该字段时采用 `--base-url` 加包文件名。图标与截图来自 `publish` 的外部 URL,不复制资源。插件库支持关键词、标签、来源、已安装和可更新筛选;源暂时不可用时显示会话缓存。安装使用已经校验的同一份包,不在确认后重新下载。
后端默认按失败自动恢复,包括已经启动的按需插件;可用 `backend.restart: "never"` 关闭。默认第三次连续失败后停用,运行稳定一分钟后重置计数,详见[生命周期](plugin-lifecycle.md)。
@@ -145,8 +145,8 @@ SDK 提供 `Section`、`Button`、`Toggle`、`Slider`、`TextField`、`Select`
插件只使用 `tags` 描述用途,不再使用分类;为兼容已发布的包,旧 `category` 字段读取时忽略,新的清单及目录不再导出它。插件数据库每次生成目录时同时生成 `tags.json`,格式为 `{ "schemaVersion": 1, "tags": ["工具", "温控"] }`,聚合当前目录全部插件的标签、精确去重并排序。stable/testing 分别生成自己的标签文件,不生成跨渠道并集;程序加载多个源后在本地聚合去重。标签随插件更新自动增删,无须手工维护。
上传的插件包、商店下载及其依赖包暂存于 `/tmp/framely-package-*`,确认安装后才写入插件目录。取消、安装完成或失败后删除暂存包;未完成上传和待确认安装包闲置 15 分钟后过期,每 30 秒清理一次。
上传的插件包、插件库下载及其依赖包暂存于 `/tmp/framely-package-*`,确认安装后才写入插件目录。取消、安装完成或失败后删除暂存包;未完成上传和待确认安装包闲置 15 分钟后过期,每 30 秒清理一次。
本机网页窗口在 `ui.windows.<key>` 设置 `localWeb: true`,宿主通过后端 `window.get` 获取窗口 URL;这是窗口加载方式,不是权限声明。URL 使用 `http://localhost:<端口>/framely-window/<key>`。普通窗口无需设置此项。
商店主目录 `catalog.json` 中每个插件 ID 仅出现一次,代表最新版。历史条目存放在相对目录 `plugins/<插件ID>/versions.json`,格式为 `{ "schemaVersion": 1, "id": "作者.插件", "versions": [...] }`;版本条目沿用目录条目的字段,列表不包含主目录的最新版。商店进入详情时按需读取历史,依赖解析仅在最新版不满足范围时读取历史。附加目录字段可扩展,读取端忽略未知字段,但校验已知字段;改变必需语义应升级 `schemaVersion`。插件安装包清单仍严格校验。
插件库主目录 `catalog.json` 中每个插件 ID 仅出现一次,代表最新版。历史条目存放在相对目录 `plugins/<插件ID>/versions.json`,格式为 `{ "schemaVersion": 1, "id": "作者.插件", "versions": [...] }`;版本条目沿用目录条目的字段,列表不包含主目录的最新版。插件库进入详情时按需读取历史,依赖解析仅在最新版不满足范围时读取历史。附加目录字段可扩展,读取端忽略未知字段,但校验已知字段;改变必需语义应升级 `schemaVersion`。插件安装包清单仍严格校验。
+4
View File
@@ -93,3 +93,7 @@ restart 默认为 on-failure;never 只记录失败,不自动重启。restart
状态包含 starting、running、stopping、stopped、recovering、failed 等阶段,并发管理操作沿核心服务的串行调度执行。管理器正常 SIGTERM/SIGINT 会尝试逐个停止插件;强制终止、断电不能保证任何钩子执行。清理应同时可在下次启动时安全恢复。
日志页面合并展示后端日志与 lifecycle 日志;每种日志保留当前 2 MiB 与上一份。安装失败且插件尚未登记时,可从 `/var/lib/framely/logs/<id>.lifecycle.log` 查看。运行身份的数据目录为 `/var/lib/framely/data/<id>/<身份>/`(自定义核心数据目录时相应变化)。
## 启动上下文
[启动上下文](developer-guide/launcher.md)
+13 -7
View File
@@ -38,12 +38,18 @@ framely call system.source.save '{"source":{"url":"https://example.org/framely-r
该机制不能恢复已被系统清除的 `/home` 数据,也不保证操作系统更新后无需人工操作。回滚到不支持修复模式的旧发行后,需要使用支持该模式的新发行包恢复安装。正式发布前应进行一次真实 SteamOS 更新,验证设置、插件数据、账号身份、服务恢复和 SteamVR 宿主兼容性。
## 本体包与独立 CEF
每次构建生成不含 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)。可选的 Quest 转换工具另打 `framely-q2f-*` 包,发行包声明经过验证的确切版本;只有用户选择 Quest 转换时才校验缓存并按需下载,Framely 本体更新不自动重转已安装 APK。
## GitHub Actions 发布
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.8`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.6`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
- 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 应用版本使用安装器版本。
@@ -53,19 +59,19 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
```bash
# 提交并推送代码后,选择需要发布的产品
git tag v0.4.2-preview.8
git push origin v0.4.2-preview.8
git tag v0.7.1
git push origin v0.7.1
# 独立发布安装器,不触发 Framely 构建
git tag installer-v0.4.1-preview.6
git push origin installer-v0.4.1-preview.6
git tag installer-v0.4.1-preview.10
git push origin installer-v0.4.1-preview.10
```
带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。
只有标签触发会发布。各工作流在自己的构建全部成功后创建草稿 Release,上传本产品产物及外部 `SHA256SUMS`,最后公开草稿。Framely Release 额外包含 `bootstrap.py`、入口脚本和更新清单;安装器 Release 只包含五个平台的安装器包和校验文件。失败的草稿不作为最新稳定版提供下载。手动触发用于验证,仅保留 Actions artifacts。
Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为设备运行包;`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 重定向,不允许降级。
+2 -2
View File
@@ -19,8 +19,8 @@
默认每天自动刷新,也可关闭自动刷新或立即刷新。自动检查在 Framely 会话运行期间进行,启动后检查到期项;支持 ETag/Last-Modified 和最多五次 HTTPS 重定向。离线/失败保留已持久化的上次成功源列表,记录错误,按一分钟起的指数退避重试,最多一天。
新增源自动纳入;名称自动更新。相同 id 的 URL 改变时保留旧地址,显示待确认变更,确认后才采用新地址。刷新订阅后同时刷新插件目录。目录缓存沿用会话内缓存,重启后的离线商店不保证有目录缓存。
新增源自动纳入;名称自动更新。相同 id 的 URL 改变时保留旧地址,显示待确认变更,确认后才采用新地址。刷新订阅后同时刷新插件目录。目录缓存沿用会话内缓存,重启后的离线插件库不保证有目录缓存。
目录 URL 规范化后去重,一个源可由多个订阅和手动添加共同引用。删除订阅或移除订阅中的源只移除对应引用;没有任何引用才从源列表移除。已安装插件、来源记录和数据保留,不静默切换来源。
可在插件源列表单独停用源,刷新不会重新开启;即使移除后又加入同一 URL,也保留停用偏好。订阅源的 URL 在待确认变更中管理。刷新只更新源列表、商店资料和更新提示,不自动安装、更新或启用插件。
可在插件源列表单独停用源,刷新不会重新开启;即使移除后又加入同一 URL,也保留停用偏好。订阅源的 URL 在待确认变更中管理。刷新只更新源列表、插件库资料和更新提示,不自动安装、更新或启用插件。
+170
View File
@@ -0,0 +1,170 @@
# APK 管理
在 **APK → 应用 → 管理** 中,可以为每个应用设置 **自定义 Steam 名称**。留空恢复应用原名;更新 APK 后保留,并应用于已有和以后创建的 Steam 入口。Steam 暂不可用时,会在就绪后同步。此设置不改变 Framely 中的应用名称、包名、容器或存档。
全局手柄与容器生命周期选项位于 **APK → 设置**,不再占用应用列表。每个应用的显示与启动选项仍保留在对应应用的管理弹窗内。
[English](../../user-guide/apk-manager.md)
[文档首页](../README.md) · [启动台](../launcher.md)
APK 管理用于安装和管理 Steam Frame 上的 Lepton 侧载应用,本地管理面板与登录后的网络面板均可使用。它只管理用户提供的文件,不提供商店、推荐、下载源或自动更新。
通过顶部切换器可在插件管理与 APK 管理面板之间切换。直接访问设备 IP 默认进入插件管理;APK 管理有独立的应用、容器和数据清理导航。
## 环境与发现
先通过 Steam 安装 Lepton。Framely 使用 Steam 会话用户的 Lepton 与 Podman,不以 root 操作,也不全局安装 Android。应用兼容性取决于 Lepton;Google 服务、设备专用接口和 DRM 可能不可用。
打开“APK 管理 → 刷新”,扫描标准 Lepton 容器目录、Framely 已保存记录及运行中容器的兼容数据标签。扫描不会启动容器。Steam 管理的 APK 在容器视图中标识,更新和卸载仍通过 Steam。
其他工具使用自定义位置时,在“容器 → 其他数据位置”添加包含 `data_overlay/system/packages.xml` 的 `baked` 目录。如果原容器名称与上级目录不同,还需填写原容器名称;名称不能与其他数据目录对应的容器冲突。目录必须属于会话用户;移除扫描位置不会删除数据。无法自动发现的自定义布局需手动登记。
应用按“容器+包名”识别,同包在不同容器里分别管理。改变容器名称或数据位置可能改变应用身份。
## 安装与更新
安装确认中可选择 **显示方式 → 平面窗口 / VR 应用**。初始选项依据 APK 检测结果,更新时保留已有窗口设置。平面模式显示安卓窗口;VR 模式隐藏二维窗口,应用本身需要支持 VR。安装成功后保存选择,安装失败保留原设置。
点击“安装 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 还原。
已显示的应用再次打开时复用运行中的容器和虚拟手柄。无显示或已完全隐藏的窗口、改变窗口物理尺寸、首次挂载新的手柄设备时仍需重启,因为 Lepton 无法安全地在运行中完成这些显示或设备切换。
启动台图标优先使用 APK 中最高密度的位图资源,不再直接取资源表第一项。已安装应用会在面板或启动台刷新时重新提取,无须重装 APK;PNG、WebP、JPEG 图标保留原始字节。
每个新应用固定使用独立容器。已有共享容器继续识别,允许更新和管理其中已安装的应用,但不能再添加新的包名。Framely 不会自动拆分、迁移或删除旧共享容器及其数据;Steam 管理的容器仍不作为安装目标。
安装全程使用无显示的后台容器,不打开安卓桌面,不受所选显示方式影响。安装不会自动打开应用,完成后可以点击“打开”。没有检测到启动入口的 APK 仍可管理,但不会放进启动台。
更新时进入应用管理,点击“安装新版 APK”。先核对包名与版本,再由 Android 在覆盖安装时执行签名兼容校验。默认拒绝降级;签名失败不会自动卸载重装。同包覆盖安装保留数据。更新可能停止目标容器,影响其中其他应用。
替换已有安装前,会停止容器并保存数据快照,排除临时 overlay 工作目录、运行时套接字和 FIFO,保留删除标记。恢复前核对 SHA-256 文件清单;校验失败不会替换数据。备份失败则中止替换。快照是恢复材料,不保证所有 Android 数据迁移都可逆。仅包含一个应用、且数据目录与备份一致的侧载容器支持一键恢复,包括外部容器;共享容器需手动恢复。一键恢复先备份当前状态,恢复后需启动应用核对存档。
修改操作开始后等待实际结果,不能安全中断时不提供取消。上传与准备阶段可取消。异常中断后先“核对实际安装状态”,再重试或清理数据;容器读不到不代表应用已卸载;界面显示“安装状态未知”,保留之前记录,禁用启动、重新安装和删除操作,先核对实际状态。
## 启动入口与窗口
后台识别清单中的 MAIN/LAUNCHER、电视启动入口和已知 VR 入口,并结合运行中的 Android 包管理器解析结果。停用的包与组件排除。有入口不代表一定能正常运行。
平面应用使用 Lepton 完整显示窗口的启动路径,避免按应用创建窗口时触发 Gamescope 协议错误。安装留下的无显示容器或已关闭的显示窗口会按需重启;重启也会停止同容器内其他应用。VR 应用继续后台启动。ActivityManager 报告启动成功后,Framely 还会确认应用和安卓显示服务持续运行,再报告操作完成。Framely 保留 Lepton 开发容器的数据处理方式,不把已有容器切换到 APK 烘焙模式。临时启动适配脚本使用设备上已安装的 Lepton 库,不修改 Steam 安装目录;遇到不支持的入口脚本,会在冷启动容器前报错。手动“启动容器”仍保留显示桌面的行为。
默认自动处理窗口显示。“高级设置 → 显示安卓窗口”用于排查,并在重启容器后生效。它不会把二维应用转成 VR,也不会把 VR 应用转成二维。Lepton 开发容器重启后可能改变 APK 挂载路径;Android 无法识别时,Framely 会重新登记同签名 APK,保留数据并报告错误。非标准应用还可从已启用、已声明的 Activity 中指定启动入口。
启动台继续按稳定的容器与包名保存收藏、搜索和排序。更新或刷新不重排图标;卸载隐藏图标,保留收藏与位置记录,在原容器重新安装后恢复。长按本地 Lepton 图标可进入管理或卸载确认。
## 关闭、卸载与清理
“关闭应用”只停止目标包;“停止容器”影响其中全部应用。所有可确认数据位置的非 Steam 侧载容器均支持删除,并需确认受影响应用及永久数据删除。标准 Lepton 目录中的容器会整体删除;手动登记的外部位置只删除 baked 目录,保留父目录中的其他文件。卸载应用时默认保留容器,开启“删除容器”才同时删除。
卸载默认保留存档与设置,开启“删除应用数据”才彻底删除。如果容器内只有该应用,会默认勾选“删除容器”,也可手动关闭以保留容器。共享容器或应用状态未知时无法使用该选项,后端删除前会再次核对应用列表。删除容器会同时清理关联 APK 缓存和 Steam 库条目、图标及封面,历史备份保留。保留数据的应用显示在“已卸载但保留数据”中,可以重新安装或清除保留数据,清除时同样提供删除容器选项。
Android 清除已卸载包的保留状态时可能需要原签名 APK。Framely 管理卸载时会保存原 APK。如果其他工具已卸载应用且找不到原 APK,需要先提供原 APK 重新安装,再清除保留数据;不会绕过签名或删除任意目录假装成功。
“数据清理”列出保留数据、旧 APK、恢复快照及过期临时文件。存档与备份默认不选中;未知归属数据只展示,不自动删除。删除前重新核验,已重新安装的应用不会被当作残留清理。批量结果显示实际释放的文件字节及各项失败原因。由于压缩、reflink 和共享存储,文件字节数与物理磁盘占用可能不同。
切换面板不会中止已提交的任务;重新打开 APK 管理会继续显示进度。恢复流程使用本地事务记录,意外中断后会在下次修改操作前恢复到完整状态。
## 日志与面板外恢复
在应用的“日志 → 导出日志”保存操作记录。Android 详细错误保留在日志中,分享前应检查内容。
以 Steam 会话用户执行:
```bash
/var/lib/framely/current/bin/framely apk list
/var/lib/framely/current/bin/framely apk inspect --file /path/to/app.apk
/var/lib/framely/current/bin/framely apk install '{"ticket":"REVIEW_TICKET","approve":true}'
/var/lib/framely/current/bin/framely apk launch '{"app":"CONTEXT/PACKAGE"}'
/var/lib/framely/current/bin/framely apk logs '{"app":"CONTEXT/PACKAGE"}'
/var/lib/framely/current/bin/framely apk cleanup-list
```
校验票据有效期为 15 分钟。APK、管理记录和恢复材料位于 `~/.local/share/framely/apk-manager`,托管 Lepton 数据位于 `~/.local/share/lepton/contexts`。卸载 Framely 保留这些用户目录;卸载前应备份。APK 命令仍需兼容的 Framely 二进制,Lepton 本身由 Steam 独立安装。
请通过 APK 面板或上面的 `framely apk launch` 命令启动 Framely 托管容器。这些入口会保留下载资源并挂载持久存储;直接调用 Lepton 不会应用 Framely 的存储适配。
### 下载资源与 shader 缓存
升级后的下一次容器启动会把已有 Android 共享存储移动到容器内的 `baked/external`,直接挂载,绕开 Android 数据的 OverlayFS 层。迁移只移动目录,保留未完成下载和断点记录,不复制资源内容。若两处目录均已有数据或发现未知链接,会停止启动并保留数据供恢复。已经运行的容器需停止后再次启动才应用新布局;登记的 Steam 兼容数据容器保留原有独立资源布局。
每个容器的 Mesa shader 缓存保存在 `baked/shadercache`,挂载到 `/data/shaders`,重启后仍保留,可复用兼容的已编译 shader;首次使用仍需编译。托管容器备份包含这两个目录,恢复时会随应用数据一起恢复下载资源和缓存。
不要将 Lepton 的全局清理命令用作单应用卸载。
## 格式范围
首版支持单个 APK。Quest 兼容转换同样只接受单个 APK;游戏自行下载的 OBB 资源继续使用持久化容器存储。不包含分包、XAPK/APKM、手动 OBB 导入、Google Play 安装及自动创建 Steam 快捷方式;不支持的包格式会明确拒绝。
### 平面窗口方向
在 **管理应用 → 高级设置 → 窗口方向** 中选择 **自动选择、横屏或竖屏**。每个应用独立保存,下次启动时应用。竖屏使用纵向窗口,而不是让内容侧着显示。切换方向可能重启共享容器,停止其中其他应用;VR 应用忽略此设置。
Moonlight V+ 12.12.12 的创建连接界面在当前测试的 Lepton 版本上会因缺少 Android 剪贴板服务而崩溃,与此前窗口启动失败是两个问题。Framely 不修改或重新签名 APK,需要兼容的 Lepton 运行环境或应用端容错处理。
### 可选的 Frame 手柄输入
**APK → 应用 → Frame 手柄输入**默认关闭,旧配置升级后也保持关闭。开启后,通过 Framely 打开的 APK 可以将两个 Frame 手柄作为一个标准 Android 游戏手柄使用,包括 ABXY、十字键及斜向组合、双摇杆及按下、肩键、模拟扳机、Select 和 Start。应用本身需要支持 Android 游戏手柄;该功能不把按键映射为触屏操作,也不提供 Android VR 手柄追踪或振动。
开启后在下次打开应用时生效。接入输入设备可能需要重启容器,影响其中其他正在运行的应用。关闭开关立即停止转发。同一时间只向一个由 Framely 打开的 APK 转发。切换或关闭应用、进程查询暂时失败、前台切换时只暂停输入,不移除虚拟手柄。每个运行中的容器保留自己的输入设备供下次打开复用;停止容器、关闭开关或会话服务重启时释放设备。服务重启后再次打开 APK 即可重新接入。使用时在 Dock 中选中目标 Android 窗口;该功能不修改 Steam 的实体手柄绑定。
桥接以 Steam 会话用户运行,需要 `/dev/uinput` 访问权限、已运行的 SteamVR 和受支持的 Lepton 启动接口,不修改 Steam 安装目录中的 Lepton 文件。只向目标容器挂载自己的虚拟输入节点,由 Android 独占接收。接管失败时不转发输入,并提示启动错误;可以关闭开关恢复普通启动。错误记录在 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键内容。原生桥接和 Android 适配库均包含在离线包及核心更新包中。
原生 Steam 启动在等待启动确认之前选择输入目标。只有 Android 接管虚拟设备、目标应用进入前台后,才开始转发。每个容器的桥接进程使用独立的临时 SteamVR 身份,避免其他桥接进程覆盖其登记。返回已运行应用时重新选择输入目标。
开启此选项后,请通过管理面板或启动台打开 APK。一次性命令行进程无法管理持续运行的输入桥接,此时命令行启动会明确报错。只读诊断可由 Steam 会话用户运行 `framely apk input-status '{"app":"CONTEXT/PACKAGE"}'`,查看 Android 进程、前台及生命周期状态,不输出输入事件或 Steam 令牌;这些状态不能证明应用实际处理了手柄事件。
## 容器退出策略
APK 页面提供两个持久保存的开关。**应用退出后自动停止容器**默认关闭:确认启动后,应用的所有相关进程连续消失 15 秒才停止容器;后台应用或服务仍存在时不会停止。**关闭窗口时停止容器**默认开启:确认系统发出的窗口关闭信号后停止对应容器,通常需要数秒。切换窗口、最小化和打开启动台不属于关闭。
APK 安装、更新、其他修改与启动检查期间暂停监测。容器重启、进程状态无法确认或旧共享容器内还有其他应用运行时,不停止容器;Steam 管理的容器交给 Steam。停止使用正常 Podman 停止流程,给予 10 秒退出时间,保留 APK、存档和下载数据。已停止的容器再次打开需要冷启动 Android;关闭两个开关后仍会复用健康的运行中容器。
### Steam / Lepton 原生启动
**通过 Steam 启动**默认开启。Framely 使用 Steam 原生 Lepton 兼容工具注册可启动的侧载 APK,保留主动关闭的选择,跳过 Steam 已管理的应用。注册不会启动应用或重启容器;Steam 需要保持唤醒,不可用时会后台重试。
Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的库封面、横幅和背景图,不联网下载图片。启动台原有图标、排序和收藏不变,通过明确的 Steam App ID 绑定过滤 Steam 分类中的重复入口。APK 更新会刷新生成的入口信息。
原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。
切换启动路径之前,需要先关闭已经运行的容器;Framely 不会为了绑定 Steam 重启游戏。旧共享容器仍可识别,但同时只允许一个 Steam 启动占用其数据。跨进程保留的占用锁阻止重复启动及共享数据的并发启动。
独立平面 APK 的实机验证显示,窗口下方和 Dock 的关闭按钮都能弹出 Steam 退出确认,确认后应用和容器停止。也可使用 Steam 的“停止”或 APK 管理中的“停止容器”。具体应用及 VR 应用行为可能不同;直接启动的非 Steam Lepton 窗口仍受当前系统未实现 Wayland 关闭的限制。
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,确认 Android 卸载成功后再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
容器列表标题显示已安装应用名称,数据路径用于识别容器。更新 APK 会先停止容器再备份数据。如果停止失败,或原生 Steam 启动进程尚未释放容器,安装确认中提供 **关闭容器** 按钮并要求确认。关闭会中断运行中的应用,保留存档;已检查的 APK 会保留,可关闭后重试安装。
### Steam 入口保留
**APK → 设置 → 保留安装的 APK 到 Steam** 默认开启。关闭后清理空闲的 Framely 自有入口,不再自动注册。应用启用 **通过 Steam 启动** 时,会在启动时临时注册入口;原生启动进程和容器退出后删除入口及临时 Devkit 副本。已安装 APK、容器、存档和下载的游戏数据都会保留,运行中的入口不会删除。失败或中断的启动在启动宽限期后清理;Steam 不可用时稍后重试。重新开启保留设置会恢复自动注册。
### 卸载并保留容器
保留应用数据的卸载使用 Android 包管理器就绪检查,不再强制等待用户解锁或外部存储挂载。删除数据仍等待这些条件。Framely 在后台启动已停止的容器,不显示容器桌面;需要时只修复目标 APK 的登记。启动、包管理器、解锁、存储和容器退出分别报错。Android 确认卸载成功后才移除 Steam 入口,先保存 Android 卸载结果,重试 Steam 清理不会重复卸载。启动或卸载失败保留应用记录、Steam 绑定和应用数据。错误弹窗提供重试与导出日志。无法启动的容器仍需诊断,不会自动删除容器或直接修改 Android 包数据库。
在“APK 管理 → 设置”中可调节 L2/R2 按键触发行程,范围 1%~100%,默认 80%。两个扳机共用该设置,Steam Input 和 Frame 直连均适用,不改变模拟轴的完整行程。保存后立即应用到正在运行的转发进程;松开阈值比按下低 5 个百分点,设置低于 5% 时在归零后松开。
## 单应用图形兼容
在**管理应用 → 高级设置**中,“帧缓冲兼容修复”默认关闭。仅为有声音但黑屏的游戏开启(已在当前 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`,不记录按键事件。
+53 -5
View File
@@ -9,21 +9,69 @@
在 SteamVR Dashboard 点击 Framely Dock 图标打开快捷菜单。首次访问需阅读并同意协议及隐私声明;未同意时插件不会自动启动。
- **常用**:打开收藏的插件快捷页。
- **已安装**:查看本机插件,进入插件页面。
- **设置**:语言、安全模式和“管理插件”入口。
- **已安装**:查看已启用的本机插件,进入插件页面。
- **通知**:查看收件箱消息和更新提醒,并控制悬浮通知。
- **设置**:选择已安装的语言、切换安全模式,或打开“管理插件”和“检查更新”。“检查更新”打开管理窗口的设置 → 更新页面;安装语言文件和下载语言模板在管理窗口的设置中进行。
“管理插件”打开独立大窗口,左侧导航进入插件、商店、插件源、设置和关于。安装、更新及来源管理在这里完成;电脑或手机也可使用[网络管理面板](network-panel.md)。
“管理插件”打开独立大窗口,顶部进入插件、APK、终端、文件与设置五个区域。安装、更新及来源管理在这里完成;电脑或手机也可使用[网络管理面板](network-panel.md)。
## 插件启停与窗口
在插件列表启用、停用或收藏插件。启用表示允许使用;默认后端按需启动,只有声明 `autostart` 的插件会常驻。关闭插件窗口通常不会停用后台功能。依赖不满足或有冲突时,界面会给出原因,并在变更前显示受影响插件。
带 Dock 图标的独立窗口由 SteamVR 提供关闭控件;临时窗口可能在切换界面时销毁。不同插件的 UI 和功能由各自开发者决定。
无论是否带 Dock 图标,插件窗口都使用相同的 SteamVR 移动、缩放、键盘和关闭控件。不同插件的 UI 和功能由各自开发者决定。
## 设置与诊断
设置中可修改语言、网络面板及代理。HTTP 代理和 GitHub 代理各有开关,保存地址不会自动启用代理。
“安全模式”暂停第三方插件,适合出现故障时检查;排查后再退出安全模式。在“关于”查看本体版本、检查更新和复查协议。撤回协议会停用全部插件,再次同意后需要手动启用。
“安全模式”暂停第三方插件,适合出现故障时检查;排查后再退出安全模式。在“设置 → 关于”查看本体版本和协议,在“设置 → 更新”检查更新。撤回协议会停用全部插件,再次同意后需要手动启用。
在“设置 → 更新”选择正式版或测试版渠道并检查更新,点击“下载并安装”并确认一次:Framely 下载、校验发行包后自动开始安装。下载和校验分别显示阶段提示与实际字节进度。下载或校验失败时停止安装并显示原因。插件和数据保留,Framely 界面会暂时关闭并重新启动。
日志排查命令见[安装指南](installation.md#修复与日志)。界面能打开不代表所有插件都正常;先逐个启用,确认运行身份与来源可信。
## 通知收件箱与自动更新检查
快捷面板的“通知”标签展示明确设置为进入收件箱的消息,浮窗启用时,普通通知默认不保存;浮窗关闭时会转存到收件箱。浮动通知显示自动关闭倒计时,关闭浮动通知会保留已保存的收件箱副本;收件箱里的删除按钮会移除消息。通知按钮默认在操作成功后关闭弹窗并移除收件箱副本,发送方可以分别修改这两个行为。
“通知”顶部的“允许通知”开关控制全局浮窗。关闭后,新通知即使没有声明进入收件箱也会保存到这里,正在显示的浮窗也会转存。重新开启不会重新弹出旧消息。
悬浮通知跟随头显,显示在视野内右上方。激光命中区域与实际卡片对齐,短按按钮可容忍小幅追踪抖动。
管理窗口的“通知设置”提供全局“悬浮通知”和“启动图标角标”开关,并为 Framely 本体(包括更新提醒)及所有已安装插件(包括已停用插件)提供独立的“角标”和“悬浮”开关。全局开关优先,关闭后仍保留各应用偏好。Framely Dock 启动图标的角标统计允许显示角标来源的未读收件箱消息,超过 99 显示 `99+`;实际打开通知标签后消除角标,隐藏的头显面板不会自动标记已读。查看消息不会删除消息,也不会关闭其操作按钮。设置和已读状态均会保存并在重启后恢复。
本体默认在启动约 30 秒后检查更新(须已接受用户协议),之后每 6 小时检查一次;面板关闭时也会继续检查。可在管理窗口“设置 → 更新”关闭自动检查或修改间隔。自动检查遵循所选正式版/测试版渠道及代理配置,只检查并提醒,不会自动下载或安装。
发现较新版本后会将提醒保存在收件箱,通知设置允许时同时显示浮窗。“忽略”关闭并移除此版本提醒,同一版本在重启后也不会重复提醒;“打开更新”直接打开管理窗口的更新页面。更改更新源或渠道会清除旧提醒。
## 导出日志
打开 **设置 → 恢复与诊断 → 导出日志**。在头显内,ZIP 保存到 Frame 的 `~/Downloads`,面板显示保存路径;通过网络面板访问时,ZIP 下载到浏览器。日志包包括本体与插件的近期日志、CEF 渲染日志、GPU 信息、服务状态和安装版本。缺失来源或命令失败会记录在 `report.json` 中,不影响导出其余信息;单个来源和总收集量均有大小限制。
管理面板无法打开时,使用安装器连接 Frame,点击底部的 **导出日志**,选择电脑上的保存位置。安装器直接通过 SSH 收集,也会加入当前安装器的操作日志;本体服务停止或状态文件损坏时仍可使用,无需选择发行版本或安装包。
日志包不包含插件配置、收件箱内容或密码文件,会过滤日志中的常见凭据格式;分享前仍应检查应用日志。不会自动上传。
无需运行本体服务,也可以通过终端导出:
```bash
sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs.zip
```
输出路径必须是新文件。本体程序无法运行时,解压发行包中的 `tools/export-diagnostics.py` 也可独立使用:`sudo python3 tools/export-diagnostics.py --output /tmp/framely-logs.zip`。
## VR 页面滚动
激光指向页面后,拨动任意手柄的摇杆即可滚动,无需先点击或聚焦页面。滚动作用于激光下的元素,也支持插件页面的内层滚动区域。键盘打开时暂停页面摇杆滚动;启动台仍使用原有的摇杆分类与翻页操作。
按住主扳机并上下移动激光,可拖动可滚动的页面内容。小幅抖动仍视为普通点击;纵向拖动开始后,松开不会触发原先按下的按钮。文本输入、滑块及明确声明的自定义拖拽区域保留自身交互。启动台保留图标排列和横向拖动翻页操作。
启动台窗口内部的空白点击只收起图标菜单。点击窗口外或按 B 关闭;调整模式下 B 先退出调整。Steam 的移动、推远和拉近控件会保持窗口打开,并暂停摇杆导航,摇杆回中后恢复。调整模式拖拽图标时可以用摇杆左右翻页,上下不会切换分类;也可将指针移过分页线并停留 650 毫秒跨页。
## 启动台资源优先与关闭
设置 → 启动台新增默认开启的**启动台资源优先**。启动台打开时,将 Framely 原生界面与 CEF 线程的 CPU 调度优先级提高到有界的 nice −5,关闭启动台或禁用设置后恢复原优先级。游戏与终端进程不参与提升。此设置可以改善 CPU 竞争,不预留 GPU 资源;安装后的会话服务需要包含对应的优先级限制。
关闭启动台时,之前选中的 Dock 窗口仍存在则返回该窗口。启动应用时由 Steam 接管焦点,不恢复旧窗口。Android 确认应用启动后即交接 APK 窗口,后续仍进行启动存活检查。运行中的容器即使已隐藏应用窗口也会复用,不再仅因之前游戏退出就重启。
+16 -4
View File
@@ -6,7 +6,7 @@
## 安装前
目标设备为 Linux ARM64 Steam Frame。当前开发基线为 SteamOS VR 0.4.2、SteamVR build 20260928.6175029;其他系统版本需验证。运行包自带 CEF,设备使用时无需安装 Node.js 或 Rust。
目标设备为 Linux ARM64 Steam Frame。当前开发基线为 SteamOS VR 0.4.2、SteamVR build 20260928.6175029;其他系统版本需验证。完整离线安装包自带 CEF,设备使用时无需安装 Node.js 或 Rust。
电脑与 Frame 连接同一网络,在 Frame 开启开发者模式并启用 SSH,准备 Steam 用户 `steamos` 的登录密码。安装会使用 sudo 创建账号、systemd 服务和发行目录;请核对 SSH 指纹。安装器不会自动解除 SteamOS 只读保护;实际所需目录不可写时会停止。
@@ -40,14 +40,26 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
## 方式三:本地发行包
下载设备运行包 `framely-<版本及构建号>-linux-arm64.tar.gz`、外部 `SHA256SUMS` 和同一 Release 的 `bootstrap.py`,放入 Frame 的同一目录:
下载完整离线包 `framely-<版本及构建号>-offline-linux-arm64.tar.gz`、外部 `SHA256SUMS` 和同一 Release 的 `bootstrap.py`,放入 Frame 的同一目录:
```bash
python3 bootstrap.py install --archive "./framely-<版本及构建号>-linux-arm64.tar.gz" --checksums ./SHA256SUMS
python3 bootstrap.py install --archive "./framely-<版本及构建号>-offline-linux-arm64.tar.gz" --checksums ./SHA256SUMS
```
尖括号是占位符,执行前替换为真实文件名。引擎校验后在 `/home` 暂存、解压并检查包内哈希,避免耗尽设备的 `/tmp`。
## 发行包与 CEF
每个本体 Release 提供三种压缩包:
- `framely-<版本及构建号>-offline-linux-arm64.tar.gz`:包含 CEF 的完整离线包,首次安装固定使用此包。
- `framely-<版本及构建号>-linux-arm64.tar.gz`:不含 CEF 的本体包;桌面安装器、SSH 引擎及本体更新器在更新时自动选择此包。
- `framely-cef-<运行库标识>-linux-arm64.tar.gz`:单独的 CEF 运行库,提供在线下载;`framely-cef.json` 包含下载 URL、大小和 SHA256。
CEF 保存在 `/home/.framely/cef/<运行库标识>`。更新复用经过校验的对应运行库;旧完整安装中相同的 CEF 文件会自动迁移。所需运行库缺失或发行更换 CEF 时,本体安装流程通过 HTTPS 下载单独的匹配 CEF 包,并校验大小、SHA256 和包内哈希。完整离线包直接提供运行库,无需下载 CEF。
各发行引用自己的 CEF 版本,回滚时仍保留之前的运行库;修复会校验运行库,卸载则与程序一起移除 CEF 缓存。本地首次安装必须选择完整离线包;本地更新可使用不含 CEF 的包,前提是对应运行库已安装,否则恢复运行库需要联网。
## 验证安装
```bash
@@ -60,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
+3 -1
View File
@@ -24,7 +24,7 @@ http://设备IP:15915
## 可以做什么
登录后可以管理来源和订阅、浏览商店、导入本地插件包、确认安装、启停插件、修改设置及检查本体更新。插件后端始终运行在 Frame,上传本地包只是从手机/电脑把文件传到设备。
登录后可以管理来源和订阅、浏览插件库、导入本地插件包、确认安装、启停插件、修改设置及检查本体更新。插件后端始终运行在 Frame,上传本地包只是从手机/电脑把文件传到设备。
VR 窗口、手柄振动和 SteamVR 虚拟键盘需要头显端验收;浏览器无法替代这些设备交互。部分外部链接由设备默认浏览器打开。
@@ -35,3 +35,5 @@ VR 窗口、手柄振动和 SteamVR 虚拟键盘需要头显端验收;浏览
会话服务重启也会使旧登录失效,需要重新登录。保存完全相同的设置不会撤销当前会话。错误密码验证有速率限制,提示尝试过多时稍后再试。
无法访问时检查设备 IP 是否变化、两台设备是否处于可互通的网络、面板是否开启以及端口是否正确。设置/密码保存在 `/home/.framely/state`,卸载本体保留这些状态,重装后可能仍需原密码。
顶部切换器提供插件、APK、终端、文件与设置;设备地址默认打开插件。[终端与文件](terminal-and-files.md)提供用户 Shell 会话、上传下载、编辑、压缩及预览,复用相同登录和端口。
+6 -6
View File
@@ -4,21 +4,21 @@
[文档首页](../README.md) · [网络面板](network-panel.md)
## 从商店安装
## 从插件库安装
1. 打开管理窗口“商店”。当前代码首次初始化加入社区 stable/testing 来源;来源内容取决于服务和设备配置。没有目录或插件时,进入“插件源”添加开发者提供的完整 HTTPS `catalog.json` 地址。
1. 打开管理窗口“插件库”。当前代码首次初始化加入社区 stable/testing 来源;来源内容取决于服务和设备配置。没有目录或插件时,进入“插件源”添加开发者提供的完整 HTTPS `catalog.json` 地址。
2. 可按关键词、标签、来源或安装状态筛选。
3. 打开详情,查看作者、介绍、更新记录、运行用户和依赖。默认显示推荐版本,历史版本会按需加载。
4. 点击安装或检查所选版本。下载、校验和依赖解析完成后,阅读确认页。
5. 检查包内实际运行用户以及依赖带来的 root 权限,确认后安装。安装后在插件列表启用或打开。
商店提供的运行用户只是目录资料,最终确认以下载包内声明为准。`steamos` 使用 Steam 会话用户,`root` 可修改系统和设备设置。包哈希用于校验内容,不等于对作者身份或安全性的担保。
插件库提供的运行用户只是目录资料,最终确认以下载包内声明为准。`steamos` 使用 Steam 会话用户,`root` 可修改系统和设备设置。包哈希用于校验内容,不等于对作者身份或安全性的担保。
## 添加源与订阅
单个目录地址:在“插件源”添加名称和 `https://.../catalog.json`,测试连接后保存。
多个源的订阅:在“源订阅”粘贴开发者提供的订阅 JSON 地址,预览所含来源后确认添加。它和单个目录不是同一种文件。订阅刷新仅更新来源和商店资料,不自动安装或更新插件。
多个源的订阅:在“源订阅”粘贴开发者提供的订阅 JSON 地址,预览所含来源后确认添加。它和单个目录不是同一种文件。订阅刷新仅更新来源和插件库资料,不自动安装或更新插件。
停用或删除来源不会自动卸载已安装插件;订阅管理的 URL 变更需另行确认。格式与刷新行为见[源订阅参考](../source-subscriptions.md)。
@@ -41,7 +41,7 @@ sudo /var/lib/framely/current/bin/framely install ./plugin.framely --approve
## 更新、选择旧版与卸载
商店提示“可更新”时进入详情,查看变化后按安装确认流程更新。需要旧版时,在详情选择历史版本再检查;换来源也会明确提示。设置和收藏通常保留,但运行用户改变会使用另一份数据目录,不自动迁移旧身份的数据。
插件库提示“可更新”时进入详情,查看变化后按安装确认流程更新。需要旧版时,在详情选择历史版本再检查;换来源也会明确提示。设置和收藏通常保留,但运行用户改变会使用另一份数据目录,不自动迁移旧身份的数据。
从插件管理弹窗卸载。卸载钩子负责清理该插件的外部资源,保存的数据保留;不要直接删除系统中的插件目录。停用只停止使用,不卸载程序和数据。
@@ -49,7 +49,7 @@ sudo /var/lib/framely/current/bin/framely install ./plugin.framely --approve
| 现象 | 处理 |
| --- | --- |
| 商店空白 | 检查是否添加并启用了来源、连接能否成功、筛选是否过窄 |
| 插件库空白 | 检查是否添加并启用了来源、连接能否成功、筛选是否过窄 |
| 下载或校验失败 | 检查网络和包地址,重新下载;不要跳过哈希校验 |
| 历史版本不可用 | 来源可能没有历史文件,可先安装最新版或导入可信旧包 |
| 插件无法启用 | 查看依赖、冲突和错误说明,确认其他必要插件已安装 |
@@ -0,0 +1,95 @@
# 终端与文件
[English](../../user-guide/terminal-and-files.md)
[文档索引](../README.md) · [网络面板](network-panel.md)
## 管理面板导航
顶部切换器为 **插件 / APK / 终端 / 文件 / 设置**。直接访问设备 IP 默认进入插件;各区域在当前浏览器中记住上次子页面。原有 `#settings`、`#launcher-settings`、`#about`、`#updates` 链接继续有效。
插件区域包含已安装插件、插件库、来源和通知设置,通知仍包含 Framely 本体;插件自身设置位于对应插件的管理弹窗。APK 保留应用、容器及数据清理。设置集中放置通用、启动台、更新和关于。
## 手机操作
手机端底部导航包含插件、APK、终端、文件和设置。点击顶部当前页面名称选择子页面,确认和安装弹窗以底部面板展示。界面为安全区和软键盘保留空间,键盘展开时隐藏底部导航。
文件页点击“浏览文件夹”打开快速访问与目录树抽屉,选择目录后自动收起。“新建与上传”和“筛选与显示”打开操作面板;点击“选择”勾选文件,再从底部选择栏的“操作”进入复制、移动、下载或删除,无需右键。浏览目录和切换区域不停止上传队列。
终端提供 Ctrl+C、Esc、Tab 和方向快捷键,其中 Ctrl+C 用于中断程序。“粘贴”在浏览器允许时读取剪贴板,否则打开文本框,使用手机原生粘贴后点击“发送到终端”。断连或只读时禁用输入快捷键。
## 交互反馈
在 **设置 → 通用 → 交互反馈** 中,振动反馈默认开启,提示音反馈默认关闭,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机键盘音效,以较低音量反馈聚焦和点击,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
## 终端
新建终端会以安装时配置的 Steam 会话用户打开登录 Shell,初始目录为该用户主目录,通常是 `steamos`。真实 PTY 支持 Ctrl-C、Tab 补全、方向键、中文、尺寸调整,以及 Vim、top 等全屏程序。需要管理员权限时在终端使用 `sudo`;面板访问密码与 sudo 密码不同。
最多创建 12 个会话,支持改名、重连及明确关闭。切换页面、关闭浏览器或断网不结束 Shell;用户会话服务或 Framely 重启后结束,不恢复进程。关闭须确认,并回收 Shell、属于该 Unix 会话的子进程及 PTY。
同一会话只允许一个连接输入,其他连接只读;“接管控制”转移输入权限。断网时禁止输入,重连不重放按键,仅恢复保留输出。每个会话缓存上限 2 MiB,截断旧输出时显示提示;重放截断后的原始终端数据可能缺少此前屏幕上下文。
终端输入、sudo 密码及输出不写入 Framely 日志。输出作为终端字符渲染,不执行 HTML,忽略写剪贴板的转义请求。xterm.js 和尺寸适配依赖随离线包提供,不使用 CDN。
使用 **Ctrl+Shift+C** 复制终端选中文本,**Ctrl+Shift+V** 粘贴。**Ctrl+C** 用于中断运行中的程序,不作为复制快捷键;终端底部会显示提示。
## 浏览与整理文件
默认打开用户主目录,以该用户权限访问文件系统,不自动提权。权限编辑仅支持普通 Unix 权限位;管理员操作请使用终端里的 `sudo`。
通过地址栏、面包屑、上级按钮或可展开文件夹树导航。快速访问包含主目录、下载、挂载存储、文件系统根目录、回收站及自定义文件夹。星标按钮添加当前目录,旁边按钮可改名、排序、移除;暂不可用的目录保留记录,打开时显示访问错误。
文件列表支持隐藏文件、排序、多选、当前目录过滤及可取消的递归文件名搜索。递归搜索不跟随目录链接;列表每页 200 项,递归搜索最多返回 5,000 个匹配项、访问 500,000 项。不在后台扫描媒体内容。
“操作”提供复制、剪切、粘贴、重命名、删除、属性、权限修改、压缩、解压及打开。右键文件会在指针处打开操作菜单。符号链接显示箭头,属性显示目标;复制保留链接,删除链接不会删除目标。特殊文件不能复制或压缩。
复制、移动、解压可按批次选择跳过、保留两份或明确确认覆盖。同名文件夹覆盖会替换整个文件夹,不合并内容。长任务显示真实字节或已完成条目,可取消并逐项报告错误;后续失败或取消不撤销已完成的项目。在当前网页内切换区域会保留文件任务及编辑状态。
拖动侧栏与文件列表之间的分隔线可调整宽度,并在当前浏览器中记住;窄窗口会自动限制宽度。快速访问固定在顶部一定高度,目录树占据侧栏剩余高度;两个列表分别在内部滚动,标题保持可见,从右侧列表或地址栏进入文件夹时,目录树会展开并标记对应位置。
右键文件或文件夹会在指针处打开操作菜单;右键已选中的项目时保留多选。右键列表空白处可新建文件或文件夹、粘贴、上传和刷新。按 Escape 或点击其他位置关闭菜单。
## 回收站
删除默认选择“移至回收站(30 天)”,永久删除须主动选择并确认。回收站保存在原文件系统内:主目录回收站,或存储挂载点的私有 `.framely-trash-UID` 目录。存储无法支持回收站时报告错误,不自动改成永久删除。
支持恢复到空闲名称或保留两份、永久删除及清空已登记项目。恢复要求原父目录存在。用户会话服务运行时约每小时检查到期项目,只清理 Framely 登记的回收站文件;存储离线时保留记录,恢复访问后再处理。不影响其他应用的回收站。
## 传输与编辑
从电脑拖入文件、文件夹或混合选择。列表、图标视图、侧边目录树、快速访问及面包屑中的文件夹均为上传目标;列表空白区域对应当前目录。拖动时高亮目标并显示路径,文件条目、回收站内容及预览遮罩不接受上传。
写入前弹窗确认真实目标路径、文件与文件夹数量及总大小,并选择跳过、覆盖或保留两者,默认保留两者。同名文件夹合并,策略只处理同名文件,已有其他文件保留。跳过在传输前判断;覆盖在上传完成后原子替换;保留两者会显示新文件名。类型冲突逐项报错,目标目录变化及嵌套符号链接逃逸会被拒绝。
点击“上传任务队列”按钮打开紧凑悬浮面板,不挤占文件列表。队列按批次和目标分组,显示已上传文件数与总文件数(不包含文件夹,跳过的文件不计为已上传),可取消单项或整批、重试失败项及清除已结束记录。“同时上传文件数”默认 3,可设置 1–8,按设备用户全局保存,不改变展示方式和目录排序。增加立即补充任务,减少时让正在上传的文件完成。每文件最多两个分块请求,整个队列最多八个分块请求。上传不锁住文件页面,切换目录或管理区域后继续,目标保持确认时的路径。
拖入文件夹会保留空文件夹。上传文件夹按钮优先使用浏览器目录 API;传统文件输入框只能返回文件,空文件夹请通过拖放上传。大目录异步枚举,完整读取浏览器分批返回的条目,每次最多 100,000 项。
队列仅保留在当前浏览器页面,刷新或关闭前提醒,并尽力取消活动上传;服务端按闲置时间兜底清理。已完成文件保留,失败项从头重试,不提供跨刷新续传。
网页可上传多个文件或文件夹,使用 16 MiB 分块和字节进度,先写私有临时文件,完成后才获得最终文件名。取消会移除临时文件,约每小时清理闲置至少一小时的上传。服务被强行停止可能留下明确命名的 `.framely-upload-*` 临时文件,不会冒充完整结果。每个文件最大 64 GiB,同时最多保存 64 条上传记录。
单文件可直接下载,多选文件或文件夹以 ZIP 下载;下载链接一小时后失效。大文件流式传输,单段 Range 请求支持视频拖动和下载续传。
普通文件打开时尝试 UTF-8 文本编辑器,上限 5 MiB;非法编码、二进制或超大文件无法编辑,可下载后处理。保存校验内容版本,外部修改后须重新加载或确认覆盖。关闭未保存内容会要求确认。
## 压缩与媒体
支持创建 ZIP、TAR、TAR.GZ,输出文件名决定格式。解压时选择目标目录和冲突策略。拒绝绝对路径、向上路径穿越、软硬链接及特殊文件,不执行解压内容;最多 50,000 项、展开大小 64 GiB。取消会清理暂存文件,保留已经提交的项目。
选择图片或视频后才读取内容预览。PNG、JPEG、GIF、WebP、AVIF、BMP、MP4、WebM、MOV 使用浏览器原生解码,具体编码支持取决于浏览器;视频提供播放、暂停、拖动及全屏。不支持的格式提示下载,不转码。HTML、SVG 不作为同源页面打开。
## 访问与隐私
终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 WebSocket 严格匹配 Origin,插件沙箱不可调用。接口运行于 Steam 用户会话服务,不借用 root 本体执行文件操作。完整预览与下载在用户主动选择后读取内容;图标视图仅为当前可见的媒体按需读取并生成缩略图,不后台扫描目录媒体。测试媒体为生成的样本,开发及验证不读取或上传私人 Arcturus 录制文件。
## 导出诊断日志
**设置 → 通用 → 导出日志** 和安装器的 **导出日志** 使用同一收集流程。日志包包含近期 APK 操作日志、Lepton 已保存的 Android 日志,以及运行中容器的有限快照:Android 启动、用户解锁、存储挂载、包管理器就绪状态和近期 main/system/crash 日志。以配置的 Steam 会话用户检查无 root 容器,不启动已停止的容器。应用状态只导出摘要,排除令牌和图标;不读取 APK、存档、媒体或原始内存转储。单文件及总大小受限,保留近期输出;缺失来源、截断和命令超时记录在 `report.json`。日志可能包含应用信息,分享前请检查。
文件排序(名称、大小或修改时间,以及升序/降序)按文件夹分别记忆。“展示方式”可切换列表、小图标、中图标和大图标,此选项对所有文件夹统一生效。偏好保存在 Frame,重新打开浏览器或头显管理面板时恢复。图标视图仅为当前可见的图片和视频按需生成缩略图,使用浏览器解码,最多同时处理两项。视频截取约第 1 秒的画面。缩略图使用有上限的页面内存缓存,文件元数据变化后重新生成;滚出可视区域会取消待处理任务。不支持的格式或解码失败时保留文件类型图标。不使用 FFmpeg,也不扫描整个文件夹的媒体。
点击图片或视频会打开全屏遮罩预览。使用滚轮或缩放按钮缩放,拖动画面移动,通过上一个/下一个或方向键切换当前展示列表中的图片、视频。“适应窗口”恢复初始视图,Esc 关闭;视频保留播放控制。浏览器无法解码时,可在预览中下载文件。5 MiB 以内的 UTF-8 文本仍在编辑器中打开;二进制文件、不支持的编码及更大的文件点击后直接下载。
查看器操作集中在底部居中的悬浮胶囊条中,文件名在下方居中显示。点击空白区域关闭;点击媒体、操作按钮或拖动画面不会误关闭。
+1 -1
View File
@@ -1,4 +1,4 @@
import React,{useState} from 'react';
import {registerPlugin,framely,Section,Button} from '@framely/sdk';
function Page(){const [text,setText]=useState('');const [result,setResult]=useState('');const [count,setCount]=useState(0);async function run(f:()=>Promise<unknown>){try{setResult(JSON.stringify(await f(),null,2));}catch(e){setResult(String(e));}}return <><Section title="Framely 功能测试"><small>每个按钮都调用正式 SDK。</small><p><Button onClick={()=>setCount(n=>n+1)}>点击计数:{count}</Button><Button onClick={()=>void run(()=>framely.call('identity'))}>读取后端身份</Button></p></Section><Section title="输入与持久化"><input placeholder="点击后打开 Steam 虚拟键盘" value={text} onChange={e=>setText(e.target.value)}/><Button onClick={()=>void run(()=>framely.call('save',{text}))}>保存</Button><Button onClick={()=>void run(()=>framely.call('read'))}>读取</Button></Section><Section title="窗口"><Button onClick={()=>void run(()=>framely.windows.open('main'))}>打开原生 Dock 窗口</Button><Button onClick={()=>void run(()=>framely.windows.open('temporary'))}>打开临时窗口</Button><Button onClick={()=>void run(()=>framely.windows.close('main'))}>关闭大窗口</Button><Button onClick={()=>void run(()=>framely.windows.close('temporary'))}>关闭临时窗口</Button></Section><Section title="通知"><Button onClick={()=>void run(()=>framely.notifications.send({id:'showcase',title:'Framely 通知测试',body:'一张图片和三个按钮。重复发送会更新这条通知。',image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII=',durationMs:15000,actions:[{id:'ok',label:'确认',icon:'✓'},{id:'save',label:'保存',icon:'↓'},{id:'more',label:'详情',icon:'⋯'}]}))}>发送通知</Button><Button onClick={()=>void run(()=>framely.notifications.remove('showcase'))}>撤回</Button></Section><pre style={{whiteSpace:'pre-wrap',fontSize:16}}>{result}</pre><Section title="滚动测试">{Array.from({length:12},(_,i)=><p key={i}>测试条目 {i+1}</p>)}</Section></>}
function Page(){const [text,setText]=useState('');const [result,setResult]=useState('');const [count,setCount]=useState(0);async function run(f:()=>Promise<unknown>){try{setResult(JSON.stringify(await f(),null,2));}catch(e){setResult(String(e));}}return <><Section title="Framely 功能测试"><small>每个按钮都调用正式 SDK。</small><p><Button onClick={()=>setCount(n=>n+1)}>点击计数:{count}</Button><Button onClick={()=>void run(()=>framely.call('identity'))}>读取后端身份</Button></p></Section><Section title="输入与持久化"><input placeholder="点击后打开 Steam 虚拟键盘" value={text} onChange={e=>setText(e.target.value)}/><Button onClick={()=>void run(()=>framely.call('save',{text}))}>保存</Button><Button onClick={()=>void run(()=>framely.call('read'))}>读取</Button></Section><Section title="窗口"><Button onClick={()=>void run(()=>framely.windows.open('main'))}>打开原生 Dock 窗口</Button><Button onClick={()=>void run(()=>framely.windows.open('temporary'))}>打开临时窗口</Button><Button onClick={()=>void run(()=>framely.windows.close('main'))}>关闭大窗口</Button><Button onClick={()=>void run(()=>framely.windows.close('temporary'))}>关闭临时窗口</Button></Section><Section title="通知"><Button onClick={()=>void run(()=>framely.notifications.send({id:'showcase',inbox:true,title:'Framely 通知测试',body:'一张图片和三个按钮。重复发送会更新这条通知。',image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII=',durationMs:15000,actions:[{id:'ok',label:'确认',icon:'✓'},{id:'save',label:'保存',icon:'↓'},{id:'more',label:'详情',icon:'⋯'}]}))}>发送通知</Button><Button onClick={()=>void run(()=>framely.notifications.remove('showcase'))}>撤回</Button></Section><pre style={{whiteSpace:'pre-wrap',fontSize:16}}>{result}</pre><Section title="滚动测试">{Array.from({length:12},(_,i)=><p key={i}>测试条目 {i+1}</p>)}</Section></>}
registerPlugin({QuickPage:Page,WindowPage:Page});
+14 -1
View File
@@ -1985,7 +1985,7 @@ dependencies = [
[[package]]
name = "framely-installer"
version = "0.4.1-preview.6"
version = "0.4.1-preview.10"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -2003,6 +2003,7 @@ dependencies = [
"tempfile",
"ureq",
"url",
"winreg",
"winresource",
"zeroize",
]
@@ -6549,6 +6550,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]]
name = "spin"
version = "0.9.9"
@@ -7450,6 +7462,7 @@ dependencies = [
"rustls-pki-types",
"serde",
"serde_json",
"socks",
"url",
"webpki-roots 0.26.11",
]
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "framely-installer"
version = "0.4.1-preview.6"
version = "0.4.1-preview.10"
edition = "2024"
license = "AGPL-3.0-only"
@@ -22,7 +22,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
hex = "0.4"
ureq = { version = "2.12", features = ["json"] }
ureq = { version = "2.12", features = ["json", "socks-proxy"] }
url = "2"
ssh2 = { version = "0.9", features = ["vendored-openssl"] }
if-addrs = "0.13"
@@ -33,6 +33,9 @@ tempfile = "3"
zeroize = "1"
directories = "6"
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "=0.55.0"
[build-dependencies]
winresource = "=0.1.31"
+19 -1
View File
@@ -32,6 +32,12 @@ After an operation, maintenance shows a separate success/failure card and a reco
Scanning, downloads, and SSH work run on background threads, with progress and logs in the UI. The network panel button assumes port 15915; use the actual address if the device port has changed.
After a successful first installation, the installer automatically opens the network management panel in your browser. The highlighted **Management panel** button remains available after installation and updates.
Online first installation always downloads the complete `*-offline-linux-arm64.tar.gz` package with CEF. Online updates download the core package without CEF and reuse the device runtime. A missing or newly required CEF runtime is downloaded separately by the device. For local first installation, choose the complete offline package. See [package selection](../docs/user-guide/installation.md#release-packages-and-cef).
Release queries, checksum files and package downloads automatically use the computer's manual system proxy: Windows Internet Settings, macOS network settings, or Linux GNOME/KDE settings. `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` (or their lowercase forms) override desktop settings; `NO_PROXY` and desktop bypass rules are respected, including on redirects. HTTP CONNECT and SOCKS proxies are supported. PAC scripts and IPv6 proxy endpoints are currently unsupported; use a manual proxy with a hostname or IPv4 address. SSH/SFTP and downloads initiated by Frame use the device connection and network rather than the computer's HTTP proxy.
## Build
Linux requires C/C++ build tools, CMake, pkg-config, and development libraries for Fontconfig/Freetype, X11/XCB, XKBCommon, Wayland, and Vulkan. macOS requires Xcode command-line tools. Windows requires MSVC, CMake, and Perl for built-in OpenSSL. GPUI Kit is pinned to 0.7.0; matching dependencies are locked in `Cargo.lock`.
@@ -58,12 +64,14 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
Preview requires an available GPU or software Vulkan renderer. Default release builds do not include preview support.
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.6`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.10`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
Actions builds Linux x64/ARM64, Windows x64, and macOS Intel/Apple Silicon. macOS produces an `.app` ZIP, Windows an EXE ZIP, and Linux a tar.gz. Apple notarization and Windows code signing are not configured; verify first-launch behavior on each system before publishing.
The titlebar and application icons share the pixel logo. Windows builds embed the ICO in the EXE; macOS packaging uses the system `iconutil` to generate and configure `.icns`. On Linux, run `bash install-desktop-entry.sh` from the extracted archive to register an icon-bearing entry in the current user's application menu. The entry points to that directory; rerun the script after moving it.
Installer archives do not include a separate `assets` directory. The UI logo is embedded in the executable; the Linux registration script embeds its menu icon, while macOS keeps its application icon inside the `.app` bundle.
Automated tests and Actions builds validate software and artifacts. Discovery, login, permissions, update, rollback, and uninstall still need acceptance testing on actual computers and Frame hardware.
Discovery queries `frame.local` mDNS address records directly. It does not require an advertised SSH service or system DNS support for `.local`. Run the same discovery logic independently for diagnosis:
@@ -71,3 +79,13 @@ Discovery queries `frame.local` mDNS address records directly. It does not requi
```bash
cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --example scan -- 192.168.5.0/24
```
## Export diagnostic logs
After connecting to Frame, click **Export logs** in the footer and choose a ZIP destination on your computer. You do not need to select a release or installation package. Collection runs directly over SSH with administrator access, independently of Framely’s web panel and daemon. It also works with stopped services or damaged state, and includes the installer’s current operation log. Export does not install, restart or change Framely.
The archive includes bounded recent service/plugin/CEF logs, GPU evidence, installation versions and a plugin status summary. Missing sources are listed in `report.json`. Plugin settings, inbox contents and password files are excluded; common credential patterns are masked. Review logs before sharing. Cancelling the save dialog leaves the installation untouched.
### Proxy preferences
The **Settings** tab controls downloads on the installer computer. System proxy discovery is enabled by default. An explicit HTTP/SOCKS proxy takes precedence; leave it empty to follow the system toggle. An optional HTTPS GitHub proxy prefix routes GitHub API and asset URLs as `prefix/original-URL`. Settings persist locally and apply to subsequent version checks and downloads; SSH and Frame settings are unchanged. Saving clears the release selection so the next version check uses the new settings. Package verification remains enabled.
+19 -1
View File
@@ -32,6 +32,12 @@ Rust + GPUI Kit 的桌面安装器,连接 Steam Frame,安装、更新、修
扫描、下载和 SSH 工作在后台线程运行,界面展示进度和日志。默认网络管理面板按钮使用端口 15915;设备修改端口后应使用实际地址。
首次安装成功后,安装器会自动在浏览器打开网络管理面板;高亮的“管理面板”按钮在安装和更新完成后仍可使用。
在线首次安装固定下载包含 CEF 的完整 `*-offline-linux-arm64.tar.gz` 包;在线更新下载不含 CEF 的本体包并复用设备运行库。所需 CEF 缺失或版本更换时,由设备单独下载。本地首次安装也需选择完整离线包,参见[发行包说明](../docs/zh-CN/user-guide/installation.md#发行包与-cef)。
版本查询、校验文件和压缩包下载自动读取电脑的手动系统代理:Windows Internet 设置、macOS 网络设置及 Linux GNOME/KDE 设置。`HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY`(也支持小写)优先于桌面设置;遵循 `NO_PROXY` 及桌面绕过规则,重定向后也会重新判断。支持 HTTP CONNECT 和 SOCKS 代理。暂不支持 PAC 脚本及 IPv6 代理地址,可使用主机名或 IPv4 地址的手动代理。SSH/SFTP 使用设备连接,Frame 主动下载使用设备网络,不使用电脑的 HTTP 代理。
## 构建
Linux 需要 C/C++ 构建工具、CMake、pkg-config、Fontconfig/Freetype、X11/XCB、XKBCommon、Wayland 和 Vulkan 开发库。macOS 需要 Xcode 命令行工具;Windows 需要 MSVC、CMake 和 Perl(用于内置 OpenSSL)。GPUI Kit 固定为 0.7.0,配套依赖由 `Cargo.lock` 锁定。
@@ -58,12 +64,14 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
预览依赖可用的 GPU 或软件 Vulkan 渲染器;默认发行构建不包含此预览功能。
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.6`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.10`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
Actions 构建 Linux x64/ARM64、Windows x64、macOS Intel/Apple Silicon。macOS 输出 `.app` ZIP,Windows 输出 EXE ZIP,Linux 输出 tar.gz。当前未配置 Apple 公证或 Windows 代码签名,发布前应分别验证系统的首次启动体验。
安装器标题栏和应用图标使用统一的像素 Logo。Windows 构建将 ICO 嵌入 EXE,macOS 打包使用系统 `iconutil` 生成并配置 `.icns`。Linux 解压后可在解压目录执行 `bash install-desktop-entry.sh`,把带图标的入口注册到当前用户的应用菜单;入口指向该目录,移动后需重新执行脚本。
安装器压缩包不包含独立的 `assets` 目录。界面 Logo 内嵌在可执行文件中,Linux 注册脚本内嵌菜单图标,macOS 的应用图标保存在 `.app` 内部。
所有自动测试和 Actions 构建只能验证软件及产物;仍需在真实电脑和 Frame 上完成扫描、登录、权限、升级回滚及卸载验收。
扫描会直接查询 `frame.local` 的 mDNS 地址记录,不要求设备广播 SSH 服务,也不依赖电脑的系统 DNS 支持 `.local`。可单独运行与界面相同的发现逻辑进行诊断:
@@ -71,3 +79,13 @@ Actions 构建 Linux x64/ARM64、Windows x64、macOS Intel/Apple Silicon。macOS
```bash
cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --example scan -- 192.168.5.0/24
```
## 导出诊断日志
连接 Frame 后,点击底部的 **导出日志**,选择电脑上的 ZIP 保存位置,无需选择发行版本或安装包。安装器通过 SSH 和管理员权限独立收集,不依赖网络面板或本体服务;服务停止、状态文件损坏时仍能导出,并包含安装器当前的操作日志。导出不会安装、重启或修改 Framely。
日志包包含有大小限制的近期服务、插件和 CEF 日志、GPU 信息、安装版本及插件状态摘要。缺失来源记录在 `report.json` 中。不包含插件设置、收件箱内容和密码文件,并过滤常见凭据格式;分享前仍应检查日志。取消保存弹窗不会改变安装状态。
### 代理设置
**设置**页控制安装器电脑的下载。默认启用系统代理;填写 HTTP/SOCKS 代理后优先使用指定地址,留空则遵循系统代理开关。可选的 HTTPS GitHub 代理以 `代理前缀/原始 URL` 转发 GitHub API 和资源请求。设置在本机保存,后续检查版本和下载时生效,不更改 SSH 或 Frame 的设置。保存会清除版本选择,下次检查版本使用新配置;包校验继续启用。
+5 -2
View File
@@ -3,7 +3,7 @@
set -euo pipefail
base=$(cd -- "$(dirname -- "$0")" && pwd)
python3 - "$base" <<'PY'
import os, pathlib, shutil
import os, pathlib
base = pathlib.Path(__import__('sys').argv[1])
binary = base / 'framely-installer'
if not binary.is_file():
@@ -13,7 +13,10 @@ apps = data / 'applications'
icons = data / 'icons/hicolor/scalable/apps'
apps.mkdir(parents=True, exist_ok=True)
icons.mkdir(parents=True, exist_ok=True)
shutil.copy2(base / 'assets/branding/framely-app-icon.svg', icons / 'org.framely.installer.svg')
icon = None # Embedded by tools/package-installer.py.
if icon is None:
icon = (base.parent / 'assets/branding/framely-app-icon.svg').read_bytes()
(icons / 'org.framely.installer.svg').write_bytes(icon)
# Desktop entries parse backslash escapes before parsing the quoted Exec value.
quoted = str(binary).replace('%', '%%').replace('\\', '\\\\')
for character in ('"', '`', '$'):
+1
View File
@@ -2,6 +2,7 @@ pub mod discovery;
pub mod instance;
pub mod maintenance;
pub mod progress;
pub mod proxy;
pub mod release;
pub mod remote;
pub const DEFAULT_REPO: &str = "SteamFramelyHomebrew/framely";
+152 -9
View File
@@ -42,11 +42,15 @@ enum Event {
Connected(Result<Connection, String>),
File(FileKind, Option<PathBuf>),
Log(String),
LogsExported(Result<Option<PathBuf>, String>),
Progress(Progress),
Done(Result<(), String>),
}
struct Installer {
page: usize,
system_proxy: bool,
http_proxy: Entity<InputState>,
github_proxy: Entity<InputState>,
chosen_action: String,
device_state: Option<Installation>,
preview: bool,
@@ -103,9 +107,18 @@ impl Installer {
.masked(masked)
})
};
let proxy_result = framely_installer::proxy::load();
let proxy_error = proxy_result
.as_ref()
.err()
.map(|_| "无法读取代理设置,请在设置页重新保存。".to_owned());
let proxies = proxy_result.unwrap_or_default();
let networks = discovery::networks();
let mut view = Self {
page: 0,
system_proxy: proxies.system,
http_proxy: input(&proxies.http, "http://127.0.0.1:7890", false),
github_proxy: input(&proxies.github, "https://your-github-proxy.example", false),
chosen_action: "install".into(),
device_state: None,
preview: cfg!(feature = "visual-test")
@@ -144,7 +157,7 @@ impl Installer {
progress: None,
logs: Vec::new(),
status: "开启 Frame 开发者模式并设置密码,将电脑和 Frame 连接到同一网络。".into(),
error: None,
error: proxy_error,
release_error: None,
connection_stage: None,
sender,
@@ -320,6 +333,20 @@ impl Installer {
self.status = progress.detail.clone();
self.progress = Some(progress);
}
Event::LogsExported(result) => {
self.busy = false;
match result {
Ok(Some(path)) => {
self.status = "日志已导出。".into();
self.notice("日志导出完成", format!("已保存到:{}", path.display()));
}
Ok(None) => self.status = "已取消导出日志。".into(),
Err(error) => {
self.status = "日志导出失败。".into();
self.notice("日志导出失败", error);
}
}
}
Event::Done(result) => {
self.busy = false;
self.error = None;
@@ -328,6 +355,17 @@ impl Installer {
} else {
"操作失败,请查看操作结果与日志。".into()
};
let first_install = result.is_ok()
&& self.chosen_action == "install"
&& self
.device_state
.as_ref()
.is_some_and(|state| !state.present);
if first_install && !self.preview {
if let Some(url) = self.manager_url() {
cx.open_url(&url);
}
}
self.queue_prompt(dialogs::Prompt::Outcome(
self.chosen_action.clone(),
result.clone(),
@@ -347,6 +385,60 @@ impl Installer {
}
self.present_prompt(window, cx);
}
fn manager_url(&self) -> Option<String> {
let completed = self
.operation_result
.as_ref()
.is_some_and(|(action, result)| result.is_ok() && action != "uninstall");
if self.connected_host.is_empty() || (self.connection.is_none() && !completed) {
return None;
}
let host = self
.connected_host
.rsplit_once(':')
.map(|(host, _)| host)
.unwrap_or(&self.connected_host)
.trim_matches(['[', ']']);
let host = if host.contains(':') {
format!("[{host}]")
} else {
host.to_owned()
};
Some(format!("http://{host}:15915/manager"))
}
fn export_logs(&mut self, cx: &mut Context<Self>) {
if self.busy {
return;
}
let Some(connection) = self.connection.clone() else {
return;
};
let password = Zeroizing::new(self.password.read(cx).value().to_string());
let sender = self.sender.clone();
self.busy = true;
self.status = "正在导出日志…".into();
let logs = self.logs.clone();
std::thread::spawn(move || {
let result = (|| -> anyhow::Result<Option<PathBuf>> {
let Some(path) = rfd::FileDialog::new()
.set_title("保存 Framely 日志包")
.set_file_name("framely-logs.zip")
.add_filter("ZIP 日志包", &["zip"])
.save_file()
else {
return Ok(None);
};
let guard = connection
.lock()
.map_err(|_| anyhow::anyhow!("设备连接不可用"))?;
remote::export_logs(&guard, &password, &path, &logs)?;
Ok(Some(path))
})()
.map_err(|e| format!("{e:#}"));
let _ = sender.send(Event::LogsExported(result));
});
cx.notify();
}
fn scan(&mut self, cx: &mut Context<Self>) {
self.cancelled.store(true, Ordering::Relaxed);
self.cancelled = Arc::new(AtomicBool::new(false));
@@ -463,7 +555,7 @@ impl Installer {
} else {
self.selected
.and_then(|i| self.releases.get(i))
.is_some_and(|r| release::package(r).is_ok())
.is_some_and(|r| release::package_for(r, &self.chosen_action).is_ok())
}
}
fn action_available(&self, action: &str) -> bool {
@@ -480,7 +572,7 @@ impl Installer {
} else {
self.selected
.and_then(|i| self.releases.get(i))
.and_then(|r| release::package(r).ok())
.and_then(|r| release::package_for(r, "update").ok())
.map(|(package, _)| package.name.as_str())
};
self.chosen_action == "update"
@@ -507,6 +599,30 @@ impl Installer {
self.queue_prompt(dialogs::Prompt::Operation(self.chosen_action.clone()));
cx.notify();
}
fn save_proxy(&mut self, cx: &mut Context<Self>) {
let settings = framely_installer::proxy::DownloadSettings {
system: self.system_proxy,
http: self.http_proxy.read(cx).value().trim().to_owned(),
github: self
.github_proxy
.read(cx)
.value()
.trim()
.trim_end_matches('/')
.to_owned(),
};
match framely_installer::proxy::save(settings) {
Ok(()) => {
self.error = None;
self.release_error = None;
self.releases.clear();
self.selected = None;
self.status = "代理设置已保存,下次下载及检查版本时生效。".into();
}
Err(error) => self.error = Some(format!("保存代理设置失败:{error}")),
}
cx.notify();
}
fn navigate(&mut self, page: usize, cx: &mut Context<Self>) {
if self.busy {
return;
@@ -592,7 +708,7 @@ impl Installer {
} else {
let selected =
selected.ok_or_else(|| anyhow::anyhow!("请选择 Release 版本"))?;
let (package, checksum) = release::package(&selected)?;
let (package, checksum) = release::package_for(&selected, &action)?;
let archive = folder.path().join(&package.name);
let sums = folder.path().join("SHA256SUMS");
let total = checksum
@@ -615,8 +731,7 @@ impl Installer {
(None, None)
};
if let (Some(archive), Some(sums)) = (&archive, &sums) {
progress(Progress::new(Stage::Verify, "校验本地安装包 SHA256"));
release::verify(archive, sums)?;
release::verify_with_progress(archive, sums, &mut progress)?;
log("电脑端 SHA256 校验通过,开始传输;设备端将再次校验。".into());
}
remote::operate(
@@ -722,6 +837,7 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
(0, "devices"),
(1, "versions"),
(2, "maintenance"),
(3, "settings"),
(1, "local"),
(0, "empty"),
(0, "connecting"),
@@ -732,6 +848,7 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
(2, "uninstalled"),
(2, "same-version"),
(2, "update-complete"),
(2, "first-install-complete"),
(2, "operation-failed"),
] {
context.update_window(handle, |_, window, cx| {
@@ -848,10 +965,23 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
assert!(view.same_update() && view.selection_ready());
view.request_operation(cx);
assert!(view.confirmation.is_none());
} else if name == "update-complete" || name == "operation-failed" {
view.chosen_action = "update".into();
} else if matches!(
name,
"update-complete" | "first-install-complete" | "operation-failed"
) {
view.chosen_action = if name == "first-install-complete" {
"install"
} else {
"update"
}
.into();
view.connected_host = "192.168.1.42:22".into();
if name == "first-install-complete" {
view.device_state = Some(Installation::default());
}
view.sender
.send(Event::Done(if name == "update-complete" {
.send(Event::Done(if name != "operation-failed" {
Ok(())
} else {
Err("网络连接中断,请重新连接设备后重试。".into())
@@ -864,6 +994,19 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
&& view.confirmation.is_none()
&& !view.busy
);
assert_eq!(view.manager_url().is_some(), name != "operation-failed");
if name != "operation-failed" {
assert_eq!(
view.manager_url().as_deref(),
Some("http://192.168.1.42:15915/manager")
);
view.connected_host = "[fd00::42]:22".into();
assert_eq!(
view.manager_url().as_deref(),
Some("http://[fd00::42]:15915/manager")
);
view.connected_host = "192.168.1.42:22".into();
}
}
cx.notify();
});
+4 -3
View File
@@ -27,9 +27,10 @@ impl Installation {
}
}
pub fn same_package(&self, name: &str) -> bool {
self.current_version
.as_ref()
.is_some_and(|version| name == format!("framely-{version}-linux-arm64.tar.gz"))
self.current_version.as_ref().is_some_and(|version| {
name == format!("framely-{version}-linux-arm64.tar.gz")
|| name == format!("framely-{version}-offline-linux-arm64.tar.gz")
})
}
}
pub fn requires_package(action: &str) -> bool {
+19 -2
View File
@@ -102,6 +102,7 @@ impl DeviceProgress {
"stage" => (2, "准备设备安装目录"),
"extract" => (3, "解压并校验发行文件"),
"configure" => (4, "配置账号、安装文件与系统服务"),
"cef-download" => (4, "设备下载 CEF 运行库"),
"activate" => (5, "启动服务并检查安装状态"),
_ => continue,
};
@@ -111,8 +112,17 @@ impl DeviceProgress {
{
continue;
}
let mut value = Progress::new(Stage::Install, detail);
value.step = Some((index, 5));
let mut value = Progress::new(
if step.step == "cef-download" {
Stage::Download
} else {
Stage::Install
},
detail,
);
if step.step != "cef-download" {
value.step = Some((index, 5));
}
value.completed = step.completed.unwrap_or(0);
value.total = step.total;
report(value);
@@ -188,5 +198,12 @@ mod tests {
assert_eq!(reports[0].step, Some((3, 5)));
assert_eq!(reports[1].step, Some((4, 5)));
assert_eq!(reports[1].fraction(), None);
decoder.push(
"FRAMELY_PROGRESS {\"step\":\"cef-download\",\"completed\":50,\"total\":100}\n",
&mut |p| reports.push(p),
);
assert_eq!(reports[2].stage, Stage::Download);
assert_eq!(reports[2].fraction(), Some(0.5));
assert_eq!(reports[2].step, None);
}
}
+752
View File
@@ -0,0 +1,752 @@
//! Read proxy settings without changing the computer or the SSH device.
use anyhow::{Result, bail, ensure};
use std::{collections::HashMap, net::IpAddr};
use url::Url;
/// Installer-only download preferences. Explicit HTTP proxy overrides automatic
/// discovery; GitHub routing rewrites only trusted GitHub source hosts.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct DownloadSettings {
pub system: bool,
pub http: String,
pub github: String,
}
impl Default for DownloadSettings {
fn default() -> Self {
Self {
system: true,
http: String::new(),
github: String::new(),
}
}
}
impl DownloadSettings {
pub fn validate(&self) -> Result<()> {
ensure!(
self.http.len() <= 2048 && self.github.len() <= 2048,
"代理地址过长"
);
if !self.http.is_empty() {
parse_proxy(&self.http)?;
}
if !self.github.is_empty() {
let url =
Url::parse(&self.github).map_err(|_| anyhow::anyhow!("GitHub 代理地址无效"))?;
ensure!(
url.scheme() == "https"
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none(),
"GitHub 代理必须是 HTTPS 地址,不能含账号、查询参数或片段"
);
}
Ok(())
}
pub fn route(&self, url: &Url) -> Url {
if !self.github.is_empty()
&& matches!(
url.host_str(),
Some(
"github.com"
| "api.github.com"
| "raw.githubusercontent.com"
| "objects.githubusercontent.com"
| "release-assets.githubusercontent.com"
)
)
&& !url
.as_str()
.starts_with(&format!("{}/", self.github.trim_end_matches('/')))
{
Url::parse(&format!("{}/{}", self.github.trim_end_matches('/'), url))
.expect("validated GitHub proxy")
} else {
url.clone()
}
}
pub fn proxy_for(&self, url: &Url) -> Result<Option<ureq::Proxy>> {
if !self.http.is_empty() {
return parse_proxy(&self.http).map(Some);
}
if self.system { for_url(url) } else { Ok(None) }
}
}
static DOWNLOAD_SETTINGS: std::sync::OnceLock<std::sync::RwLock<DownloadSettings>> =
std::sync::OnceLock::new();
fn active_settings() -> &'static std::sync::RwLock<DownloadSettings> {
DOWNLOAD_SETTINGS.get_or_init(|| std::sync::RwLock::new(DownloadSettings::default()))
}
pub fn current() -> DownloadSettings {
active_settings().read().unwrap().clone()
}
fn settings_path() -> Result<std::path::PathBuf> {
Ok(
directories::ProjectDirs::from("org", "Framely", "Installer")
.ok_or_else(|| anyhow::anyhow!("无法确定配置目录"))?
.config_dir()
.join("download-settings.json"),
)
}
pub fn load() -> Result<DownloadSettings> {
let path = settings_path()?;
let settings = if path.exists() {
serde_json::from_slice::<DownloadSettings>(&std::fs::read(path)?)?
} else {
DownloadSettings::default()
};
settings.validate()?;
*active_settings().write().unwrap() = settings.clone();
Ok(settings)
}
pub fn save(settings: DownloadSettings) -> Result<()> {
settings.validate()?;
let path = settings_path()?;
std::fs::create_dir_all(path.parent().unwrap())?;
let mut file = tempfile::NamedTempFile::new_in(path.parent().unwrap())?;
use std::io::Write;
file.write_all(&serde_json::to_vec_pretty(&settings)?)?;
file.as_file().sync_all()?;
file.persist(path)?;
*active_settings().write().unwrap() = settings;
Ok(())
}
#[derive(Default)]
struct Settings {
proxies: HashMap<String, String>,
bypass: Vec<String>,
exclude_simple: bool,
reverse_bypass: bool,
}
impl Settings {
fn select(&self, url: &Url) -> Option<&str> {
let host = url.host_str()?;
let excluded = self.exclude_simple && !host.contains('.')
|| self
.bypass
.iter()
.any(|pattern| bypass_matches(pattern, host, url.port_or_known_default()));
if excluded != self.reverse_bypass {
return None;
}
self.proxies
.get(url.scheme())
.or_else(|| self.proxies.get("*"))
.map(String::as_str)
}
}
fn env_settings(mut get: impl FnMut(&str) -> Option<String>) -> Option<Settings> {
let mut settings = Settings::default();
for (scheme, lower, upper) in [
("https", "https_proxy", "HTTPS_PROXY"),
("http", "http_proxy", "HTTP_PROXY"),
("*", "all_proxy", "ALL_PROXY"),
] {
if let Some(value) = get(lower).or_else(|| get(upper)) {
settings.proxies.insert(scheme.into(), value);
}
}
(!settings.proxies.is_empty()).then_some(settings)
}
pub fn for_url(url: &Url) -> Result<Option<ureq::Proxy>> {
if let Ok(bypass) = std::env::var("no_proxy").or_else(|_| std::env::var("NO_PROXY")) {
if split_bypass(&bypass).iter().any(|pattern| {
bypass_matches(
pattern,
url.host_str().unwrap_or(""),
url.port_or_known_default(),
)
}) {
return Ok(None);
}
}
let settings = match env_settings(|key| std::env::var(key).ok()) {
Some(settings) => settings,
None => system_settings()?.unwrap_or_default(),
};
settings
.select(url)
.filter(|value| !value.trim().is_empty())
.map(parse_proxy)
.transpose()
}
fn parse_proxy(value: &str) -> Result<ureq::Proxy> {
let value = value.trim();
let value = if value.contains("://") {
value.to_owned()
} else {
format!("http://{value}")
};
let value = value
.strip_prefix("socks5h://")
.map(|rest| format!("socks5://{rest}"))
.unwrap_or(value);
let parsed = Url::parse(&value)?;
ensure!(
matches!(
parsed.scheme(),
"http" | "socks" | "socks4" | "socks4a" | "socks5"
) && parsed.host_str().is_some(),
"系统代理地址无效或协议不受支持"
);
ensure!(
!matches!(parsed.host(), Some(url::Host::Ipv6(_))),
"当前下载客户端暂不支持 IPv6 代理地址,请使用主机名或 IPv4 地址"
);
ureq::Proxy::new(value).map_err(|_| anyhow::anyhow!("系统代理地址无效"))
}
fn split_bypass(value: &str) -> Vec<String> {
value
.split([',', ';'])
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
}
fn bypass_matches(pattern: &str, host: &str, port: Option<u16>) -> bool {
let pattern = pattern.trim().to_ascii_lowercase();
let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
if pattern == "<local>" {
return !host.contains('.');
}
if let Some((network, bits)) = pattern.split_once('/') {
if let (Ok(network), Ok(address), Ok(bits)) = (
network.parse::<IpAddr>(),
host.parse::<IpAddr>(),
bits.parse::<u32>(),
) {
return match (network, address) {
(IpAddr::V4(network), IpAddr::V4(address)) if bits <= 32 => {
bits == 0
|| u32::from(network) >> (32 - bits) == u32::from(address) >> (32 - bits)
}
(IpAddr::V6(network), IpAddr::V6(address)) if bits <= 128 => {
bits == 0
|| u128::from(network) >> (128 - bits)
== u128::from(address) >> (128 - bits)
}
_ => false,
};
}
}
let (pattern, required_port) = match pattern.rsplit_once(':') {
Some((name, number)) if !name.contains(':') => (name, number.parse::<u16>().ok()),
_ => (pattern.as_str(), None),
};
if required_port.is_some() && required_port != port {
return false;
}
let domain = pattern.trim_start_matches('.');
if !pattern.contains('*') {
return host == domain || host.ends_with(&format!(".{domain}"));
}
// Match '*' throughout the hostname, including Windows IP wildcards.
let (pattern, host) = (pattern.as_bytes(), host.as_bytes());
let (mut p, mut h, mut star, mut resume) = (0, 0, None, 0);
while h < host.len() {
if p < pattern.len() && pattern[p] == host[h] {
p += 1;
h += 1;
} else if p < pattern.len() && pattern[p] == b'*' {
star = Some(p);
p += 1;
resume = h;
} else if let Some(index) = star {
p = index + 1;
resume += 1;
h = resume;
} else {
return false;
}
}
while p < pattern.len() && pattern[p] == b'*' {
p += 1;
}
p == pattern.len()
}
const PAC_ERROR: &str = "暂不支持 PAC 自动代理脚本,请配置手动系统代理或 HTTPS_PROXY";
#[cfg(target_os = "windows")]
fn system_settings() -> Result<Option<Settings>> {
use winreg::{
RegKey,
enums::{HKEY_CURRENT_USER, KEY_READ},
};
let Ok(key) = RegKey::predef(HKEY_CURRENT_USER).open_subkey_with_flags(
r"Software\Microsoft\Windows\CurrentVersion\Internet Settings",
KEY_READ,
) else {
return Ok(None);
};
if key.get_value::<u32, _>("ProxyEnable").unwrap_or(0) != 1 {
if key
.get_value::<String, _>("AutoConfigURL")
.is_ok_and(|value| !value.trim().is_empty())
{
bail!(PAC_ERROR);
}
return Ok(None);
}
let Ok(server) = key.get_value::<String, _>("ProxyServer") else {
return Ok(None);
};
let mut settings = parse_windows_servers(&server);
settings.bypass = split_bypass(
&key.get_value::<String, _>("ProxyOverride")
.unwrap_or_default(),
);
settings.exclude_simple = settings.bypass.iter().any(|value| value == "<local>");
Ok(Some(settings))
}
#[cfg(target_os = "macos")]
fn system_settings() -> Result<Option<Settings>> {
let output = std::process::Command::new("/usr/sbin/scutil")
.arg("--proxy")
.output()?;
if !output.status.success() {
return Ok(None);
}
parse_macos(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(any(target_os = "macos", test))]
fn parse_macos(text: &str) -> Result<Option<Settings>> {
let mut values = HashMap::new();
let mut settings = Settings::default();
let (mut depth, mut exceptions) = (0usize, false);
for line in text.lines().map(str::trim) {
if line == "}" {
depth = depth.saturating_sub(1);
if depth == 1 {
exceptions = false;
}
continue;
}
if depth == 1 {
if let Some((key, value)) = line.split_once(" : ") {
if key == "ExceptionsList" {
exceptions = true;
} else {
values.insert(key, value);
}
}
} else if depth == 2 && exceptions {
if let Some((_, value)) = line.split_once(" : ") {
settings.bypass.push(value.into());
}
}
if line.ends_with('{') {
depth += 1;
}
}
for (scheme, name, protocol) in [
("http", "HTTP", "http"),
("https", "HTTPS", "http"),
("*", "SOCKS", "socks5"),
] {
if values.get(format!("{name}Enable").as_str()) == Some(&"1") {
if let (Some(host), Some(port)) = (
values.get(format!("{name}Proxy").as_str()),
values.get(format!("{name}Port").as_str()),
) {
settings
.proxies
.insert(scheme.into(), format!("{protocol}://{host}:{port}"));
}
}
}
settings.exclude_simple = values.get("ExcludeSimpleHostnames") == Some(&"1");
if settings.proxies.is_empty() && values.get("ProxyAutoConfigEnable") == Some(&"1") {
bail!(PAC_ERROR);
}
Ok(Some(settings))
}
#[cfg(any(target_os = "windows", test))]
fn parse_windows_servers(server: &str) -> Settings {
let mut settings = Settings::default();
for entry in server.split(';').map(str::trim).filter(|s| !s.is_empty()) {
let (scheme, value) = entry.split_once('=').unwrap_or(("*", entry));
let scheme = scheme.to_ascii_lowercase();
if scheme == "socks" {
let value = if value.contains("://") {
value.into()
} else {
format!("socks5://{value}")
};
settings.proxies.insert("*".into(), value);
} else {
settings.proxies.insert(scheme, value.into());
}
}
settings
}
#[cfg(target_os = "linux")]
fn system_settings() -> Result<Option<Settings>> {
let kde = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_lowercase()
.contains("kde");
if kde {
if let Some(settings) = kde_settings()? {
return Ok(Some(settings));
}
}
if let Ok(output) = std::process::Command::new("gsettings")
.args(["list-recursively", "org.gnome.system.proxy"])
.output()
{
if output.status.success() {
if let Some(settings) = parse_gnome(&String::from_utf8_lossy(&output.stdout))? {
return Ok(Some(settings));
}
}
}
if !kde {
return kde_settings();
}
Ok(None)
}
#[cfg(target_os = "linux")]
fn kde_settings() -> Result<Option<Settings>> {
let mut paths = Vec::new();
if let Some(config) = std::env::var_os("XDG_CONFIG_HOME") {
paths.push(std::path::PathBuf::from(config));
} else if let Some(home) = std::env::var_os("HOME") {
paths.push(std::path::PathBuf::from(home).join(".config"));
}
paths.extend(std::env::split_paths(
&std::env::var_os("XDG_CONFIG_DIRS").unwrap_or_else(|| "/etc/xdg".into()),
));
for path in paths {
if let Ok(text) = std::fs::read_to_string(path.join("kioslaverc")) {
if let Some(settings) = parse_kde(&text)? {
return Ok(Some(settings));
}
}
}
Ok(None)
}
#[cfg(any(target_os = "linux", test))]
fn unquote(value: &str) -> String {
let value = value.trim();
let quoted = value
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
.or_else(|| value.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
.unwrap_or(value);
quoted
.replace("\\'", "'")
.replace("\\\"", "\"")
.replace("\\\\", "\\")
}
#[cfg(any(target_os = "linux", test))]
fn parse_gnome(text: &str) -> Result<Option<Settings>> {
let mut values = HashMap::new();
for line in text.lines() {
let mut fields = line.splitn(3, ' ');
if let (Some(schema), Some(key), Some(value)) =
(fields.next(), fields.next(), fields.next())
{
values.insert(format!("{schema}.{key}"), value.trim());
}
}
let get = |key: &str| {
values
.get(&format!("org.gnome.system.proxy.{key}"))
.copied()
.unwrap_or("")
};
match unquote(get("mode")).as_str() {
"auto" => bail!(PAC_ERROR),
"manual" => {}
"none" => return Ok(Some(Settings::default())),
_ => return Ok(None),
}
let mut settings = Settings::default();
for (scheme, service) in [("http", "http"), ("https", "https"), ("*", "socks")] {
let host = unquote(get(&format!("{service}.host")));
let port = get(&format!("{service}.port")).parse::<u16>().unwrap_or(0);
if !host.is_empty() && port > 0 {
let protocol = if service == "socks" { "socks5" } else { "http" };
settings
.proxies
.insert(scheme.into(), format!("{protocol}://{host}:{port}"));
}
}
if get("use-same-proxy") == "true" {
if let Some(http) = settings.proxies.get("http").cloned() {
settings.proxies.insert("https".into(), http);
}
}
settings.bypass = get("ignore-hosts")
.trim_matches(['[', ']'])
.split(',')
.map(unquote)
.filter(|s| !s.is_empty())
.collect();
Ok(Some(settings))
}
#[cfg(any(target_os = "linux", test))]
fn parse_kde(text: &str) -> Result<Option<Settings>> {
let mut values = HashMap::new();
let mut proxy_group = false;
for line in text.lines().map(str::trim) {
if line.starts_with('[') {
proxy_group = line == "[Proxy Settings]";
} else if proxy_group {
if let Some((key, value)) = line.split_once('=') {
values.insert(key.trim(), value.trim());
}
}
}
match values.get("ProxyType").copied() {
Some("1") => {}
Some("2" | "3") => bail!(PAC_ERROR),
Some("0") => return Ok(Some(Settings::default())),
_ => return Ok(None),
}
let mut settings = Settings::default();
for (scheme, key) in [
("http", "httpProxy"),
("https", "httpsProxy"),
("*", "socksProxy"),
] {
if let Some(value) = values.get(key).filter(|value| !value.is_empty()) {
// KDE also stores endpoints as "http://host port".
let value = value
.rsplit_once(' ')
.map(|(host, port)| format!("{host}:{port}"))
.unwrap_or_else(|| (*value).into());
let value = if key == "socksProxy" {
value.replace("http://", "socks5://")
} else {
value
};
settings.proxies.insert(scheme.into(), value);
}
}
settings.bypass = split_bypass(values.get("NoProxyFor").copied().unwrap_or(""));
settings.reverse_bypass = values
.get("ReversedException")
.is_some_and(|value| *value == "true");
Ok(Some(settings))
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn system_settings() -> Result<Option<Settings>> {
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn environment_preserves_credentials_and_prefers_scheme_over_all() {
let vars = HashMap::from([
("HTTPS_PROXY", "http://User:PaSS@localhost:8080"),
("ALL_PROXY", "socks5h://localhost:1080"),
]);
let settings = env_settings(|key| vars.get(key).map(|s| s.to_string())).unwrap();
assert_eq!(
settings.select(&Url::parse("https://example.org").unwrap()),
Some("http://User:PaSS@localhost:8080")
);
parse_proxy(
settings
.select(&Url::parse("https://example.org").unwrap())
.unwrap(),
)
.unwrap();
parse_proxy(
settings
.select(&Url::parse("http://example.org").unwrap())
.unwrap(),
)
.unwrap();
assert!(env_settings(|_| None).is_none());
let direct = env_settings(|key| (key == "https_proxy").then(String::new)).unwrap();
assert_eq!(
direct.select(&Url::parse("https://example.org").unwrap()),
Some("")
);
}
#[test]
fn bypass_matches_domains_ports_wildcards_and_subnets() {
assert!(bypass_matches("*foo", "foofoo", Some(443)));
for (rule, host, port) in [
("*", "example.org", 443),
(".example.org", "SUB.EXAMPLE.ORG", 443),
("*.example.org:443", "sub.example.org", 443),
("192.168.*", "192.168.5.67", 443),
("127.0.0.0/8", "127.1.2.3", 443),
("::1", "[::1]", 443),
("<local>", "frame", 443),
] {
assert!(bypass_matches(rule, host, Some(port)), "{rule} / {host}");
}
assert!(!bypass_matches("example.org", "badexample.org", Some(443)));
assert!(!bypass_matches("example.org:80", "example.org", Some(443)));
assert!(!bypass_matches("192.168.0.0/16", "10.1.2.3", Some(443)));
assert!(parse_proxy("file:///tmp/proxy").is_err());
}
#[test]
fn desktop_settings_select_https_or_socks_and_honor_disabled_mode() {
let gnome = "org.gnome.system.proxy mode 'manual'\norg.gnome.system.proxy.https host '127.0.0.1'\norg.gnome.system.proxy.https port 7890\norg.gnome.system.proxy.socks host '127.0.0.1'\norg.gnome.system.proxy.socks port 7891\norg.gnome.system.proxy ignore-hosts ['localhost', '*.example.org']\n";
let settings = parse_gnome(gnome).unwrap().unwrap();
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("http://127.0.0.1:7890")
);
assert!(
settings
.select(&Url::parse("https://sub.example.org").unwrap())
.is_none()
);
assert_eq!(
settings.select(&Url::parse("http://github.com").unwrap()),
Some("socks5://127.0.0.1:7891")
);
assert!(
parse_gnome(&gnome.replace("'manual'", "'none'"))
.unwrap()
.unwrap()
.proxies
.is_empty()
);
assert!(parse_gnome(&gnome.replace("'manual'", "'auto'")).is_err());
let kde = "[Proxy Settings]\nProxyType=1\nhttpsProxy=http://127.0.0.1 7890\nNoProxyFor=localhost,*.example.org\n";
let settings = parse_kde(kde).unwrap().unwrap();
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("http://127.0.0.1:7890")
);
assert!(
settings
.select(&Url::parse("https://sub.example.org").unwrap())
.is_none()
);
assert!(
parse_kde(&kde.replace("ProxyType=1", "ProxyType=0"))
.unwrap()
.unwrap()
.proxies
.is_empty()
);
}
#[test]
fn macos_and_windows_settings_preserve_protocol_and_bypass_rules() {
let text = "<dictionary> {\n HTTPSEnable : 1\n HTTPSProxy : proxy.example.org\n HTTPSPort : 8080\n SOCKSEnable : 1\n SOCKSProxy : localhost\n SOCKSPort : 1080\n ExceptionsList : <array> {\n 0 : *.local\n }\n __SCOPED__ : <dictionary> {\n en0 : <dictionary> {\n HTTPSEnable : 0\n HTTPSProxy : wrong.example.org\n }\n }\n}";
let settings = parse_macos(text).unwrap().unwrap();
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("http://proxy.example.org:8080")
);
assert_eq!(
settings.select(&Url::parse("http://github.com").unwrap()),
Some("socks5://localhost:1080")
);
assert!(
settings
.select(&Url::parse("https://frame.local").unwrap())
.is_none()
);
assert!(parse_macos("<dictionary> {\n ProxyAutoConfigEnable : 1\n}").is_err());
let settings =
parse_windows_servers("http=proxy1:8080;https=proxy2:8081;socks=localhost:1080");
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("proxy2:8081")
);
assert_eq!(
settings.select(&Url::parse("ftp://example.org").unwrap()),
Some("socks5://localhost:1080")
);
let settings = parse_windows_servers("proxy.example.org:8080");
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("proxy.example.org:8080")
);
}
}
#[cfg(test)]
mod download_settings_tests {
use super::*;
#[test]
fn default_preserves_system_discovery_and_old_preferences() {
assert!(DownloadSettings::default().system);
assert!(
serde_json::from_str::<DownloadSettings>("{}")
.unwrap()
.system
);
}
#[test]
fn disabled_system_and_explicit_override() {
let mut settings = DownloadSettings {
system: false,
..Default::default()
};
let target = Url::parse("https://github.com/a/b").unwrap();
assert!(settings.proxy_for(&target).unwrap().is_none());
settings.http = "http://127.0.0.1:7890".into();
settings.validate().unwrap();
assert!(settings.proxy_for(&target).unwrap().is_some());
}
#[test]
fn github_routes_only_exact_hosts_and_does_not_double_wrap_redirects() {
let settings = DownloadSettings {
github: "https://proxy.example/prefix/".into(),
..Default::default()
};
settings.validate().unwrap();
for host in [
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"objects.githubusercontent.com",
"release-assets.githubusercontent.com",
] {
let original = Url::parse(&format!("https://{host}/a?x=1")).unwrap();
let routed = settings.route(&original);
assert_eq!(
routed.as_str(),
format!("https://proxy.example/prefix/{original}")
);
assert_eq!(settings.route(&routed), routed);
}
for host in ["github.com.evil.example", "example.org", "proxy.example"] {
let url = Url::parse(&format!("https://{host}/a")).unwrap();
assert_eq!(settings.route(&url), url);
}
}
#[test]
fn invalid_preferences_are_rejected_before_saving() {
for github in [
"http://proxy.example",
"https://proxy.example?q=1",
"https://u:p@proxy.example",
"https://proxy.example/#x",
] {
assert!(
DownloadSettings {
github: github.into(),
..Default::default()
}
.validate()
.is_err()
);
}
for http in ["file:///tmp/x", "http://[::1]:1234"] {
assert!(
DownloadSettings {
http: http.into(),
..Default::default()
}
.validate()
.is_err()
);
}
}
}
+204 -12
View File
@@ -30,23 +30,29 @@ pub fn validate_repo(repo: &str) -> Result<()> {
);
Ok(())
}
fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
fn agent(url: &url::Url, settings: &crate::proxy::DownloadSettings) -> Result<ureq::Agent> {
let mut builder = ureq::AgentBuilder::new()
.try_proxy_from_env(false)
.timeout(Duration::from_secs(120))
.redirects(0)
.build()
.redirects(0);
if let Some(proxy) = settings.proxy_for(url)? {
builder = builder.proxy(proxy);
}
Ok(builder.build())
}
fn get(url: &str) -> Result<ureq::Response> {
let agent = agent();
let settings = crate::proxy::current();
let mut url = url::Url::parse(url)?;
for _ in 0..=5 {
ensure!(url.scheme() == "https", "下载地址必须使用 HTTPS");
let response = agent
.get(url.as_str())
// Re-evaluate bypass rules for every redirect target.
let routed = settings.route(&url);
let response = agent(&routed, &settings)?
.get(routed.as_str())
.set("User-Agent", "Framely-Installer")
.call()?;
if (300..400).contains(&response.status()) {
url = url.join(response.header("Location").context("重定向缺少地址")?)?;
url = routed.join(response.header("Location").context("重定向缺少地址")?)?;
} else {
return Ok(response);
}
@@ -68,7 +74,11 @@ pub fn list(repo: &str) -> Result<Vec<Release>> {
ensure!(bytes.len() <= 4 * 1024 * 1024, "Release 列表过大");
let items: Vec<Release> = serde_json::from_slice(&bytes)?;
let finished = items.len() < 100;
all.extend(items.into_iter().filter(|r| !r.draft && package(r).is_ok()));
all.extend(
items
.into_iter()
.filter(|r| !r.draft && (package(r).is_ok() || package_for(r, "update").is_ok())),
);
if finished {
return Ok(all);
}
@@ -76,10 +86,26 @@ pub fn list(repo: &str) -> Result<Vec<Release>> {
Ok(all)
}
pub fn package(release: &Release) -> Result<(&Asset, &Asset)> {
package_for(release, "install")
}
pub fn package_for<'a>(release: &'a Release, action: &str) -> Result<(&'a Asset, &'a Asset)> {
let offline = action == "install"
&& release
.assets
.iter()
.any(|a| safe_archive_name(&a.name) && a.name.ends_with("-offline-linux-arm64.tar.gz"));
ensure!(
action != "install"
|| offline
|| !release.assets.iter().any(|a| a.name == "framely-cef.json"),
"首次安装需要包含 CEF 的完整离线包"
);
let packages: Vec<_> = release
.assets
.iter()
.filter(|a| safe_archive_name(&a.name))
.filter(|a| {
safe_archive_name(&a.name) && a.name.ends_with("-offline-linux-arm64.tar.gz") == offline
})
.collect();
ensure!(packages.len() == 1, "该版本缺少唯一的 Framely ARM64 发行包");
let checksum = release
@@ -116,6 +142,13 @@ pub fn expected_hash(checksums: &str, name: &str) -> Result<String> {
matched.context("校验文件中没有该压缩包的条目")
}
pub fn verify(archive: &Path, checksums: &Path) -> Result<()> {
verify_with_progress(archive, checksums, &mut |_| {})
}
pub fn verify_with_progress(
archive: &Path,
checksums: &Path,
report: &mut dyn FnMut(crate::progress::Progress),
) -> Result<()> {
ensure!(
archive.is_file() && checksums.is_file(),
"请选择压缩包及外部 SHA256SUMS"
@@ -137,17 +170,30 @@ pub fn verify(archive: &Path, checksums: &Path) -> Result<()> {
let mut file = File::open(archive)?;
let mut digest = Sha256::new();
let mut block = [0; 256 * 1024];
let mut progress =
crate::progress::Progress::new(crate::progress::Stage::Verify, "校验本地安装包 SHA256");
let total = file.metadata()?.len();
progress.total = Some(total);
report(progress.clone());
let mut last_update = std::time::Instant::now();
loop {
let n = file.read(&mut block)?;
if n == 0 {
break;
}
digest.update(&block[..n]);
progress.completed += n as u64;
if progress.completed < total && last_update.elapsed() >= Duration::from_millis(200) {
report(progress.clone());
last_update = std::time::Instant::now();
}
}
ensure!(
hex::encode(digest.finalize()) == expected,
"SHA256 不匹配,已停止安装"
);
ensure!(progress.completed == total, "校验期间安装包长度发生变化");
report(progress);
Ok(())
}
pub fn download(
@@ -179,6 +225,100 @@ pub fn download(
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn https_requests_use_discovered_desktop_proxy() {
use std::{
io::Write,
net::TcpListener,
os::unix::fs::PermissionsExt,
process::{Command, Stdio},
time::Instant,
};
if std::env::var_os("FRAMELY_PROXY_TEST_CHILD").is_some() {
assert!(get("https://example.invalid/installer-proxy-check").is_err());
return;
}
let work = tempfile::tempdir().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
let mock = work.path().join("gsettings");
std::fs::write(&mock, format!("#!/bin/sh\nprintf '%s\\n' \"org.gnome.system.proxy mode 'manual'\" \"org.gnome.system.proxy.https host '127.0.0.1'\" \"org.gnome.system.proxy.https port {port}\"\n")).unwrap();
std::fs::set_permissions(&mock, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut command = Command::new(std::env::current_exe().unwrap());
command
.args([
"--exact",
"release::tests::https_requests_use_discovered_desktop_proxy",
"--nocapture",
])
.env("FRAMELY_PROXY_TEST_CHILD", "1")
.env("XDG_CURRENT_DESKTOP", "GNOME")
.env("XDG_CONFIG_HOME", work.path())
.env("XDG_CONFIG_DIRS", work.path())
.env(
"PATH",
std::env::join_paths(std::iter::once(work.path().to_path_buf()).chain(
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()),
))
.unwrap(),
)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for key in [
"http_proxy",
"HTTP_PROXY",
"https_proxy",
"HTTPS_PROXY",
"all_proxy",
"ALL_PROXY",
"no_proxy",
"NO_PROXY",
] {
command.env_remove(key);
}
let mut child = command.spawn().unwrap();
let deadline = Instant::now() + Duration::from_secs(8);
let mut connection = loop {
if let Ok((connection, _)) = listener.accept() {
break connection;
}
if Instant::now() >= deadline || child.try_wait().unwrap().is_some() {
let _ = child.kill();
let output = child.wait_with_output().unwrap();
panic!(
"Installer did not connect through detected proxy: {}",
String::from_utf8_lossy(&output.stderr)
);
}
std::thread::sleep(Duration::from_millis(10));
};
connection
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
let mut request = Vec::new();
let mut buffer = [0u8; 1024];
while !request.ends_with(b"\r\n\r\n") {
match connection.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(n) => request.extend_from_slice(&buffer[..n]),
}
}
connection
.write_all(
b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.unwrap();
drop(connection);
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(request.starts_with(b"CONNECT example.invalid:443 HTTP/1.1\r\n"));
}
#[test]
fn checksum_rejects_ambiguity_and_tampering() {
let dir = tempfile::tempdir().unwrap();
@@ -191,9 +331,19 @@ mod tests {
archive.file_name().unwrap().to_str().unwrap()
);
std::fs::write(&sums, &line).unwrap();
verify(&archive, &sums).unwrap();
let mut progress = Vec::new();
verify_with_progress(&archive, &sums, &mut |value| progress.push(value)).unwrap();
assert_eq!(progress.first().unwrap().completed, 0);
assert_eq!(progress.last().unwrap().fraction(), Some(1.0));
assert!(
progress
.iter()
.all(|value| value.stage == crate::progress::Stage::Verify)
);
std::fs::write(&archive, b"changed").unwrap();
assert!(verify(&archive, &sums).is_err());
progress.clear();
assert!(verify_with_progress(&archive, &sums, &mut |value| progress.push(value)).is_err());
assert!(!progress.iter().any(|value| value.fraction() == Some(1.0)));
assert!(
expected_hash(
&(line.clone() + &line),
@@ -234,4 +384,46 @@ mod tests {
release.assets[0].name = "framely-0.4.2-build-linux-arm64.tar.gz".into();
assert!(package(&release).is_ok());
}
#[test]
fn first_install_uses_offline_and_updates_use_core_with_three_archives() {
let mut release = Release {
tag_name: "v0.4.2".into(),
name: None,
published_at: None,
body: None,
prerelease: false,
draft: false,
assets: [
"framely-0.4.2-build-linux-arm64.tar.gz",
"framely-0.4.2-build-offline-linux-arm64.tar.gz",
"framely-cef-154-build-linux-arm64.tar.gz",
"framely-cef.json",
"SHA256SUMS",
]
.into_iter()
.map(|name| Asset {
name: name.into(),
browser_download_url: format!("https://example.org/{name}"),
size: 1,
})
.collect(),
};
assert!(
package_for(&release, "install")
.unwrap()
.0
.name
.ends_with("-offline-linux-arm64.tar.gz")
);
assert_eq!(
package_for(&release, "update").unwrap().0.name,
"framely-0.4.2-build-linux-arm64.tar.gz"
);
release
.assets
.retain(|a| !a.name.ends_with("-offline-linux-arm64.tar.gz"));
assert!(package_for(&release, "install").is_err());
assert!(package_for(&release, "update").is_ok());
}
}
+65
View File
@@ -507,3 +507,68 @@ mod tests {
eprintln!("stages={stages:?}; error={error}");
}
}
/// Collect directly over SSH; neither the web panel nor the daemon is required.
pub fn export_logs(
connection: &Connection,
password: &str,
destination: &Path,
logs: &[String],
) -> Result<()> {
use base64::engine::general_purpose::STANDARD;
ensure!(
!password.is_empty() && !password.contains(['\n', '\r']),
"请输入有效的设备登录密码"
);
connection.session.set_timeout(60_000);
let script = include_str!("../../tools/export-diagnostics.py");
// Keep archive output private and binary-safe. The ordinary text logger
// masks password substrings, which could corrupt a base64 archive.
let mut channel = connection.session.channel_session()?;
let local_log = logs.join("\n").replace(password, "[密码已隐藏]");
let recent: String = local_log
.chars()
.rev()
.take(64 * 1024)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
channel.exec(&format!(
"sudo -S -p '' -- python3 -c {} --installer-log-base64 {}",
quote(script),
quote(&STANDARD.encode(recent))
))?;
channel.write_all(password.as_bytes())?;
channel.write_all(b"\n")?;
channel.flush()?;
channel.send_eof()?;
let mut output = String::new();
Read::by_ref(&mut channel)
.take(12 * 1024 * 1024)
.read_to_string(&mut output)?;
let mut error = String::new();
channel.stderr().take(4096).read_to_string(&mut error)?;
channel.wait_close()?;
ensure!(
channel.exit_status()? == 0,
"日志收集失败:{}",
error.replace(password, "[密码已隐藏]")
);
let archive: serde_json::Value =
serde_json::from_str(output.trim()).context("日志包响应无效")?;
let bytes = STANDARD.decode(archive["data"].as_str().context("日志包缺少数据")?)?;
ensure!(
bytes.len() <= 9 * 1024 * 1024 && bytes.starts_with(b"PK\x03\x04"),
"日志包格式无效"
);
let parent = destination
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let mut file = tempfile::NamedTempFile::new_in(parent).context("无法创建日志包")?;
file.write_all(&bytes).context("无法保存日志包")?;
file.as_file().sync_all()?;
file.persist(destination).context("无法保存日志包")?;
Ok(())
}
+31 -12
View File
@@ -1,6 +1,6 @@
use super::*;
use gpui_kit::assets::IconName;
use gpui_kit::component::Icon;
use gpui_kit::component::{Icon, switch::Switch};
use gpui_kit::prelude::FluentBuilder;
pub(super) const BG: u32 = 0x15171b;
@@ -479,6 +479,24 @@ impl Installer {
)
.into_any_element()
}
fn settings_page(&self, cx: &mut Context<Self>) -> AnyElement {
panel()
.child(heading("代理设置", "仅影响安装器检查版本与下载,不改变电脑或 Frame 的系统设置。"))
.child(div().flex().items_center().justify_between()
.child(div().flex().flex_col().gap_2().child("使用系统代理")
.child(muted("读取环境变量和系统网络设置;填写 HTTP 代理后优先使用指定地址。")))
.child(Switch::new("system-proxy").checked(self.system_proxy)
.accessibility_label("使用系统代理").disabled(self.busy)
.on_change(cx.listener(|view, checked, _, cx| { view.system_proxy = *checked; cx.notify(); }))))
.child(field("HTTP 代理", &self.http_proxy, self.busy))
.child(muted("留空时使用系统代理开关的选择。支持 HTTP 和 SOCKS 代理。"))
.child(field("GitHub 代理", &self.github_proxy, self.busy))
.child(muted("留空直接访问 GitHub。填写 HTTPS 前缀地址后,GitHub 请求按“代理地址/原始 URL”转发;仍保留包校验。"))
.when(self.error.is_some(), |this| this.child(div().text_color(rgb(0xf7a7a7)).child(self.error.clone().unwrap())))
.child(div().flex().justify_end().child(Button::new("save-proxy").primary().label("保存")
.disabled(self.busy).on_click(cx.listener(|view, _, _, cx| view.save_proxy(cx)))))
.into_any_element()
}
fn release_page(&self, cx: &mut Context<Self>) -> AnyElement {
let mut list = div()
.id("release-list")
@@ -1037,7 +1055,8 @@ impl Render for Installer {
let body = match self.page {
0 => self.device_page(cx),
1 => self.release_page(cx),
_ => self.maintenance_page(cx),
2 => self.maintenance_page(cx),
_ => self.settings_page(cx),
};
let navigation = div()
.flex()
@@ -1053,6 +1072,7 @@ impl Render for Installer {
("连接设备", IconName::Monitor),
("选择版本", IconName::Package),
("安装与维护", IconName::Wrench),
("设置", IconName::Settings),
]
.into_iter()
.enumerate()
@@ -1111,6 +1131,12 @@ impl Render for Installer {
},
)),
);
footer = footer.child(
Button::new("export-diagnostic-logs")
.label("导出日志")
.disabled(self.busy || self.connection.is_none())
.on_click(cx.listener(|view, _, _, cx| view.export_logs(cx))),
);
if self.page < 2 {
let label = if self.page == 0 && self.connection.is_none() {
"先连接设备"
@@ -1155,18 +1181,11 @@ impl Render for Installer {
view.navigate(next, cx);
})),
);
} else {
if self.connection.is_some() {
let url = format!(
"http://{}:15915",
self.connected_host
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(&self.connected_host)
);
} else if self.page == 2 {
if let Some(url) = self.manager_url() {
footer = footer.child(
Button::new("manager")
.ghost()
.primary()
.label("管理面板")
.on_click(move |_, _, cx| cx.open_url(&url)),
);
+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)
Loaded 100 of 351 files, more files were not shown because too many files have changed in this diff. Show more