mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 16:02:38 +02:00
Compare commits
324
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f718273a5 | ||
|
|
4e456480ae | ||
|
|
241f538f99 | ||
|
|
21dacfcbe7 | ||
|
|
eb181d8996 | ||
|
|
827ddcf14f | ||
|
|
7c3894e630 | ||
|
|
040581a4c7 | ||
|
|
17f6d52717 | ||
|
|
b4e80cab0a | ||
|
|
b98e3e496f | ||
|
|
a148e02d39 | ||
|
|
0fb9174c23 | ||
|
|
c7489a88eb | ||
|
|
be74850a8e | ||
|
|
dc21428f85 | ||
|
|
fa43c83b06 | ||
|
|
1d4c57d648 | ||
|
|
c6215712fe | ||
|
|
cd7ab2d9f7 | ||
|
|
4b68971383 | ||
|
|
54c001d537 | ||
|
|
5ce5c023b6 | ||
|
|
709fef0dbd | ||
|
|
49408dc171 | ||
|
|
c728e64fbd | ||
|
|
0626c52e12 | ||
|
|
b9b4a00485 | ||
|
|
eb125b59c7 | ||
|
|
7e53fe32cb | ||
|
|
ebae409632 | ||
|
|
08cb267ce4 | ||
|
|
0119544c3e | ||
|
|
2a9e7dcabf | ||
|
|
5a9099dddb | ||
|
|
ec4086db69 | ||
|
|
5a7c3541f8 | ||
|
|
525040162e | ||
|
|
21e771d16e | ||
|
|
2cd095a8c4 | ||
|
|
bb9236b2b7 | ||
|
|
1bc090d00d | ||
|
|
63ba25ffdd | ||
|
|
98e6e90d84 | ||
|
|
7c061846bd | ||
|
|
a91d55151f | ||
|
|
7f25e87b42 | ||
|
|
ff245953ac | ||
|
|
46665d51c0 | ||
|
|
67615e85c3 | ||
|
|
9e3c3cd31d | ||
|
|
537754ebce | ||
|
|
573a9e2730 | ||
|
|
8b91b58336 | ||
|
|
12c60ec024 | ||
|
|
60261f093f | ||
|
|
3120a3cacf | ||
|
|
fecb226e43 | ||
|
|
e9cbb8fd55 | ||
|
|
d72be95f25 | ||
|
|
65fedbf01e | ||
|
|
311ee70783 | ||
|
|
f832d0b912 | ||
|
|
361bcf8a1b | ||
|
|
10f86d3e7a | ||
|
|
5fea1526ca | ||
|
|
7a7dca70de | ||
|
|
bf8c500a87 | ||
|
|
71fe208fea | ||
|
|
2b838ede9e | ||
|
|
8402a671ef | ||
|
|
310e778d00 | ||
|
|
a909beb39e | ||
|
|
e2601965bd | ||
|
|
96502e6e4d | ||
|
|
d5f26efc1a | ||
|
|
eac9557286 | ||
|
|
4aa9f18fd1 | ||
|
|
fa40e30f3c | ||
|
|
26206e85b4 | ||
|
|
d7a887b98c | ||
|
|
30cbe1c901 | ||
|
|
f4efefa958 | ||
|
|
a5980d8f98 | ||
|
|
e179616ad0 | ||
|
|
8f245cede7 | ||
|
|
359407bd06 | ||
|
|
810ef1822d | ||
|
|
3dd8e982a3 | ||
|
|
fce1800dcf | ||
|
|
3f377d8f56 | ||
|
|
9060142f5d | ||
|
|
172851971c | ||
|
|
48e9ce58ee | ||
|
|
9a5d697579 | ||
|
|
71ef6a57c7 | ||
|
|
be9e7b5cda | ||
|
|
5c44d56f53 | ||
|
|
fed2249776 | ||
|
|
1e44a74165 | ||
|
|
2a3e52f708 | ||
|
|
b2485500e1 | ||
|
|
c5521a917f | ||
|
|
a13f4fafab | ||
|
|
f9496d67da | ||
|
|
7eae209b1c | ||
|
|
28bc0506a6 | ||
|
|
0a4f008d03 | ||
|
|
ea0b6a80bd | ||
|
|
16b37bc667 | ||
|
|
a8e5d7cf61 | ||
|
|
5cdfee4f62 | ||
|
|
dd80ca7b74 | ||
|
|
612a766057 | ||
|
|
d546568a11 | ||
|
|
5b531c84fb | ||
|
|
ae8cc1b4a6 | ||
|
|
e89912d936 | ||
|
|
4c27a3397c | ||
|
|
75acf4a3e9 | ||
|
|
629a2fafb6 | ||
|
|
b0d848dc5c | ||
|
|
57a9d8c717 | ||
|
|
fa9ee01cc1 | ||
|
|
63017e1969 | ||
|
|
b351fe7ad6 | ||
|
|
6aab62983e | ||
|
|
bcdd9d0830 | ||
|
|
ddd2d1c295 | ||
|
|
8f2e34a40e | ||
|
|
351230fcad | ||
|
|
5b35e9cc5d | ||
|
|
baaddd7622 | ||
|
|
671c4c06e7 | ||
|
|
07fc7ca3ff | ||
|
|
443813a088 | ||
|
|
e885e9adba | ||
|
|
7aa7c55be9 | ||
|
|
92a68285b5 | ||
|
|
2554309c7d | ||
|
|
5878c45ddb | ||
|
|
adc1e44cf1 | ||
|
|
7f46cd54a4 | ||
|
|
ba0ac6022b | ||
|
|
8b9bd2119c | ||
|
|
d773babaa3 | ||
|
|
a8888cb2fc | ||
|
|
1f51cc89d2 | ||
|
|
bcdb4c3b78 | ||
|
|
e9b02d61e7 | ||
|
|
02419ffbd4 | ||
|
|
5db9631571 | ||
|
|
c060df4b50 | ||
|
|
c7125bc3de | ||
|
|
9c33711e6f | ||
|
|
9d92396d49 | ||
|
|
be2eda8304 | ||
|
|
f5bed08931 | ||
|
|
e144325375 | ||
|
|
291b8ec295 | ||
|
|
9697812984 | ||
|
|
0fc6fbe596 | ||
|
|
f2261bac1c | ||
|
|
7c45d0ff24 | ||
|
|
19233fbf7d | ||
|
|
3ef978e232 | ||
|
|
8afa1113c9 | ||
|
|
7fe7e504da | ||
|
|
f678b4c8a2 | ||
|
|
33e4d2c092 | ||
|
|
621330d397 | ||
|
|
15e14c0d47 | ||
|
|
141b95ddde | ||
|
|
4ec2ff40c3 | ||
|
|
39e819872a | ||
|
|
ee7951e8d0 | ||
|
|
77c6c286cd | ||
|
|
ca53b26701 | ||
|
|
baec984449 | ||
|
|
18e5053b64 | ||
|
|
4ecc03280e | ||
|
|
2f1aeaac72 | ||
|
|
8347c2c3f7 | ||
|
|
a94c45d014 | ||
|
|
7faf80fc2b | ||
|
|
6ea20a2806 | ||
|
|
e20225604d | ||
|
|
2d81c92114 | ||
|
|
fe73906f1f | ||
|
|
84912ea4e1 | ||
|
|
d456aa1f3d | ||
|
|
6c7b2ca22f | ||
|
|
8a06848b5f | ||
|
|
9cca67529b | ||
|
|
b656f80922 | ||
|
|
7cc418662d | ||
|
|
b2bbff4305 | ||
|
|
2cfcdaaf0e | ||
|
|
2960581541 | ||
|
|
8b1c290730 | ||
|
|
b64082c974 | ||
|
|
7c1d698400 | ||
|
|
b6406a90a4 | ||
|
|
9b26c907ce | ||
|
|
5f3798b79b | ||
|
|
6e0b140efc | ||
|
|
226ec40720 | ||
|
|
f1aaed6279 | ||
|
|
b89538f506 | ||
|
|
311fb5f6c7 | ||
|
|
93ae2b1bd1 | ||
|
|
12bd10ee42 | ||
|
|
218df4f312 | ||
|
|
ce1a271064 | ||
|
|
dbf6ffc79c | ||
|
|
a8d5c4b44d | ||
|
|
b365a85665 | ||
|
|
8aeeaef666 | ||
|
|
4e374de040 | ||
|
|
d17e911c5b | ||
|
|
6746a8220d | ||
|
|
5fb02770db | ||
|
|
62d233f9c9 | ||
|
|
2861567d15 | ||
|
|
fcf2d12411 | ||
|
|
b5c8d2f19d | ||
|
|
4a489e4606 | ||
|
|
0304575e5b | ||
|
|
81bf646b0e | ||
|
|
4f99cd75d0 | ||
|
|
3e814cfa1a | ||
|
|
6e566db1a7 | ||
|
|
f221a5f588 | ||
|
|
b1f33c804c | ||
|
|
db9bee2903 | ||
|
|
22df550ff2 | ||
|
|
7871aa1ca0 | ||
|
|
0a9bb2e0c3 | ||
|
|
b25855d13a | ||
|
|
9e70cdb48a | ||
|
|
9da1dea974 | ||
|
|
241e35d3b6 | ||
|
|
70309d3cec | ||
|
|
76878403c3 | ||
|
|
105b92a0dd | ||
|
|
d356d9a9d5 | ||
|
|
6f5aa33197 | ||
|
|
e47eefec7c | ||
|
|
d92a9fc7ae | ||
|
|
875e92ea7b | ||
|
|
f9a87e76e8 | ||
|
|
95ccae5494 | ||
|
|
d58a926257 | ||
|
|
9292ce0a4c | ||
|
|
9d062b7ba0 | ||
|
|
a46c28d6c8 | ||
|
|
67bb71a708 | ||
|
|
0b9a165d83 | ||
|
|
88a2fe6728 | ||
|
|
ba9706b69e | ||
|
|
87884f382b | ||
|
|
f98f3e728b | ||
|
|
90034540ae | ||
|
|
cc9af9eba4 | ||
|
|
bc0e0fc043 | ||
|
|
5b4efc87b3 | ||
|
|
c2c1cf0168 | ||
|
|
01ba796881 | ||
|
|
b9f96866e5 | ||
|
|
3fb876ad5a | ||
|
|
9bd6890fe8 | ||
|
|
0d181b676e | ||
|
|
8de0292568 | ||
|
|
8e8c24f370 | ||
|
|
79dda60bde | ||
|
|
0bb2f9eb03 | ||
|
|
30509da71f | ||
|
|
d4b34e1bad | ||
|
|
22548bc42f | ||
|
|
1863c92101 | ||
|
|
497b5d9ae6 | ||
|
|
04995c066f | ||
|
|
0bceb5c928 | ||
|
|
22bb9c6469 | ||
|
|
fab04f0558 | ||
|
|
c0ae75d56b | ||
|
|
0520082586 | ||
|
|
36dd5a05f2 | ||
|
|
002c040223 | ||
|
|
4b1bb1557d | ||
|
|
73b97fedc5 | ||
|
|
924e37aa30 | ||
|
|
b151ae691e | ||
|
|
36db6b1ac6 | ||
|
|
bd24436fbc | ||
|
|
895cc0ebbd | ||
|
|
12aaec7888 | ||
|
|
7ea999d5b4 | ||
|
|
f7dbad500a | ||
|
|
3f20e5f9d3 | ||
|
|
d6fb5e917a | ||
|
|
8051687e1e | ||
|
|
9e25ae9025 | ||
|
|
ed4a0ad5cd | ||
|
|
cb0a90a9c3 | ||
|
|
32fbbfe475 | ||
|
|
daf30515b1 | ||
|
|
2f056dbcff | ||
|
|
e3176a2de8 | ||
|
|
416f3181cc | ||
|
|
3cb3defaa7 | ||
|
|
a992f6d896 | ||
|
|
d9d538690e | ||
|
|
391ecbeac5 | ||
|
|
1dc2a99617 | ||
|
|
d6f3d7c380 | ||
|
|
838eaa4e6b | ||
|
|
6104c3f192 | ||
|
|
da5c954ce4 | ||
|
|
2466c5aa14 | ||
|
|
060cf9cd57 | ||
|
|
cd202433bb | ||
|
|
c5fc552c60 | ||
|
|
559caa2dd0 |
No files matched your search
@@ -22,6 +22,7 @@ desktop or panels.
|
||||
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
|
||||
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
|
||||
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
|
||||
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
|
||||
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
|
||||
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||
|
||||
@@ -5,6 +5,11 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# A newer push to the same pull request supersedes a run still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -17,7 +22,9 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||
- name: Script syntax
|
||||
run: |
|
||||
sh -n ui/local-bin/ssh
|
||||
@@ -36,8 +43,8 @@ jobs:
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
- name: Updater tests
|
||||
run: node --test app/test/updater.test.js
|
||||
- name: App tests
|
||||
run: node --test app/test/*.test.js
|
||||
- name: Website
|
||||
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
|
||||
|
||||
@@ -84,6 +91,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||
- name: End-to-end tests
|
||||
run: scripts/e2e.sh
|
||||
@@ -0,0 +1,53 @@
|
||||
name: PC host libraries
|
||||
# PC in the headset (Experimental). The Windows job builds the native adapter
|
||||
# and runs the real-encoder protocol tests; the installer's own bundle is
|
||||
# tested in release.yml. The Linux adapter from PR #38 isn't shipped or offered
|
||||
# in the app, but shares capture.c and controller.c, so it's still built here.
|
||||
# These builds add the GPL x264 encoder for headless tests only: they're never
|
||||
# uploaded or shipped.
|
||||
on:
|
||||
pull_request:
|
||||
paths: ["desktop/**", "ui/**", "tests/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
linux:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Native libraries
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
|
||||
python3 desktop/build.py --with-x264
|
||||
- name: Tests (real x264, fake capture consent/input)
|
||||
env:
|
||||
FRAME_PC_REQUIRE_NATIVE: "1"
|
||||
FRAME_PC_REQUIRE_X264: "1"
|
||||
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
|
||||
# flaky mirror can't break a release build.
|
||||
- name: MSYS2 packages for the PC host
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
python desktop/build.py --with-x264
|
||||
FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_REQUIRE_X264=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
@@ -2,22 +2,36 @@ name: release
|
||||
|
||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
||||
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||
# Pull requests that touch the app build the same installers as artifacts.
|
||||
# Pull requests that change how the installers are built (the Electron app, the
|
||||
# Windows PC helper libraries, the macOS helper, or this workflow) build the same installers as
|
||||
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
|
||||
# are tested by the checks workflow; run this one by hand to package them early.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# One job makes the draft, before the builds: three matrix jobs each running
|
||||
# "view || create" could race and make duplicate drafts. The draft is tied to
|
||||
# the pushed tag (--verify-tag, then tag_name set explicitly), so
|
||||
# scripts/publish-release.sh and `gh release upload` find it by tag.
|
||||
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
|
||||
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
|
||||
# by tag. Editing a draft can make GitHub show an untagged-* name again; the
|
||||
# upload step below recovers that exact draft if tag lookup fails. It is
|
||||
# created with the REST API so the id comes straight back: the
|
||||
# release list can lag a fresh draft (v0.4.2's first run found nothing and
|
||||
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
|
||||
# this step against tests/fakegh.
|
||||
draft:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
@@ -28,11 +42,21 @@ jobs:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
|
||||
if [ -z "$id" ]; then
|
||||
gh release create "$tag" --draft --verify-tag --title "Frame Control ${tag#v}" --notes ""
|
||||
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\") | .id" | head -n 1)
|
||||
# A rerun finds the draft even if GitHub temporarily shows untagged-*.
|
||||
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
|
||||
--jq ".[] | select(.tag_name == \"$tag\" or (.draft and (.tag_name | startswith(\"untagged-\")) and .name == \"Frame Control ${tag#v}\")) | .id")
|
||||
if [ -n "$id" ] && ! [[ "$id" =~ ^[0-9]+$ ]]; then
|
||||
echo "expected one release for $tag; found: $id" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$id" ]; then
|
||||
# Given a missing tag, GitHub would create it on the default branch.
|
||||
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|
||||
|| { echo "tag $tag isn't on GitHub" >&2; exit 1; }
|
||||
id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
|
||||
-F draft=true -f body="" --jq .id)
|
||||
fi
|
||||
[ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
|
||||
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
|
||||
|
||||
build:
|
||||
@@ -58,20 +82,88 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
# PC in the headset (Experimental, Windows only): our capture adapter and
|
||||
# the GStreamer libraries it uses, bundled into resources/desktop/bundle.
|
||||
- uses: msys2/setup-msys2@v2
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
|
||||
# flaky mirror can't break a release build.
|
||||
- name: MSYS2 packages for the PC host
|
||||
if: runner.os == 'Windows'
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
- name: Windows PC streaming libraries
|
||||
if: runner.os == 'Windows'
|
||||
shell: msys2 {0}
|
||||
run: python desktop/build.py --out app/build/desktop/win-x64
|
||||
- name: Build
|
||||
working-directory: app
|
||||
shell: bash
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
# The installer, not just the unpacked build: its 7-Zip payload once dropped
|
||||
# the OpenXR layer's arm64 library without any error. Install it silently
|
||||
# and compare every installed file with the unpacked build.
|
||||
- name: Check the Windows installer installs every file
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$setup = Get-ChildItem app/dist/Frame-Control-Setup-*.exe | Select-Object -First 1
|
||||
$p = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "installer exited $($p.ExitCode)" }
|
||||
$installed = Join-Path $env:LOCALAPPDATA 'Programs\Frame Control'
|
||||
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
|
||||
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
# From the installed app, so this also proves the installer carries the
|
||||
# libraries. After the check above, and without writing __pycache__,
|
||||
# so it can't change what that check compares.
|
||||
- name: Test the installed app's Python with the Windows PC libraries
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
run: |
|
||||
resources="$(cygpath -u "$LOCALAPPDATA")/Programs/Frame Control/resources"
|
||||
python="$resources/python/python.exe"
|
||||
native="$(cygpath -w "$resources/desktop/bundle")"
|
||||
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_INSTALLER_BUNDLE=1 "$python" -B -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
- name: Upload to the release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
||||
# A release-notes edit can make GitHub expose a draft as untagged-*.
|
||||
# Keep the normal upload path; recover only that lookup failure.
|
||||
if result=$(gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber 2>&1); then
|
||||
printf '%s\n' "$result"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$result" >&2
|
||||
case "$result" in *"release not found"*) ;; *) exit 1 ;; esac
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
ids=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
|
||||
--jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\" and (.tag_name == \"$tag\" or (.tag_name | startswith(\"untagged-\")))) | .id")
|
||||
if ! [[ "$ids" =~ ^[0-9]+$ ]]; then
|
||||
echo "expected one matching draft for $tag; found: ${ids:-none}" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh api -X PATCH "repos/$GH_REPO/releases/$ids" -f tag_name="$tag" >/dev/null
|
||||
gh release upload "$tag" ${{ matrix.files }} --clobber
|
||||
# Tagged builds are attached to the release above; only other builds need artifacts.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
with:
|
||||
name: frame-control-${{ matrix.os }}
|
||||
path: |
|
||||
|
||||
+10
@@ -7,5 +7,15 @@ compat-db/.lakebed/
|
||||
tests/smoke/results/
|
||||
mac/bin/
|
||||
|
||||
# Shared desktop streaming controller build products
|
||||
desktop/controller.dll
|
||||
desktop/controller.dylib
|
||||
desktop/controller.so
|
||||
desktop/*.obj
|
||||
desktop/*.lib
|
||||
desktop/*.exp
|
||||
desktop/bundle/
|
||||
app/build/desktop/
|
||||
|
||||
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
|
||||
frame/kdeconnect/packages/
|
||||
@@ -0,0 +1,97 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Distinguish an unreadable gamescope panel list from a confirmed empty list in
|
||||
Desktop Capture and Control (issue #73). Failed discovery now asks users to
|
||||
check SteamVR and report the exact message and SteamOS version, rather than
|
||||
claiming no panel is open. This does not establish a new capture fix.
|
||||
|
||||
- Add an opt-in desktop low-battery alert without starting a family session.
|
||||
Existing status reads confirm low charge and drain; recovery hysteresis and a
|
||||
ten-minute cooldown limit repeat alerts. Off on page open/headset switch;
|
||||
pauses with hidden-page status refreshes. No extra polling or headset changes.
|
||||
|
||||
- Clarify first-time desktop downloads, installation and opening on the website
|
||||
and README, including CPU choices, unsigned-app warnings, pairing fallback
|
||||
and the source-only iOS build. Keep section navigation available on mobile.
|
||||
|
||||
- Treat headset aliases and other CSV metadata as plain text in power and heat
|
||||
exports, so spreadsheet formula prefixes cannot run when a report is opened.
|
||||
|
||||
- Add desktop Tools → Power & heat report: bounded read-only one/five-minute
|
||||
observations with progress, stop and HTML/JSON/CSV downloads. Received samples
|
||||
survive stop, disconnect and headset switch, labeled incomplete and tied to the
|
||||
original headset. Shared sampler/report modules ship with the app; no power or
|
||||
thermal controls are added. Download before replacing a report or quitting.
|
||||
|
||||
- Fix Return to Game Mode misidentifying full Plasma Desktop Mode when its VR
|
||||
overlay also runs gamescope (issue #142). Session targets now determine the
|
||||
mode; conflicting or incomplete evidence stays unknown. Use SteamOS's
|
||||
`steamosctl` switch when available, with older selector commands retained.
|
||||
|
||||
- Export read-only power and heat observations to an offline accessible HTML
|
||||
report with JSON/CSV downloads. `--html FILE` limits sessions to ten minutes;
|
||||
Ctrl+C saves received samples with an explicit incomplete state. Units,
|
||||
unknown counts and sources remain visible; no headset controls are added.
|
||||
|
||||
- Add a guarded `frame-doctor.sh --plan` with read-only, evidence-specific next
|
||||
steps and explicit local baseline reports for before/after diagnostics (issue
|
||||
#34). Restart risk blocks plans/comparisons; no automatic repairs, password
|
||||
changes or headset security changes are performed.
|
||||
|
||||
- Recover a tagged release upload when GitHub temporarily displays its draft
|
||||
as `untagged-…` after an edit, without creating a duplicate draft or
|
||||
silently retrying unrelated upload failures.
|
||||
|
||||
- Import standalone APK/OBB backups from XAPK/APKS/APKM archives through the
|
||||
existing preview and game-data setup flow. Unsafe archives, split sets and
|
||||
Android/data restores are refused with guidance. This does not establish RE4
|
||||
compatibility or diagnose its reported failure (issue #102).
|
||||
|
||||
- Add `scripts/frame-power-sample.sh`, a bounded read-only SSH sampler for
|
||||
battery, CPU, named temperatures, fan and available power rails (issue #52).
|
||||
JSON samples/summaries and CSV exports retain explicit unavailable states;
|
||||
active smoke-test locks stop measurement. No power settings are changed.
|
||||
|
||||
- Show saved Android compatibility reports on store search cards and app details.
|
||||
Reports refresh in the background, so a stalled database or credential lookup
|
||||
cannot delay search. Warnings allow installation; build compatibility blockers
|
||||
still apply. Public copies use this computer's reports, not private shared ratings.
|
||||
|
||||
- Fix two cases where optional diagnostic logs could retain part of a headset
|
||||
display name or an SSH host name, while preserving existing privacy redaction
|
||||
(issue #81).
|
||||
|
||||
- Keep the full host-key recovery explanation readable in Devices, including
|
||||
long addresses, and remove doubled punctuation from the connection tooltip
|
||||
(issue #87). Refresh stays within the header and switching headsets selects
|
||||
the new headset's details.
|
||||
|
||||
- Fix experimental PC screen capture on Windows builds whose streaming libraries
|
||||
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
|
||||
windows are hidden with an explanation instead of failing when shown. A Windows
|
||||
VM streamed a real desktop to the Frame; physical input remains unverified.
|
||||
|
||||
- Add read-only battery health in Tools: charging state, temperature, cycle count,
|
||||
and available capacity with explicit unknown states. The parser was checked
|
||||
on a real Frame; the connected desktop UI was tested with synthetic readings.
|
||||
|
||||
- Add Tools → Game storage: read-only headset filesystem capacity and Steam
|
||||
library/game install-size breakdown, with unknown sizes, partial inventories
|
||||
and permission/missing-drive states. Includes instructions for Steam's own
|
||||
Move UI; Frame Control does not move or delete game files. The read-only
|
||||
parser was checked on a real Frame; the connected UI and game moves were not.
|
||||
|
||||
- Add `scripts/frame-doctor.sh` for read-only setup and pairing diagnostics,
|
||||
with verified SSH identity, private summaries and actionable next steps
|
||||
(issue #34). Connection steps links to the diagnostic guide. Repairs remain
|
||||
manual; restart-loop evidence stops deeper diagnostics. The real Frame
|
||||
verified the SSH connection and safety stop; deeper checks were skipped.
|
||||
- Fixed headset smoke cleanup reporting success while a smoke-title process
|
||||
could remain running after its files were removed. Cleanup now stops and
|
||||
rechecks processes tied to exact reserved test-title paths, uses PID handles
|
||||
for safe TERM/KILL escalation, and records stopped PIDs. Unrelated titles and
|
||||
shared Wine services are left alone. Verified with local Linux process
|
||||
regressions and a 13/13 real Frame smoke run that stopped five test-owned
|
||||
Proton process PIDs before removing the Windows test title.
|
||||
@@ -7,9 +7,9 @@
|
||||
**Manage your Valve Steam Frame from your computer.**<br>
|
||||
See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
|
||||
|
||||
[](https://github.com/saphid/steam-frame/releases/latest)
|
||||
[](https://github.com/saphid/frame-control/releases/latest)
|
||||
[](#install)
|
||||
[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
|
||||
[](https://github.com/saphid/frame-control/actions/workflows/checks.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[**Website**](https://frame-control.pages.dev) · [**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
|
||||
@@ -19,7 +19,7 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
<img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
|
||||
|
||||
<a id="trailer"></a>
|
||||
<a href="https://github.com/saphid/steam-frame/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
|
||||
<a href="https://github.com/saphid/frame-control/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
|
||||
|
||||
<sub>The trailer: 66 seconds, with sound. Downloads the MP4 from the trailer release.</sub>
|
||||
|
||||
@@ -44,13 +44,16 @@ Live video of what the lenses show, or of the app panel in use, flat and still h
|
||||
**🔋 Battery and status**<br>
|
||||
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
|
||||
|
||||
For repeatable read-only battery, power-rail, temperature and fan measurements,
|
||||
use the [power and heat sampler](docs/power-and-heat.md).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**🎮 Steam games**<br>
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, search the store, and force quit a game that stops responding.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
@@ -63,17 +66,17 @@ About 4,500 F-Droid apps rated for the Frame. One click installs each as its own
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**📁 Files, games and clipboard**<br>
|
||||
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
|
||||
**📁 Files and games**<br>
|
||||
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
**📸 Screenshots**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||
**📸 Screenshots and recordings**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
|
||||
|
||||
**⌨️ Keyboard and trackpad**<br>
|
||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Nothing else to install, anywhere.
|
||||
**⌨️ Type on the headset**<br>
|
||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Copy text to the headset's clipboard and back again. Nothing else to install, anywhere. Press T to open it.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -89,6 +92,14 @@ Install desktop apps like Moonlight or VLC, and set each Android app's resolutio
|
||||
**⚡ One-click tools**<br>
|
||||
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
**💾 Backup and recovery**<br>
|
||||
Copy game saves, settings and Android app data to your computer, and restore them after a factory reset. Sign-in files and keys are never copied. A Return to Game Mode button helps when the headset is stuck in Desktop Mode. Not tested on a headset yet. [How it works](docs/backup-and-recovery.md).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -105,17 +116,30 @@ already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
|
||||
## Install
|
||||
|
||||
**Install Frame Control on your computer, not on the headset.** The website is
|
||||
for downloads and instructions; it does not connect to your Frame in a browser.
|
||||
Choose the operating system and processor of the computer you will use.
|
||||
|
||||
| | Download | Needs |
|
||||
|---|---|---|
|
||||
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
|
||||
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra |
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
|
||||
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip) | OpenSSH Client (normally installed) |
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
|
||||
**iPhone and iPad:** the same features from your phone, with nothing to install on
|
||||
a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md).
|
||||
**Which build?** On a Mac, check Apple menu → About This Mac: the download is
|
||||
for Apple Silicon (M-series); no Intel Mac build is published. On Windows,
|
||||
check Settings → System → About → System type: the release is x64, not a native
|
||||
Windows ARM build. On Linux, `uname -m` reports `x86_64` (x64) or `aarch64`
|
||||
(arm64). Use `.deb` on Debian/Ubuntu, or AppImage on other distributions.
|
||||
|
||||
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
|
||||
**iPhone and iPad:** no App Store, TestFlight or `.ipa` download is provided in
|
||||
the current release. It runs from your phone without a computer after setup.
|
||||
Build it from [`ios/`](ios) in Xcode on a Mac with your own signing team; see
|
||||
[docs/iphone.md](docs/iphone.md).
|
||||
|
||||
The app brings its own Python and `adb`. macOS includes SSH; Windows needs the
|
||||
OpenSSH Client optional feature (normally installed).
|
||||
From 0.4 it updates itself: when a new version is published, a banner offers
|
||||
**Update and restart**. It sends anonymous usage statistics, which you can turn
|
||||
off. Sharing compatibility results and error details is opt-in. See
|
||||
@@ -128,7 +152,7 @@ distribution's. If you already have `adb`, the app uses yours.
|
||||
|
||||
There's no paid Apple developer account behind it, so macOS says the app is
|
||||
damaged or can't be checked. Drag it to Applications, then clear the download
|
||||
quarantine once:
|
||||
quarantine once, only for a download you trust from the releases linked above:
|
||||
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||
@@ -157,26 +181,44 @@ If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
|
||||
or run it with `--appimage-extract-and-run`.
|
||||
</details>
|
||||
|
||||
## Open the app
|
||||
|
||||
- **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then
|
||||
open it from Applications. For the unsigned-app warning, use the macOS steps
|
||||
above. Allow local network access and control of Terminal when prompted.
|
||||
- **Windows:** run the installer, then open **Frame Control** from Start. For
|
||||
the portable download, extract the entire `.zip` first and run
|
||||
`Frame Control.exe` inside it. See the SmartScreen note above.
|
||||
- **Linux:** install the `.deb` with your package installer and launch
|
||||
**Frame Control** from your applications menu; or make the AppImage executable
|
||||
and run it as described above. Install the OpenSSH client if `ssh` is missing.
|
||||
|
||||
## Set up the headset
|
||||
|
||||
You type one password on the headset, once. Everything else happens on your
|
||||
computer.
|
||||
Keep the Frame awake and connect it and your computer to the same trusted local
|
||||
network. Guest Wi-Fi/client isolation can prevent discovery and connections.
|
||||
|
||||
1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
|
||||
in the Developer section, **Set User Password**. Pick something short:
|
||||
you'll type it once more on your computer and then never again.
|
||||
2. **On your computer:** open Frame Control. It offers to **Set Up
|
||||
Connection**, which finds the headset, creates an SSH key, and asks for that
|
||||
password once in a terminal window. If it can't find the Frame, type the
|
||||
IP address from the Frame's Quick Settings.
|
||||
|
||||
Before asking for the password it tries Valve's SteamOS devkit pairing: in
|
||||
the headset, open Steam Settings → Developer → **Pair new host** and approve
|
||||
the request, and no password is needed. (The service and the pairing-mode
|
||||
step are verified on a Frame; the approval itself isn't yet. See
|
||||
[SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).)
|
||||
3. That's it. The app now reaches the headset whenever it's awake and on the
|
||||
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
|
||||
Developer → **Set User Password**. Choose a password that isn't easy to guess:
|
||||
it also protects `sudo` and remote desktop, even after SSH keys are set up.
|
||||
2. **On your computer:** open Frame Control and choose **Set Up Connection…**.
|
||||
If you dismissed the first-run prompt, use **Frame → Set Up Connection…**.
|
||||
Setup opens a terminal and tries to find the headset. If it can't, use its IP
|
||||
address from Quick Settings or Steam Settings → Internet. Windows/Linux setup
|
||||
prompts for it; macOS exits instead. On macOS, return to the app →
|
||||
**Devices & connection → + Add a headset…**, set SSH alias to `frame` for
|
||||
the default headset, fill in **Address**, then choose **Set Up…**.
|
||||
3. **Pair or enter the password:** setup first tries Valve's devkit pairing.
|
||||
Open Steam Settings → Developer → **Pair new host** in the headset and approve
|
||||
only the request you just started. The service and pairing-mode step are
|
||||
verified on a Frame; approval and key installation through that flow remain
|
||||
unverified. If pairing fails or times out, setup falls back to asking for the
|
||||
Developer Mode password in the computer's terminal. Password typing may show
|
||||
no characters; press Enter when done. See [SSH details](docs/ssh.md#password-free-pairing-steamos-devkit-service).
|
||||
4. After setup succeeds, return to Frame Control. It uses SSH keys for later
|
||||
connections whenever the Frame is awake and reachable. If it stays offline,
|
||||
check Developer Mode, the address and network isolation, then run setup again.
|
||||
For connections away from home, see [Tailscale](docs/tailscale.md).
|
||||
|
||||
**What it changes:** only what you click. Installs go to your user account on
|
||||
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
|
||||
@@ -189,7 +231,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
||||
|
||||
This is a first public test, so reports are really useful, especially from
|
||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
||||
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
|
||||
diagnostics with personal details removed, shows you exactly what's included,
|
||||
and sends it privately to the maintainer; nothing is published. Without the app,
|
||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||
@@ -213,16 +255,20 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
|
||||
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games may not |
|
||||
| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
|
||||
| [Power and heat measurements](docs/power-and-heat.md) | Read-only sampling and offline HTML reports with JSON/CSV downloads |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
|
||||
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||
|
||||
<details>
|
||||
@@ -237,8 +283,8 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
|
||||
database key (maintainer-only) is never written to the repo.
|
||||
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
|
||||
you've switched to key auth, a short password still protects `sudo` and
|
||||
RDP, so pick one that isn't trivially guessable.
|
||||
you've switched to key auth, that password still protects `sudo` and RDP,
|
||||
so pick one that isn't trivially guessable.
|
||||
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access,
|
||||
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
|
||||
**every** Frame port is reachable from your tailnet, including Steam's DevTools
|
||||
@@ -264,3 +310,5 @@ into a draft release, which reaches users once published. See
|
||||
licence, notably KDE Connect (GPL) for the keyboard and trackpad; see
|
||||
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Steam, Steam Frame and SteamVR are trademarks of Valve
|
||||
Corporation. This project isn't affiliated with or endorsed by Valve.
|
||||
|
||||
See [Game storage](docs/game-storage.md) for read-only headset space and Steam install sizes, and [the changelog](CHANGELOG.md) for unreleased changes.
|
||||
@@ -10,6 +10,8 @@ ship other people's software, unchanged, each under its own licence:
|
||||
| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
|
||||
| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
|
||||
| Electron | Desktop apps | MIT (Chromium: BSD-3-Clause and others) | `LICENSE` and `LICENSES.chromium.html` in the app |
|
||||
| GStreamer 1.x, a few of its plugins and the libraries they need, as [MSYS2](https://packages.msys2.org/) builds them | Windows app only, for the experimental [PC in the headset](docs/pc-in-headset.md) | LGPL-2.1-or-later and others (per package); the GPL x264 encoder is left out | `libraries.json` (exact package versions) and the `licenses` folder in the app's `desktop/bundle` folder |
|
||||
|
||||
Frame Control starts KDE Connect and talks to it over its network protocol;
|
||||
it doesn't link to it or include its code.
|
||||
it doesn't link to it or include its code. On Windows, Frame Control's own
|
||||
`pc-host.dll` links to GStreamer dynamically; the libraries are unmodified.
|
||||
@@ -6,11 +6,25 @@ A report: package, version, result (runs | crashes | install_failed |
|
||||
instance_failed, from an automated test), rating (works | issues | broken, from
|
||||
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
|
||||
Newest wins, and a person's rating beats an automated result.
|
||||
|
||||
An install_failed report saying the file had no arm64-v8a build (or needed a
|
||||
newer Android) is about that one APK file, often the wrong per-ABI download of
|
||||
an app that has an arm64 build, so it says nothing about the app and is left out.
|
||||
"""
|
||||
import re
|
||||
|
||||
FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API')
|
||||
|
||||
|
||||
def about_app(r):
|
||||
"""False for reports about one wrong APK file rather than the app itself."""
|
||||
return not (r.get('result') == 'install_failed' and not r.get('rating')
|
||||
and FILE_FAULT.search(r.get('notes') or ''))
|
||||
|
||||
|
||||
def verdict(reports):
|
||||
"""(verdict, summary lines) for one package's reports, or None."""
|
||||
reports = [r for r in reports or () if about_app(r)]
|
||||
if not reports:
|
||||
return None
|
||||
rs = sorted(reports, key=lambda r: r.get('date') or '')
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Checks that the app's resources hold what Frame Control can't work without.
|
||||
// The OpenXR compatibility layer is why this exists: VR APK installs need its
|
||||
// arm64-v8a library (ui/frame_android.py XR_COMPAT_FILES).
|
||||
//
|
||||
// As electron-builder's afterPack hook it checks the unpacked app. From the
|
||||
// command line it checks an installed copy, which is what caught the Windows
|
||||
// installer silently dropping the library (see .github/workflows/release.yml):
|
||||
// node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]
|
||||
// With a reference (the unpacked build's resources), every file in it must also
|
||||
// be in the installed copy, at the same size.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const REQUIRED = [
|
||||
"ui/server.py",
|
||||
"ui/frame_power.py",
|
||||
"ui/frame_status.py",
|
||||
"scripts/frame_power_sample.py",
|
||||
"scripts/frame_power_report.py",
|
||||
"ui/frame_android.py",
|
||||
"frame/android/lepton-app.sh",
|
||||
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
|
||||
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz",
|
||||
];
|
||||
|
||||
function resourcesDir(context) {
|
||||
if (context.electronPlatformName === "darwin" || context.electronPlatformName === "mas") {
|
||||
const app = `${context.packager.appInfo.productFilename}.app`;
|
||||
return path.join(context.appOutDir, app, "Contents", "Resources");
|
||||
}
|
||||
return path.join(context.appOutDir, "resources");
|
||||
}
|
||||
|
||||
function size(file) {
|
||||
try {
|
||||
return fs.statSync(file).size;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Required files that are missing or empty.
|
||||
function missing(dir) {
|
||||
return REQUIRED.filter((rel) => size(path.join(dir, rel)) <= 0);
|
||||
}
|
||||
|
||||
// Files under reference that aren't in dir at the same size.
|
||||
function differences(dir, reference) {
|
||||
const out = [];
|
||||
(function walk(rel) {
|
||||
for (const e of fs.readdirSync(path.join(reference, rel), { withFileTypes: true })) {
|
||||
const r = path.join(rel, e.name);
|
||||
if (e.isDirectory()) walk(r);
|
||||
else if (e.isFile() && size(path.join(dir, r)) !== size(path.join(reference, r))) out.push(r.split(path.sep).join("/"));
|
||||
}
|
||||
})("");
|
||||
return out;
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
const dir = resourcesDir(context);
|
||||
const gone = missing(dir);
|
||||
if (gone.length) {
|
||||
throw new Error(`packaged app is missing required resources in ${dir}: ${gone.join(", ")}`);
|
||||
}
|
||||
};
|
||||
exports.missing = missing;
|
||||
exports.differences = differences;
|
||||
exports.REQUIRED = REQUIRED;
|
||||
|
||||
if (require.main === module) {
|
||||
const [dir, reference] = process.argv.slice(2);
|
||||
if (!dir) {
|
||||
console.error("usage: node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]");
|
||||
process.exit(2);
|
||||
}
|
||||
const problems = [...missing(dir), ...(reference ? differences(dir, reference) : [])];
|
||||
if (problems.length) {
|
||||
console.error(`${dir} is missing or has the wrong size for:\n ${[...new Set(problems)].join("\n ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`${dir}: all required resources present${reference ? ", and every file of " + reference : ""}`);
|
||||
}
|
||||
+56
-3
@@ -1,7 +1,7 @@
|
||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||
// a free loopback port and shows it in a native window. The server does all the
|
||||
// work over the `frame` SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron");
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
@@ -11,6 +11,8 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
const { preferenceStore } = require("./preferences");
|
||||
const { createMetaSignIn } = require("./meta-signin");
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
@@ -27,6 +29,7 @@ const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Contr
|
||||
const LOG = path.join(LOG_DIR, "server.log");
|
||||
const BG = "#0d1117";
|
||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
|
||||
|
||||
let server = null;
|
||||
let url = null;
|
||||
@@ -115,10 +118,11 @@ function ping(target) {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
||||
// sends nothing from a source checkout.
|
||||
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
|
||||
// which sends nothing from a source checkout.
|
||||
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
||||
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
||||
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||
python = await findPython(env);
|
||||
@@ -278,6 +282,14 @@ ipcMain.handle("server:restart", (e) => {
|
||||
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
|
||||
});
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
// Text from the headset's clipboard onto this computer's.
|
||||
ipcMain.handle("clipboard:write", (e, text) => {
|
||||
if (!fromUi(e) || typeof text !== "string") return false;
|
||||
clipboard.writeText(text);
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("preferences:read", (e) => fromUi(e) ? preferences.read() : {});
|
||||
ipcMain.handle("preferences:write", (e, key, value) => fromUi(e) ? preferences.write(key, value) : false);
|
||||
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
|
||||
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
|
||||
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
|
||||
@@ -347,6 +359,47 @@ ipcMain.handle("comfort:notify", (e, message) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Sign in with Meta (app/meta-signin.js, docs/meta-library.md) ----
|
||||
// The token goes from the sign-in window's own session straight to the server's
|
||||
// connect path; the page asks for it and only hears the server's answer.
|
||||
function postApi(apiPath, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url) return reject(new Error("Frame Control's server isn't running."));
|
||||
const data = JSON.stringify(body);
|
||||
const req = http.request(new URL(apiPath, url), {
|
||||
method: "POST", timeout: 60000,
|
||||
headers: { "Content-Type": "application/json", "X-Frame-UI": process.env.FRAME_UI_KEY || "1",
|
||||
"Content-Length": Buffer.byteLength(data) },
|
||||
}, (res) => {
|
||||
let text = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (c) => { text += c; });
|
||||
res.on("end", () => {
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(text); } catch {}
|
||||
resolve({ status: res.statusCode, data: parsed });
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
|
||||
req.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
let metaSignIn = null;
|
||||
const meta = () => (metaSignIn ||= createMetaSignIn({
|
||||
BrowserWindow, session, shell, parent: () => win,
|
||||
// The page only offers sign-in after its "I understand" box is ticked (checked again below).
|
||||
connect: (token, attempt) => postApi("/api/meta", { action: "connect", token, acknowledged: true, attempt }),
|
||||
cancel: (attempt) => postApi("/api/meta", { action: "cancel-signin", attempt }),
|
||||
}));
|
||||
ipcMain.handle("meta:signin", (e, acknowledged) => {
|
||||
if (!fromUi(e)) return null;
|
||||
if (acknowledged !== true) return { ok: false, error: "My Meta library is experimental: tick that you understand the warning first." };
|
||||
return meta().signIn();
|
||||
});
|
||||
ipcMain.handle("meta:signout", (e) => fromUi(e) ? meta().signOut() : false);
|
||||
|
||||
// frame-control://install links from websites (docs/web-install.md). They can
|
||||
// arrive before the window or server exists (macOS open-url on a cold launch),
|
||||
// so they wait here until the page asks for them. The page checks the link with
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
// "Sign in with Meta" for My Meta library (docs/meta-library.md), the way BSManager's
|
||||
// "Log in with Meta" works: Meta's own login page in a Frame Control window, then the
|
||||
// access token cookie (oc_ac_at) that page sets is handed to the local server's
|
||||
// existing connect path (format check, "who am I" with Meta, 0600 file).
|
||||
//
|
||||
// The window has its own persistent session (PARTITION), so the cookie is read only
|
||||
// from there: never from Frame Control's main window, never from any browser. It has
|
||||
// no preload and no Node, and it only navigates within Meta's login sites; anything
|
||||
// else opens in the system browser. Nothing here logs the token, and the page only
|
||||
// ever gets back the server's answer (connected, display name), never the token.
|
||||
// Electron is passed in, so app/test/meta-signin.test.js can run it with fakes.
|
||||
const crypto = require("crypto");
|
||||
|
||||
const PARTITION = "persist:meta-signin";
|
||||
// BSManager's loginUrl (src/main/services/bs-version-download/bs-oculus-downloader.service.ts).
|
||||
const LOGIN_URL = "https://secure.oculus.com";
|
||||
const COOKIE = "oc_ac_at";
|
||||
// Sites the login can go through: the Oculus store web, Meta accounts, Facebook login,
|
||||
// and Instagram (Meta's login passes through www.instagram.com: seen live, 2026-10-14).
|
||||
const ALLOWED = ["oculus.com", "meta.com", "facebook.com", "instagram.com"];
|
||||
const TIMEOUT = 10 * 60 * 1000; // as BSManager
|
||||
|
||||
const underDomain = (host, domains) => {
|
||||
host = String(host || "").replace(/^\./, "").toLowerCase();
|
||||
return domains.some((d) => host === d || host.endsWith("." + d));
|
||||
};
|
||||
|
||||
// Plain HTTPS on the default port, to one of the login sites.
|
||||
function allowedUrl(raw) {
|
||||
let u;
|
||||
try { u = new URL(String(raw)); } catch { return false; }
|
||||
if (u.protocol !== "https:" || u.username || u.password || (u.port && u.port !== "443")) return false;
|
||||
return underDomain(u.hostname, ALLOWED);
|
||||
}
|
||||
|
||||
// Frames inside the page: any HTTPS address (Meta's login can embed captcha and CDN
|
||||
// frames from other sites), or an empty frame; never data:, blob:, javascript:, http:,
|
||||
// file: or any other scheme. Frames can't reach the cookie read or IPC (main process only).
|
||||
function allowedSubframeUrl(raw) {
|
||||
if (/^about:(?:blank|srcdoc)$/.test(String(raw))) return true;
|
||||
let u;
|
||||
try { u = new URL(String(raw)); } catch { return false; }
|
||||
return u.protocol === "https:" && !u.username && !u.password;
|
||||
}
|
||||
|
||||
const onOculus = (raw) => { try { return underDomain(new URL(raw).hostname, ["oculus.com"]); } catch { return false; } };
|
||||
|
||||
// A cheap first look, so a signed-out or app value is never sent anywhere; the server's
|
||||
// check_token (ui/frame_meta.py) has the final word.
|
||||
function looksLikeUserToken(v) {
|
||||
return typeof v === "string" && /^(?:OC|FRL)[A-Za-z0-9+/=_-]{18,4093}$/.test(v)
|
||||
&& !/^OC\d{15}$/.test(v) && /[a-z]/.test(v);
|
||||
}
|
||||
|
||||
const fingerprint = (v) => crypto.createHash("sha256").update(v).digest("hex");
|
||||
|
||||
// Anything token-shaped out of a message, in case a server error ever carried one.
|
||||
function scrub(text, token) {
|
||||
let s = String(text == null ? "" : text);
|
||||
if (token) s = s.split(token).join("<redacted>");
|
||||
return s.replace(/(?:OC|FRL)[A-Za-z0-9+/=_%|:-]{8,}/g, "<redacted>").slice(0, 600);
|
||||
}
|
||||
|
||||
// deps: { BrowserWindow, session, shell, parent() -> BrowserWindow|null, timeout,
|
||||
// connect(token, attempt) -> Promise<{status, data}> (the server's connect action),
|
||||
// cancel(attempt) -> Promise (the server's cancel-signin: never save that attempt) }
|
||||
function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent = () => null, timeout = TIMEOUT }) {
|
||||
let active = null; // { win, promise, finish }
|
||||
const pending = new Set(); // cancels still to reach the server, from flows already finished
|
||||
let signingOut = null; // the sign-out under way; no sign-in starts while it runs
|
||||
const SIGNING_OUT = "Frame Control is signing out of Meta; try again in a moment.";
|
||||
|
||||
const openOutside = (target) => {
|
||||
if (/^https?:\/\//i.test(String(target))) shell.openExternal(String(target));
|
||||
};
|
||||
|
||||
function signIn() {
|
||||
if (signingOut) return Promise.resolve({ ok: false, error: SIGNING_OUT });
|
||||
if (active) { active.win.focus(); return active.promise; }
|
||||
const ses = session.fromPartition(PARTITION);
|
||||
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false));
|
||||
ses.setPermissionCheckHandler(() => false);
|
||||
// Electron adds its own name and the app's to the user agent; send a plain Chrome one.
|
||||
if (ses.getUserAgent && ses.setUserAgent) {
|
||||
ses.setUserAgent(ses.getUserAgent().replace(/\s(?:Electron|frame-control|Frame Control)\/\S+/g, ""));
|
||||
}
|
||||
const p = parent();
|
||||
const win = new BrowserWindow({
|
||||
width: 520, height: 760, title: "Sign in with Meta – Frame Control", autoHideMenuBar: true,
|
||||
...(p ? { parent: p } : {}),
|
||||
webPreferences: { partition: PARTITION, contextIsolation: true, nodeIntegration: false, sandbox: true,
|
||||
nodeIntegrationInSubFrames: false, webviewTag: false, safeDialogs: true, devTools: false },
|
||||
});
|
||||
const wc = win.webContents;
|
||||
|
||||
let resolve;
|
||||
const promise = new Promise((r) => { resolve = r; });
|
||||
let done = false, busy = false, again = false, lastError = null, blocked = null;
|
||||
const tried = new Set(); // fingerprints of values already sent, so each goes once
|
||||
// The server refuses to save a connect from an attempt this window gave up on.
|
||||
const attemptId = crypto.randomUUID();
|
||||
|
||||
const onCookie = (_e, cookie, _cause, removed) => {
|
||||
if (!removed && cookie && cookie.name === COOKIE && underDomain(cookie.domain, ["oculus.com"])) attempt();
|
||||
};
|
||||
const timer = setTimeout(() => finish({ ok: false, error: "Sign-in timed out after 10 minutes. Try again." }), timeout);
|
||||
if (timer.unref) timer.unref();
|
||||
|
||||
// Stops cookie intake first, then the window (destroy: a page's beforeunload can't
|
||||
// keep it open), and answers the page. Unless it connected, and if it sent a token, it
|
||||
// then has the server drop this attempt: only once the connect under way has settled,
|
||||
// so the server always sees the cancel after the connect (and deletes the token if
|
||||
// that connect saved it). The returned promise settles when that is done.
|
||||
let finished = null;
|
||||
let inflight = null; // the last connect sent
|
||||
function finish(result) {
|
||||
if (done) return finished || Promise.resolve(); // destroy() below re-enters through "closed"
|
||||
done = true;
|
||||
ses.cookies.removeListener("changed", onCookie);
|
||||
clearTimeout(timer);
|
||||
if (active && active.win === win) active = null;
|
||||
if (!win.isDestroyed()) win.destroy();
|
||||
resolve(result);
|
||||
if (result.ok || !inflight) return (finished = Promise.resolve()); // nothing sent: nothing to cancel
|
||||
finished = Promise.resolve(inflight).catch(() => {}).then(() => cancel(attemptId)).catch(() => {});
|
||||
pending.add(finished);
|
||||
finished.then(() => pending.delete(finished));
|
||||
return finished;
|
||||
}
|
||||
|
||||
async function attempt() {
|
||||
if (done) return;
|
||||
if (busy) { again = true; return; }
|
||||
busy = true;
|
||||
try {
|
||||
do {
|
||||
again = false;
|
||||
// Only this window's own session, and only Meta's Oculus cookie.
|
||||
const cookies = await ses.cookies.get({ name: COOKIE });
|
||||
const c = cookies.find((k) => underDomain(k.domain, ["oculus.com"]) && looksLikeUserToken(k.value));
|
||||
if (!c || done) continue;
|
||||
const key = fingerprint(c.value);
|
||||
if (tried.has(key)) continue;
|
||||
tried.add(key);
|
||||
let r;
|
||||
try {
|
||||
inflight = connect(c.value, attemptId);
|
||||
r = await inflight;
|
||||
} catch {
|
||||
lastError = "Frame Control's local server didn't answer.";
|
||||
continue;
|
||||
}
|
||||
if (done) return;
|
||||
const d = (r && r.data) || {};
|
||||
if (r && r.status === 200 && d.connected) {
|
||||
finish({ ok: true, connected: true, user: d.user && d.user.name ? { name: scrub(d.user.name, c.value).slice(0, 80) } : null,
|
||||
message: scrub(d.message || "Connected your Meta account (Experimental)", c.value) });
|
||||
return;
|
||||
}
|
||||
lastError = scrub(d.error || `HTTP ${r && r.status}`, c.value);
|
||||
} while (again && !done);
|
||||
} catch {
|
||||
// The session went away (the window closed); nothing to do.
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation stays on Meta's login sites. A link elsewhere opens in the system
|
||||
// browser; a redirect elsewhere is just stopped (it may carry sign-in data).
|
||||
// Every frame: the window itself stays on the list; frames inside it may load any
|
||||
// HTTPS page (captchas), and one going anywhere else is just stopped.
|
||||
wc.on("will-navigate", (e, target) => { if (!allowedUrl(target)) { e.preventDefault(); openOutside(target); } });
|
||||
// A stopped page change in the window itself says where it went (the host only), so
|
||||
// closing the stuck window explains why instead of "closed before you signed in".
|
||||
const stopped = (to) => {
|
||||
let host = "";
|
||||
try { host = new URL(String(to)).hostname; } catch { /* not an address */ }
|
||||
blocked = `Meta's sign-in went to ${scrub(host || "an address", null).slice(0, 80)}, which Frame Control doesn't allow.`;
|
||||
};
|
||||
wc.on("will-frame-navigate", (e) => {
|
||||
if (e.isMainFrame ? allowedUrl(e.url) : allowedSubframeUrl(e.url)) return;
|
||||
e.preventDefault();
|
||||
if (e.isMainFrame) stopped(e.url);
|
||||
});
|
||||
// Redirects fire for frames too: the same rule per frame (details on the event in
|
||||
// current Electron, positional arguments in older ones; unknown counts as the window).
|
||||
wc.on("will-redirect", (e, target, _inPlace, isMainFrame) => {
|
||||
const to = e && e.url !== undefined ? e.url : target;
|
||||
const main = e && e.isMainFrame !== undefined ? e.isMainFrame : isMainFrame !== false;
|
||||
if (main ? allowedUrl(to) : allowedSubframeUrl(to)) return;
|
||||
e.preventDefault();
|
||||
if (main) stopped(to);
|
||||
});
|
||||
// A page's beforeunload never keeps the window open.
|
||||
wc.on("will-prevent-unload", (e) => e.preventDefault());
|
||||
wc.setWindowOpenHandler(({ url: target }) => {
|
||||
if (allowedUrl(target)) wc.loadURL(target).catch(() => {});
|
||||
else openOutside(target);
|
||||
return { action: "deny" };
|
||||
});
|
||||
// Signed in: the cookie changes, or the window lands back on Meta's store site.
|
||||
ses.cookies.on("changed", onCookie);
|
||||
wc.on("did-navigate", (_e, target) => { blocked = null; if (onOculus(target)) attempt(); }); // it recovered
|
||||
wc.on("did-navigate-in-page", (_e, target) => { if (onOculus(target)) attempt(); });
|
||||
win.on("closed", () => finish({ ok: false, cancelled: true,
|
||||
error: lastError ? `The sign-in window closed before Frame Control could connect. ${lastError}`
|
||||
: blocked ? `The sign-in window was closed before you signed in. ${blocked}`
|
||||
: "The sign-in window was closed before you signed in." }));
|
||||
|
||||
active = { win, promise, finish };
|
||||
wc.loadURL(LOGIN_URL).catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Signs out of Meta in Frame Control: stops any sign-in under way (and has the server
|
||||
// refuse to save it), destroys the window, then clears everything
|
||||
// the sign-in session stored (cookies, storage, cache), so the next sign-in starts fresh.
|
||||
// One at a time (a second call joins the first), and no sign-in can start meanwhile.
|
||||
// It goes round until no window is open and no connect or cancel is left, so nothing
|
||||
// started before it can set a token after it (or after the Disconnect that follows).
|
||||
function signOut() {
|
||||
if (!signingOut) {
|
||||
signingOut = (async () => {
|
||||
for (;;) {
|
||||
if (active) {
|
||||
const { win, finish } = active;
|
||||
await finish({ ok: false, cancelled: true, error: "Signed out of Meta in Frame Control." });
|
||||
// Clear only once nothing can write to the session again.
|
||||
if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again.");
|
||||
} else if (pending.size) {
|
||||
// A connect under way settles and is cancelled before the caller (Disconnect)
|
||||
// deletes the account, so it can't be saved afterwards.
|
||||
await Promise.all([...pending]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const ses = session.fromPartition(PARTITION);
|
||||
await ses.clearStorageData();
|
||||
await ses.clearCache();
|
||||
if (ses.clearAuthCache) await ses.clearAuthCache();
|
||||
return true;
|
||||
})().finally(() => { signingOut = null; });
|
||||
}
|
||||
return signingOut;
|
||||
}
|
||||
|
||||
return { signIn, signOut, isOpen: () => !!active, isSigningOut: () => !!signingOut };
|
||||
}
|
||||
|
||||
module.exports = { createMetaSignIn, allowedUrl, allowedSubframeUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE, ALLOWED };
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.17",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+19
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.17",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -29,6 +29,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"afterPack": "build/check-resources.js",
|
||||
"directories": {
|
||||
"output": "dist",
|
||||
"buildResources": "build"
|
||||
@@ -38,6 +39,8 @@
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"preferences.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
@@ -49,6 +52,7 @@
|
||||
"*.py",
|
||||
"*.html",
|
||||
"*.js",
|
||||
"*.css",
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
@@ -64,7 +68,9 @@
|
||||
"from": "../scripts",
|
||||
"to": "scripts",
|
||||
"filter": [
|
||||
"*.sh"
|
||||
"*.sh",
|
||||
"frame_power_sample.py",
|
||||
"frame_power_report.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -81,7 +87,7 @@
|
||||
"to": "frame/openxr-compat",
|
||||
"filter": [
|
||||
"XrApiLayer_FRAME_compat.json",
|
||||
"prebuilt/**/*.so"
|
||||
"prebuilt/**/*.so.gz"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -194,7 +200,16 @@
|
||||
"zip"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}"
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/desktop/${os}-${arch}",
|
||||
"to": "desktop/bundle",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Client choices live outside the local server's changing URL/origin.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const KEYS = ["mvSeparate", "padUsed", "pcView"];
|
||||
const valid = value => value === "0" || value === "1";
|
||||
|
||||
function preferenceStore(file) {
|
||||
function read() {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return {};
|
||||
return Object.fromEntries(KEYS.filter(key => valid(saved[key])).map(key => [key, saved[key]]));
|
||||
} catch { return {}; }
|
||||
}
|
||||
function write(key, value) {
|
||||
if (!KEYS.includes(key) || !valid(value)) throw new Error("Invalid preference");
|
||||
const saved = { ...read(), [key]: value };
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = file + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(saved), { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
return true;
|
||||
}
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
module.exports = { preferenceStore };
|
||||
+15
-3
@@ -1,17 +1,23 @@
|
||||
// Lets the page read this computer's clipboard through Electron, so sending it
|
||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// Lets the page read and write this computer's clipboard through Electron, so sending
|
||||
// it to the Frame, and copying the Frame's back, needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||
// as a title without zipping it (the local server reads it from there).
|
||||
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
|
||||
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
|
||||
// It also receives frame-control://install links (docs/web-install.md): only
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
// And it passes update state both ways: see app/updater.js.
|
||||
// And it passes update state both ways: see app/updater.js. It opens the Sign in
|
||||
// with Meta window and signs out of it (app/meta-signin.js).
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
preferences: {
|
||||
read: () => ipcRenderer.invoke("preferences:read"),
|
||||
write: (key, value) => ipcRenderer.invoke("preferences:write", key, value),
|
||||
},
|
||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
writeClipboard: (text) => ipcRenderer.invoke("clipboard:write", text),
|
||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||
@@ -39,6 +45,12 @@ contextBridge.exposeInMainWorld("frameApp", {
|
||||
ipcRenderer.removeAllListeners("report:open");
|
||||
ipcRenderer.on("report:open", () => cb());
|
||||
},
|
||||
// Sign in with Meta (app/meta-signin.js): Meta's login page in its own window. The
|
||||
// answer is the server's (connected, display name), never the token.
|
||||
meta: {
|
||||
signIn: (acknowledged) => ipcRenderer.invoke("meta:signin", acknowledged === true),
|
||||
signOut: () => ipcRenderer.invoke("meta:signout"),
|
||||
},
|
||||
onInstallLink: (cb) => {
|
||||
ipcRenderer.removeAllListeners("install-link");
|
||||
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
// Run: node --test app/test/
|
||||
// "Sign in with Meta" (app/meta-signin.js) with fake Electron windows, sessions and cookies.
|
||||
const nodeTest = require("node:test");
|
||||
// A broken flow can leave a sign-in waiting forever; fail it instead of hanging.
|
||||
const test = (name, fn) => nodeTest(name, { timeout: 5000 }, fn);
|
||||
const assert = require("node:assert");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createMetaSignIn, allowedUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE } = require("../meta-signin");
|
||||
|
||||
const TOKEN = "OCAbcdefghijklmnopQRSTUVwxyz0123456789abcdEFGH";
|
||||
const OTHER = "OCzyxwvutsrqponmlkJIHGFEDcba9876543210zyxwVUTS";
|
||||
|
||||
class FakeCookies extends EventEmitter {
|
||||
constructor() { super(); this.store = []; this.reads = 0; }
|
||||
async get(filter) {
|
||||
this.reads++;
|
||||
return this.store.filter((c) => !filter.name || c.name === filter.name);
|
||||
}
|
||||
set(cookie) { // as the page would: store it, then tell listeners
|
||||
this.store = this.store.filter((c) => !(c.name === cookie.name && c.domain === cookie.domain)).concat(cookie);
|
||||
this.emit("changed", {}, cookie, "explicit", false);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSession {
|
||||
constructor(name, log) { this.name = name; this.log = log; this.cookies = new FakeCookies(); this.cleared = []; this.permission = null; }
|
||||
setPermissionRequestHandler(fn) { this.permission = fn; }
|
||||
setPermissionCheckHandler(fn) { this.permissionCheck = fn; }
|
||||
getUserAgent() { return "Mozilla/5.0 Chrome/140.0 frame-control/0.4.4 Electron/44.4.5 Safari/537.36"; }
|
||||
setUserAgent(ua) { this.ua = ua; }
|
||||
async clearStorageData(opts) { this.log.push("clear"); this.cleared.push(["storage", opts]); }
|
||||
async clearCache() { this.cleared.push(["cache"]); }
|
||||
async clearAuthCache() { this.cleared.push(["auth"]); }
|
||||
}
|
||||
|
||||
function fakes({ timeout } = {}) {
|
||||
const log = []; // the order things happened in
|
||||
const sessions = new Map();
|
||||
const defaultSession = new FakeSession("default", log);
|
||||
const session = {
|
||||
defaultSession,
|
||||
fromPartition(name) {
|
||||
if (!sessions.has(name)) sessions.set(name, new FakeSession(name, log));
|
||||
return sessions.get(name);
|
||||
},
|
||||
};
|
||||
const windows = [];
|
||||
class BrowserWindow extends EventEmitter {
|
||||
constructor(opts) {
|
||||
super();
|
||||
this.opts = opts;
|
||||
this.destroyed = false;
|
||||
this.veto = false; // a page whose beforeunload keeps the window open on close()
|
||||
this.focused = 0;
|
||||
const wc = new EventEmitter();
|
||||
wc.loaded = [];
|
||||
wc.loadURL = async (u) => { wc.loaded.push(u); };
|
||||
wc.setWindowOpenHandler = (fn) => { wc.openHandler = fn; };
|
||||
// Deliberately a different session: the code must read the partition's, not guess.
|
||||
wc.session = defaultSession;
|
||||
this.webContents = wc;
|
||||
windows.push(this);
|
||||
}
|
||||
isDestroyed() { return this.destroyed; }
|
||||
focus() { this.focused++; }
|
||||
close() { if (this.destroyed || this.veto) return; this.destroy(); }
|
||||
destroy() { if (this.destroyed) return; this.destroyed = true; log.push("destroyed"); this.emit("closed"); }
|
||||
}
|
||||
const opened = [];
|
||||
const shell = { openExternal: (u) => { opened.push(u); } };
|
||||
const calls = [], attempts = [], cancels = [];
|
||||
let answer = () => ({ status: 200, data: { connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" } });
|
||||
const connect = async (token, attempt) => { calls.push(token); attempts.push(attempt); log.push("connect"); return answer(token); };
|
||||
const cancel = async (attempt) => {
|
||||
cancels.push(attempt);
|
||||
log.push(`cancel (cookie listeners: ${session.fromPartition(PARTITION).cookies.listenerCount("changed")})`);
|
||||
};
|
||||
const flow = createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent: () => null, ...(timeout ? { timeout } : {}) });
|
||||
return { flow, session, sessions, windows, opened, calls, attempts, cancels, log, setAnswer: (fn) => { answer = fn; },
|
||||
meta: () => session.fromPartition(PARTITION) };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setImmediate(r));
|
||||
async function settle() { for (let i = 0; i < 10; i++) await tick(); }
|
||||
|
||||
function navigate(wc, event, target) {
|
||||
let prevented = false;
|
||||
wc.emit(event, { preventDefault: () => { prevented = true; } }, target);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test("only plain HTTPS addresses on Meta's login sites are allowed", () => {
|
||||
for (const ok of ["https://secure.oculus.com", "https://secure.oculus.com/my/profile", "https://auth.meta.com/login/",
|
||||
"https://www.meta.com/", "https://www.facebook.com/login.php", "https://oculus.com:443/",
|
||||
"https://www.instagram.com/accounts/login/"]) {
|
||||
assert.ok(allowedUrl(ok), ok);
|
||||
}
|
||||
for (const bad of ["http://secure.oculus.com/", "https://oculus.com.evil.example/", "https://evil-oculus.com/",
|
||||
"https://notmeta.com/", "https://example.com/", "javascript:alert(1)", "file:///etc/passwd",
|
||||
"https://secure.oculus.com:8443/", "https://user:pw@secure.oculus.com/", "https://evil.example/?u=https://www.meta.com",
|
||||
"data:text/html,hi", "not a url"]) {
|
||||
assert.ok(!allowedUrl(bad), bad);
|
||||
}
|
||||
});
|
||||
|
||||
test("the window is isolated: its own partition, no preload, no Node, sandboxed, on Meta's login page", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const [w] = f.windows;
|
||||
const wp = w.opts.webPreferences;
|
||||
assert.strictEqual(wp.partition, PARTITION);
|
||||
assert.strictEqual(PARTITION, "persist:meta-signin");
|
||||
assert.strictEqual(wp.contextIsolation, true);
|
||||
assert.strictEqual(wp.nodeIntegration, false);
|
||||
assert.strictEqual(wp.sandbox, true);
|
||||
assert.strictEqual(wp.preload, undefined);
|
||||
assert.strictEqual(wp.devTools, false);
|
||||
assert.deepStrictEqual(w.webContents.loaded, [LOGIN_URL]);
|
||||
assert.strictEqual(LOGIN_URL, "https://secure.oculus.com");
|
||||
// Permission prompts (camera, notifications...) are refused.
|
||||
let granted = null;
|
||||
f.meta().permission(null, "media", (v) => { granted = v; });
|
||||
assert.strictEqual(granted, false);
|
||||
assert.strictEqual(f.meta().permissionCheck(null, "notifications", "https://secure.oculus.com"), false);
|
||||
assert.ok(!/Electron|frame-control/.test(f.meta().ua));
|
||||
w.close();
|
||||
});
|
||||
|
||||
test("navigation stays on Meta's sites; other links open in the system browser", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
assert.strictEqual(navigate(wc, "will-navigate", "https://auth.meta.com/login/"), false);
|
||||
assert.strictEqual(navigate(wc, "will-navigate", "https://www.facebook.com/login.php"), false);
|
||||
assert.strictEqual(navigate(wc, "will-navigate", "https://example.com/help"), true);
|
||||
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
|
||||
// A redirect elsewhere is stopped and not opened anywhere (it may carry sign-in data).
|
||||
assert.strictEqual(navigate(wc, "will-redirect", "https://evil.example/cb?x=1"), true);
|
||||
assert.strictEqual(navigate(wc, "will-redirect", "https://auth.meta.com/next"), false);
|
||||
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
|
||||
// New windows: never created. Meta's own load in this window; others go outside; non-web schemes nowhere.
|
||||
assert.deepStrictEqual(wc.openHandler({ url: "https://www.meta.com/legal/terms/" }), { action: "deny" });
|
||||
assert.deepStrictEqual(wc.openHandler({ url: "https://example.org/" }), { action: "deny" });
|
||||
assert.deepStrictEqual(wc.openHandler({ url: "file:///etc/passwd" }), { action: "deny" });
|
||||
await settle();
|
||||
assert.deepStrictEqual(wc.loaded, [LOGIN_URL, "https://www.meta.com/legal/terms/"]);
|
||||
assert.deepStrictEqual(f.opened, ["https://example.com/help", "https://example.org/"]);
|
||||
f.windows[0].close();
|
||||
});
|
||||
|
||||
test("the cookie is read only from the sign-in partition, only for Meta's Oculus domain", async () => {
|
||||
const f = fakes();
|
||||
// The same cookie elsewhere: the default session and another partition. Never read.
|
||||
f.session.defaultSession.cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
f.session.fromPartition("persist:other").cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
const done = f.flow.signIn();
|
||||
const w = f.windows[0];
|
||||
// A cookie with the right name on another site, and a signed-out value: ignored.
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: OTHER });
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: "OC123456789012345" });
|
||||
w.webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
const r = await done;
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.strictEqual(f.session.defaultSession.cookies.reads, 0);
|
||||
assert.strictEqual(f.session.fromPartition("persist:other").cookies.reads, 0);
|
||||
assert.ok(f.sessions.get(PARTITION).cookies.reads > 0);
|
||||
});
|
||||
|
||||
test("landing on the logged-in page picks up a cookie set earlier", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.store.push({ name: COOKIE, domain: "secure.oculus.com", value: TOKEN }); // no change event
|
||||
f.windows[0].webContents.emit("did-navigate", {}, "https://auth.meta.com/login/"); // not Meta's store site yet
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
|
||||
assert.strictEqual((await done).ok, true);
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
});
|
||||
|
||||
test("after a connect the window closes, and the page gets the answer, never the token", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
const r = await done;
|
||||
assert.ok(f.windows[0].isDestroyed());
|
||||
assert.ok(!f.flow.isOpen());
|
||||
assert.deepStrictEqual(r, { ok: true, connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" });
|
||||
assert.ok(!JSON.stringify(r).includes(TOKEN));
|
||||
// The cookie listener is gone: a later change sends nothing.
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
});
|
||||
|
||||
test("a token the server refuses keeps the window open, is sent once, and the next one connects", async () => {
|
||||
const f = fakes();
|
||||
f.setAnswer((t) => t === TOKEN ? { status: 400, data: { error: "Meta didn't accept this token." } }
|
||||
: { status: 200, data: { connected: true, user: null } });
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile"); // same value again
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
assert.ok(!f.windows[0].isDestroyed());
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
const r = await done;
|
||||
assert.deepStrictEqual(f.calls, [TOKEN, OTHER]);
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.strictEqual(r.user, null);
|
||||
});
|
||||
|
||||
test("the token is never logged, and an error that echoed it is scrubbed", async () => {
|
||||
const seen = [];
|
||||
const names = ["log", "info", "warn", "error", "debug", "trace"];
|
||||
const saved = Object.fromEntries(names.map((n) => [n, console[n]]));
|
||||
const writes = [process.stdout.write, process.stderr.write];
|
||||
for (const n of names) console[n] = (...a) => { seen.push(a.map(String).join(" ")); };
|
||||
process.stdout.write = (c, ...rest) => { seen.push(String(c)); return writes[0].call(process.stdout, c, ...rest); };
|
||||
process.stderr.write = (c, ...rest) => { seen.push(String(c)); return writes[1].call(process.stderr, c, ...rest); };
|
||||
let r;
|
||||
try {
|
||||
const f = fakes();
|
||||
f.setAnswer((t) => ({ status: 500, data: { error: `RuntimeError: bad ${t} and ${t.slice(0, 12)}` } }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close(); // the user gives up
|
||||
r = await done;
|
||||
} finally {
|
||||
for (const n of names) console[n] = saved[n];
|
||||
[process.stdout.write, process.stderr.write] = writes;
|
||||
}
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.strictEqual(r.cancelled, true);
|
||||
assert.ok(!r.error.includes(TOKEN) && !r.error.includes(TOKEN.slice(0, 12)), r.error);
|
||||
assert.match(r.error, /<redacted>/);
|
||||
assert.ok(!seen.some((s) => s.includes(TOKEN.slice(0, 12))), "token in output");
|
||||
assert.strictEqual(scrub(`x FRL${"a".repeat(30)} y`), "x <redacted> y");
|
||||
});
|
||||
|
||||
test("closing the window before signing in reports it, and a second click reuses the open window", async () => {
|
||||
const f = fakes();
|
||||
const a = f.flow.signIn();
|
||||
const b = f.flow.signIn();
|
||||
assert.strictEqual(a, b);
|
||||
assert.strictEqual(f.windows.length, 1);
|
||||
assert.strictEqual(f.windows[0].focused, 1);
|
||||
f.windows[0].close();
|
||||
const r = await a;
|
||||
assert.deepStrictEqual(r, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test("disconnect's sign-out closes the window and clears only the sign-in partition", async () => {
|
||||
const f = fakes();
|
||||
f.session.fromPartition("persist:other");
|
||||
const pending = f.flow.signIn();
|
||||
await f.flow.signOut();
|
||||
const r = await pending;
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.ok(f.windows[0].isDestroyed());
|
||||
assert.deepStrictEqual(f.meta().cleared, [["storage", undefined], ["cache"], ["auth"]]);
|
||||
assert.deepStrictEqual(f.session.defaultSession.cleared, []);
|
||||
assert.deepStrictEqual(f.session.fromPartition("persist:other").cleared, []);
|
||||
// The next sign-in starts with a new window.
|
||||
f.flow.signIn();
|
||||
assert.strictEqual(f.windows.length, 2);
|
||||
f.windows[1].close();
|
||||
});
|
||||
|
||||
test("the first look at a cookie value matches the server's token rules", () => {
|
||||
assert.ok(looksLikeUserToken(TOKEN));
|
||||
assert.ok(looksLikeUserToken("FRL" + "aB3".repeat(10)));
|
||||
for (const bad of [undefined, "", "OC123456789012345", "OC|123|abc", "OCabc%2Fdef" + "x".repeat(20), "OCshort",
|
||||
"XY" + "a".repeat(30), "OC" + "A".repeat(30)]) {
|
||||
assert.ok(!looksLikeUserToken(bad), String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
function frameNav(wc, url, isMainFrame) {
|
||||
let prevented = false;
|
||||
wc.emit("will-frame-navigate", { url, isMainFrame, preventDefault: () => { prevented = true; } });
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test("the window stays on Meta's sites; frames inside it may load any HTTPS page, nothing else", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
// Frames: captcha and CDN frames from other sites load; other schemes are stopped.
|
||||
for (const ok of ["https://client-api.arkoselabs.com/fc/gc/", "https://newassets.hcaptcha.com/captcha/v1/x",
|
||||
"https://www.google.com/recaptcha/api2/anchor", "https://static.xx.fbcdn.net/x",
|
||||
"https://www.facebook.com/plugin", "about:blank", "about:srcdoc"]) {
|
||||
assert.strictEqual(frameNav(wc, ok, false), false, ok);
|
||||
}
|
||||
for (const bad of ["http://www.facebook.com/plugin", "data:text/html,<script>x</script>", "blob:https://evil.example/1",
|
||||
"javascript:alert(1)", "file:///etc/passwd", "ftp://example.com/", "https://u:p@evil.example/",
|
||||
"chrome://settings", "not a url"]) {
|
||||
assert.strictEqual(frameNav(wc, bad, false), true, bad);
|
||||
}
|
||||
// The window itself: Meta's sites only.
|
||||
assert.strictEqual(frameNav(wc, "about:blank", true), true);
|
||||
assert.strictEqual(frameNav(wc, "https://evil.example/", true), true);
|
||||
assert.strictEqual(frameNav(wc, "https://client-api.arkoselabs.com/", true), true);
|
||||
assert.strictEqual(frameNav(wc, "https://auth.meta.com/login/", true), false);
|
||||
assert.deepStrictEqual(f.opened, []); // stopped, never opened outside
|
||||
f.windows[0].destroy();
|
||||
});
|
||||
|
||||
function redirect(wc, url, isMainFrame, style) {
|
||||
let prevented = false;
|
||||
const e = { preventDefault: () => { prevented = true; } };
|
||||
if (style === "details") wc.emit("will-redirect", Object.assign(e, { url, isMainFrame }));
|
||||
else wc.emit("will-redirect", e, url, false, isMainFrame);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test("redirects follow the same rule per frame", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
for (const style of ["details", "positional"]) {
|
||||
// A captcha frame redirected within HTTPS loads; a frame redirected to data: or http: doesn't.
|
||||
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", false, style), false, style);
|
||||
assert.strictEqual(redirect(wc, "https://newassets.hcaptcha.com/x", false, style), false, style);
|
||||
assert.strictEqual(redirect(wc, "data:text/html,<b>x</b>", false, style), true, style);
|
||||
assert.strictEqual(redirect(wc, "http://www.facebook.com/x", false, style), true, style);
|
||||
assert.strictEqual(redirect(wc, "javascript:alert(1)", false, style), true, style);
|
||||
// The window: Meta's sites only.
|
||||
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", true, style), true, style);
|
||||
assert.strictEqual(redirect(wc, "https://auth.meta.com/next", true, style), false, style);
|
||||
}
|
||||
// Without frame information it is treated as the window.
|
||||
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/", undefined, "positional"), true);
|
||||
assert.deepStrictEqual(f.opened, []);
|
||||
f.windows[0].destroy();
|
||||
});
|
||||
|
||||
test("a redirect the window stops is named when the window is closed", async () => {
|
||||
const f = fakes();
|
||||
const p = f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
assert.strictEqual(redirect(wc, "https://www.instagram.com/x?next=1", true, "details"), false); // Meta's login goes here
|
||||
assert.strictEqual(redirect(wc, "https://login.evil.example/cb?code=SECRET", true, "details"), true);
|
||||
f.windows[0].close();
|
||||
const r = await p;
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.match(r.error, /went to login\.evil\.example, which Frame Control doesn't allow/);
|
||||
assert.doesNotMatch(r.error, /SECRET|code=/);
|
||||
});
|
||||
|
||||
test("a stopped redirect is forgotten once the window loads a page again", async () => {
|
||||
const f = fakes();
|
||||
const p = f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
assert.strictEqual(redirect(wc, "https://login.evil.example/cb", true, "details"), true);
|
||||
wc.emit("did-navigate", {}, "https://auth.meta.com/login/"); // the user carried on
|
||||
f.windows[0].close();
|
||||
assert.deepStrictEqual(await p, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
|
||||
});
|
||||
|
||||
test("a page's beforeunload can't keep the window open", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
const w = f.windows[0];
|
||||
let prevented = false;
|
||||
w.webContents.emit("will-prevent-unload", { preventDefault: () => { prevented = true; } });
|
||||
assert.ok(prevented);
|
||||
// Even if close() would be vetoed, finishing destroys the window.
|
||||
w.veto = true;
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
assert.strictEqual((await done).ok, true);
|
||||
assert.ok(w.isDestroyed());
|
||||
});
|
||||
|
||||
test("closing during a connect: the cancel goes to the server only after that connect settles", async () => {
|
||||
for (const outcome of ["saved", "refused", "failed"]) {
|
||||
const f = fakes();
|
||||
let release, fail;
|
||||
f.setAnswer(() => new Promise((r, j) => { release = r; fail = j; }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
f.windows[0].close(); // the user gives up while the server and Meta are answering
|
||||
const r = await done; // the page hears at once
|
||||
assert.strictEqual(r.ok, false);
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.cancels, [], outcome); // not before the connect has settled
|
||||
if (outcome === "saved") release({ status: 200, data: { connected: true } });
|
||||
else if (outcome === "refused") release({ status: 400, data: { error: "no" } });
|
||||
else fail(new Error("socket hang up"));
|
||||
await settle();
|
||||
// The same attempt id the connect carried, after it settled and after cookie intake stopped.
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]], outcome);
|
||||
assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/);
|
||||
assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"], outcome);
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
}
|
||||
});
|
||||
|
||||
test("closing before any connect needs no cancel", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: TOKEN }); // nothing usable
|
||||
await settle();
|
||||
f.windows[0].close();
|
||||
assert.strictEqual((await done).ok, false);
|
||||
await f.flow.signOut();
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
});
|
||||
|
||||
test("a timeout during a connect cancels after it settles", async () => {
|
||||
const f = fakes({ timeout: 50 });
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; })); // Meta is slow
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
const r = await done;
|
||||
assert.match(r.error, /timed out/);
|
||||
assert.ok(f.windows[0].isDestroyed());
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
release({ status: 400, data: { error: "no" } });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
});
|
||||
|
||||
test("disconnect during a connect waits for it, cancels it, then clears, before the page deletes the account", async () => {
|
||||
const f = fakes();
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.log.length = 0;
|
||||
let signedOut = false;
|
||||
const out = f.flow.signOut().then(() => { signedOut = true; }); // the page's Disconnect, step one
|
||||
assert.strictEqual((await done).ok, false);
|
||||
await settle();
|
||||
assert.strictEqual(signedOut, false); // still waiting for the connect
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
release({ status: 200, data: { connected: true } }); // it saved, just now
|
||||
await out;
|
||||
// Only now would the page post its disconnect: the cancel (which deletes what that
|
||||
// connect saved) has reached the server, and the session is cleared.
|
||||
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
});
|
||||
|
||||
test("a later disconnect still waits for a closed window's connect", async () => {
|
||||
const f = fakes();
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close();
|
||||
await done;
|
||||
let signedOut = false;
|
||||
const out = f.flow.signOut().then(() => { signedOut = true; });
|
||||
await settle();
|
||||
assert.strictEqual(signedOut, false);
|
||||
release({ status: 400, data: { error: "no" } });
|
||||
await out;
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
});
|
||||
|
||||
test("nothing sent means nothing to cancel; sign-out destroys the window, cancels, then clears", async () => {
|
||||
const f = fakes();
|
||||
// Nothing sent: closing cancels nothing.
|
||||
const a = f.flow.signIn();
|
||||
f.windows[0].close();
|
||||
await a;
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
// Something sent, then signed out: the attempt is cancelled before the session is cleared.
|
||||
f.setAnswer(() => ({ status: 400, data: { error: "Meta didn't accept this token." } }));
|
||||
const b = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[1].veto = true; // and its page tries to stay open
|
||||
f.log.length = 0;
|
||||
await f.flow.signOut();
|
||||
assert.strictEqual((await b).ok, false);
|
||||
assert.ok(f.windows[1].isDestroyed());
|
||||
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
// After sign-out a new cookie in the partition sends nothing.
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
});
|
||||
|
||||
test("each sign-in has its own attempt id", async () => {
|
||||
const f = fakes();
|
||||
f.setAnswer(() => ({ status: 400, data: { error: "no" } }));
|
||||
const a = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close();
|
||||
await a;
|
||||
const b = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
f.windows[1].close();
|
||||
await b;
|
||||
await settle();
|
||||
assert.strictEqual(f.attempts.length, 2);
|
||||
assert.notStrictEqual(f.attempts[0], f.attempts[1]);
|
||||
assert.deepStrictEqual(f.cancels, f.attempts);
|
||||
});
|
||||
|
||||
test("no sign-in can start while a sign-out waits on an earlier connect", async () => {
|
||||
const f = fakes();
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; }));
|
||||
const first = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
const out = f.flow.signOut(); // Disconnect pressed while that connect is under way
|
||||
assert.strictEqual((await first).ok, false);
|
||||
assert.ok(f.flow.isSigningOut());
|
||||
// The page answered, so the user tries again at once: refused, no window, nothing sent.
|
||||
const second = await f.flow.signIn();
|
||||
assert.deepStrictEqual(second, { ok: false, error: "Frame Control is signing out of Meta; try again in a moment." });
|
||||
assert.strictEqual(f.windows.length, 1);
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
// A second sign-out joins the first.
|
||||
assert.strictEqual(f.flow.signOut(), out);
|
||||
release({ status: 200, data: { connected: true } });
|
||||
await out;
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
assert.strictEqual(f.meta().cleared.filter(([k]) => k === "storage").length, 1);
|
||||
assert.ok(!f.flow.isSigningOut());
|
||||
// Once it is done, signing in works again.
|
||||
f.setAnswer(() => ({ status: 200, data: { connected: true } }));
|
||||
const third = f.flow.signIn();
|
||||
assert.strictEqual(f.windows.length, 2);
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
assert.strictEqual((await third).ok, true);
|
||||
});
|
||||
|
||||
test("sign-out goes round until no window and no connect or cancel is left", async () => {
|
||||
const f = fakes();
|
||||
let releaseA;
|
||||
f.setAnswer(() => new Promise((r) => { releaseA = r; }));
|
||||
const a = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close(); // window A closed; its cancel waits on its connect
|
||||
await a;
|
||||
// Window B is open, with its own connect under way, when sign-out starts.
|
||||
let releaseB;
|
||||
f.setAnswer(() => new Promise((r) => { releaseB = r; }));
|
||||
const b = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
let finished = false;
|
||||
const out = f.flow.signOut().then(() => { finished = true; });
|
||||
await b;
|
||||
releaseB({ status: 400, data: { error: "no" } });
|
||||
await settle();
|
||||
assert.strictEqual(finished, false); // A's connect is still under way
|
||||
releaseA({ status: 200, data: { connected: true } });
|
||||
await out;
|
||||
assert.deepStrictEqual([...f.cancels].sort(), [...f.attempts].sort());
|
||||
assert.strictEqual(f.attempts.length, 2);
|
||||
assert.strictEqual(f.log[f.log.length - 1], "clear");
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { preferenceStore } = require("../preferences");
|
||||
|
||||
function fixture(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-preferences-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return path.join(dir, "client-preferences.json");
|
||||
}
|
||||
|
||||
test("desktop packaging includes every local application module dependency", () => {
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const packaged = new Set(require("../package.json").build.files);
|
||||
for (const file of ["main.js", "preload.js"]) {
|
||||
const source = fs.readFileSync(path.join(root, file), "utf8");
|
||||
for (const match of source.matchAll(/require\(["'](\.\/[^"']+)["']\)/g)) {
|
||||
const target = match[1].slice(2) + ".js";
|
||||
assert.ok(packaged.has(target), `${file} needs packaged ${target}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("client choices survive a new native session and local server origin", t => {
|
||||
const file = fixture(t);
|
||||
const first = preferenceStore(file);
|
||||
assert.deepEqual(first.read(), {});
|
||||
first.write("mvSeparate", "0");
|
||||
first.write("padUsed", "1");
|
||||
// A new process/session reads the same client store, regardless of its URL.
|
||||
const second = preferenceStore(file);
|
||||
assert.deepEqual(second.read(), { mvSeparate: "0", padUsed: "1" });
|
||||
second.write("mvSeparate", "1");
|
||||
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
|
||||
});
|
||||
|
||||
test("only the non-sensitive binary choices can enter the native store", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
for (const [key, value] of [["token", "secret"], ["__proto__", "1"], ["padUsed", true], ["mvSeparate", "yes"]]) {
|
||||
assert.throws(() => store.write(key, value), /Invalid preference/);
|
||||
}
|
||||
assert.equal(fs.existsSync(file), false);
|
||||
fs.writeFileSync(file, JSON.stringify({ token: "discard", mvSeparate: "0", padUsed: 1 }));
|
||||
assert.deepEqual(store.read(), { mvSeparate: "0" });
|
||||
store.write("padUsed", "1");
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { mvSeparate: "0", padUsed: "1" });
|
||||
});
|
||||
|
||||
test("a missing or damaged preference file retains usable defaults", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
assert.deepEqual(store.read(), {});
|
||||
for (const bad of ["{broken", "null", "[]", '"text"']) {
|
||||
fs.writeFileSync(file, bad);
|
||||
assert.deepEqual(store.read(), {});
|
||||
}
|
||||
store.write("padUsed", "1");
|
||||
assert.deepEqual(store.read(), { padUsed: "1" });
|
||||
});
|
||||
|
||||
test("the PC in the headset opt-in is remembered and off unless saved", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
assert.equal(store.read().pcView, undefined);
|
||||
store.write("pcView", "1");
|
||||
assert.deepEqual(preferenceStore(file).read(), { pcView: "1" });
|
||||
assert.throws(() => store.write("pcView", "on"), /Invalid preference/);
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"label": "pc-agent-frame-arm64-hairpin",
|
||||
"date": "2026-09-28T22:36:19",
|
||||
"commit": "c5fc552+dirty",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "separate",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"usb": false,
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 10.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
|
||||
"source": "",
|
||||
"pc_host": true,
|
||||
"headset": "0:03.056203 [Info] - 1 - leaving standby",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 351,
|
||||
"frames_drawn": 351,
|
||||
"frames_shown": 324,
|
||||
"duration_s": 10.1,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.5,
|
||||
"n": 351
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.3,
|
||||
"p99": 0.4,
|
||||
"n": 351
|
||||
},
|
||||
"encode": {
|
||||
"p50": 2.0,
|
||||
"p95": 2.9,
|
||||
"p99": 3.5,
|
||||
"n": 351
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.4,
|
||||
"p99": 0.6,
|
||||
"n": 351
|
||||
},
|
||||
"network": {
|
||||
"p50": 11.6,
|
||||
"p95": 75.7,
|
||||
"p99": 130.1,
|
||||
"n": 351
|
||||
},
|
||||
"decode": {
|
||||
"p50": 0.7,
|
||||
"p95": 2.1,
|
||||
"p99": 4.1,
|
||||
"n": 351
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.4,
|
||||
"p95": 0.8,
|
||||
"p99": 1.1,
|
||||
"n": 351
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.8,
|
||||
"p95": 18.1,
|
||||
"p99": 24.9,
|
||||
"n": 324
|
||||
},
|
||||
"content": {
|
||||
"p50": 15.1,
|
||||
"p95": 80.3,
|
||||
"p99": 134.2,
|
||||
"n": 351
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 21.0,
|
||||
"p95": 71.8,
|
||||
"p99": 146.3,
|
||||
"n": 324
|
||||
}
|
||||
},
|
||||
"fps": 34.8,
|
||||
"fps_shown": 32.0,
|
||||
"late_pct": 53.41,
|
||||
"stall_max": 226.8,
|
||||
"stalls_over_100ms": 7,
|
||||
"mbps": 0.26,
|
||||
"keyframes": 6,
|
||||
"size": "1280x720",
|
||||
"captured": 607,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 43.3,
|
||||
"p95": 98.4,
|
||||
"p99": 110.1,
|
||||
"n": 18
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 54.7,
|
||||
"p95": 98.9,
|
||||
"p99": 114.0,
|
||||
"n": 18
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 17.1,
|
||||
"p95": 48.3,
|
||||
"p99": 53.1,
|
||||
"n": 18
|
||||
},
|
||||
"mac": {
|
||||
"p50": 12.5,
|
||||
"p95": 32.9,
|
||||
"p99": 35.7,
|
||||
"n": 18
|
||||
},
|
||||
"back": {
|
||||
"p50": 16.8,
|
||||
"p95": 50.0,
|
||||
"p99": 55.9,
|
||||
"n": 18
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 18,
|
||||
"sent": 18,
|
||||
"seen": 18
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 38,
|
||||
"mbps": 0.3,
|
||||
"content_p50": 14.8,
|
||||
"content_p95": 25.3,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 33,
|
||||
"mbps": 0.24,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 103.5,
|
||||
"bitrate": 3845448,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 39,
|
||||
"mbps": 0.28,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 29.9,
|
||||
"bitrate": 5283790,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 25,
|
||||
"mbps": 0.21,
|
||||
"content_p50": 17.8,
|
||||
"content_p95": 175.9,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 34,
|
||||
"mbps": 0.22,
|
||||
"content_p50": 15.4,
|
||||
"content_p95": 69.8,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 38,
|
||||
"mbps": 0.29,
|
||||
"content_p50": 17.7,
|
||||
"content_p95": 68.7,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 38,
|
||||
"mbps": 0.26,
|
||||
"content_p50": 15.0,
|
||||
"content_p95": 18.2,
|
||||
"bitrate": 3091280,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 30,
|
||||
"mbps": 0.22,
|
||||
"content_p50": 19.2,
|
||||
"content_p95": 111.0,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 35,
|
||||
"mbps": 0.26,
|
||||
"content_p50": 13.4,
|
||||
"content_p95": 102.6,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 38,
|
||||
"mbps": 0.28,
|
||||
"content_p50": 12.3,
|
||||
"content_p95": 20.9,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 3,
|
||||
"mbps": 0.02,
|
||||
"content_p50": 20.9,
|
||||
"content_p95": 24.7,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 15.1,
|
||||
"content_p95": 80.3,
|
||||
"input_p50": 43.3,
|
||||
"input_p95": 98.4,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "acceptable",
|
||||
"input_p50": "local",
|
||||
"input_p95": "acceptable",
|
||||
"fps": "bad",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "acceptable"
|
||||
},
|
||||
"controller": {
|
||||
"target": 3442081,
|
||||
"ceiling": 5529600,
|
||||
"tier": 0,
|
||||
"fps": 60,
|
||||
"scale": 1.0,
|
||||
"baseRtt": 13.334,
|
||||
"inFlight": 1,
|
||||
"slack": 71.738,
|
||||
"adapt": true
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"t": 3.77,
|
||||
"e": "target 2764800 bit/s; tier 0"
|
||||
},
|
||||
{
|
||||
"t": 7.42,
|
||||
"e": "target 1725204 bit/s; tier 0"
|
||||
}
|
||||
],
|
||||
"source_fps": 60.1,
|
||||
"cpu": {
|
||||
"host": "not sampled"
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
|
||||
"show_s": 1.88,
|
||||
"panel": "valve.steam.desktopgame.2001932457",
|
||||
"src": "test",
|
||||
"route": "network"
|
||||
}
|
||||
],
|
||||
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"label": "pc-agent-frame-arm64-final",
|
||||
"date": "2026-09-28T22:55:27",
|
||||
"commit": "cd20243",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "native",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"usb": false,
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 10.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
|
||||
"source": "",
|
||||
"pc_host": true,
|
||||
"headset": "0:03.056203 [Info] - 1 - leaving standby",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 400,
|
||||
"frames_drawn": 400,
|
||||
"frames_shown": 339,
|
||||
"duration_s": 10.2,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.1,
|
||||
"p99": 0.2,
|
||||
"n": 400
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.4,
|
||||
"n": 400
|
||||
},
|
||||
"encode": {
|
||||
"p50": 2.0,
|
||||
"p95": 2.7,
|
||||
"p99": 3.1,
|
||||
"n": 400
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.3,
|
||||
"p99": 0.4,
|
||||
"n": 400
|
||||
},
|
||||
"network": {
|
||||
"p50": 11.8,
|
||||
"p95": 25.2,
|
||||
"p99": 37.8,
|
||||
"n": 400
|
||||
},
|
||||
"decode": {
|
||||
"p50": 0.7,
|
||||
"p95": 1.5,
|
||||
"p99": 2.0,
|
||||
"n": 400
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.3,
|
||||
"p95": 0.7,
|
||||
"p99": 1.0,
|
||||
"n": 400
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.8,
|
||||
"p95": 19.3,
|
||||
"p99": 26.8,
|
||||
"n": 339
|
||||
},
|
||||
"content": {
|
||||
"p50": 15.1,
|
||||
"p95": 28.6,
|
||||
"p99": 41.3,
|
||||
"n": 400
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 17.6,
|
||||
"p95": 39.0,
|
||||
"p99": 47.2,
|
||||
"n": 339
|
||||
}
|
||||
},
|
||||
"fps": 39.3,
|
||||
"fps_shown": 33.4,
|
||||
"late_pct": 50.25,
|
||||
"stall_max": 69.3,
|
||||
"stalls_over_100ms": 0,
|
||||
"mbps": 0.35,
|
||||
"keyframes": 6,
|
||||
"size": "1280x720",
|
||||
"captured": 612,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 76.6,
|
||||
"p95": 98.2,
|
||||
"p99": 127.8,
|
||||
"n": 19
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 80.5,
|
||||
"p95": 107.8,
|
||||
"p99": 128.1,
|
||||
"n": 16
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 36.5,
|
||||
"p95": 57.6,
|
||||
"p99": 83.2,
|
||||
"n": 19
|
||||
},
|
||||
"mac": {
|
||||
"p50": 14.6,
|
||||
"p95": 31.3,
|
||||
"p99": 31.7,
|
||||
"n": 19
|
||||
},
|
||||
"back": {
|
||||
"p50": 15.1,
|
||||
"p95": 32.8,
|
||||
"p99": 36.2,
|
||||
"n": 19
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 19,
|
||||
"sent": 19,
|
||||
"seen": 19
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 40,
|
||||
"mbps": 0.34,
|
||||
"content_p50": 11.7,
|
||||
"content_p95": 27.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 11.9,
|
||||
"content_p95": 28.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 41,
|
||||
"mbps": 0.35,
|
||||
"content_p50": 11.4,
|
||||
"content_p95": 16.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 12.7,
|
||||
"content_p95": 28.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 38,
|
||||
"mbps": 0.33,
|
||||
"content_p50": 15.3,
|
||||
"content_p95": 30.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 16.7,
|
||||
"content_p95": 28.5,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 41,
|
||||
"mbps": 0.38,
|
||||
"content_p50": 15.9,
|
||||
"content_p95": 29.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 41,
|
||||
"mbps": 0.31,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 28.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 38,
|
||||
"mbps": 0.38,
|
||||
"content_p50": 15.5,
|
||||
"content_p95": 25.9,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 38,
|
||||
"mbps": 0.29,
|
||||
"content_p50": 16.7,
|
||||
"content_p95": 28.8,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 6,
|
||||
"mbps": 0.03,
|
||||
"content_p50": 17.6,
|
||||
"content_p95": 23.1,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 15.1,
|
||||
"content_p95": 28.6,
|
||||
"input_p50": 76.6,
|
||||
"input_p95": 98.2,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"input_p50": "acceptable",
|
||||
"input_p95": "acceptable",
|
||||
"fps": "bad",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "local"
|
||||
},
|
||||
"controller": {
|
||||
"target": 5529600,
|
||||
"ceiling": 5529600,
|
||||
"tier": 0,
|
||||
"fps": 60,
|
||||
"scale": 1.0,
|
||||
"baseRtt": 12.332,
|
||||
"inFlight": 0,
|
||||
"slack": 74.12,
|
||||
"adapt": true
|
||||
},
|
||||
"events": [],
|
||||
"source_fps": 60.0,
|
||||
"cpu": {
|
||||
"host": "not sampled"
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
|
||||
"show_s": 1.75,
|
||||
"panel": "valve.steam.desktopgame.2001932457",
|
||||
"src": "test",
|
||||
"route": "network"
|
||||
}
|
||||
],
|
||||
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the common controller for the Python PC host (a C11 compiler needed)."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
root = Path(__file__).resolve().parent
|
||||
suffix = '.dll' if sys.platform == 'win32' else '.dylib' if sys.platform == 'darwin' else '.so'
|
||||
out = root / ('controller' + suffix)
|
||||
if sys.platform == 'win32' and os.environ.get('CC', 'cl') == 'cl':
|
||||
cmd = ['cl', '/nologo', '/O2', '/LD', '/std:c11', str(root / 'controller.c'), '/link', '/OUT:' + str(out)]
|
||||
else:
|
||||
cmd = [os.environ.get('CC', 'cc'), '-O2', '-std=c11', '-shared', '-fPIC', str(root / 'controller.c'), '-o', str(out)]
|
||||
subprocess.run(cmd, cwd=root, check=True)
|
||||
print(out)
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build our native PC adapter and bundle its ordinary shared libraries.
|
||||
|
||||
Run on the target architecture after installing GStreamer development packages.
|
||||
No GStreamer executable or desktop streaming application is shipped or invoked.
|
||||
Linux libc, display/GPU drivers and the portal service remain platform pieces.
|
||||
|
||||
x264 is GPL, so the bundle the Windows installer ships leaves it out: Windows
|
||||
encodes with Media Foundation. --with-x264 adds it for CI's headless protocol
|
||||
tests, which have no GPU or desktop to encode with.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def output(*args):
|
||||
return subprocess.check_output(args, text=True).strip()
|
||||
|
||||
|
||||
def build(destination, with_x264=False):
|
||||
win = sys.platform == 'win32'
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
modules = ['gstreamer-1.0', 'gstreamer-app-1.0', 'gstreamer-video-1.0']
|
||||
if not win:
|
||||
modules += ['gio-unix-2.0']
|
||||
import shlex
|
||||
flags = shlex.split(output('pkg-config', '--cflags', '--libs', *modules))
|
||||
lib = destination / ('pc-host.dll' if win else 'pc-host.so')
|
||||
cmd = [os.environ.get('CC', 'gcc' if win else 'cc'), '-std=c11', '-O2', '-shared', '-Wall', '-Wextra']
|
||||
if not win:
|
||||
cmd += ['-fPIC', '-Wl,-rpath,$ORIGIN/lib']
|
||||
subprocess.run(cmd + [str(ROOT / f) for f in ('controller.c', 'capture.c', 'portal.c')] + flags + ['-o', str(lib)], check=True)
|
||||
prefix = Path(output('pkg-config', '--variable=prefix', 'gstreamer-1.0'))
|
||||
plugin_dir = Path(output('pkg-config', '--variable=pluginsdir', 'gstreamer-1.0'))
|
||||
plugins_out, libs_out = destination / 'lib' / 'gstreamer-1.0', destination / ('bin' if win else 'lib')
|
||||
plugins_out.mkdir(parents=True, exist_ok=True)
|
||||
libs_out.mkdir(parents=True, exist_ok=True)
|
||||
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad']
|
||||
if with_x264:
|
||||
names.append('x264')
|
||||
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
|
||||
pending = [lib]
|
||||
for name in names:
|
||||
matches = list(plugin_dir.glob('*gst' + name + ('.dll' if win else '.so')))
|
||||
if not matches:
|
||||
raise SystemExit('Missing GStreamer library: ' + name)
|
||||
for source in matches:
|
||||
target = plugins_out / source.name
|
||||
shutil.copy2(source, target)
|
||||
pending.append(source)
|
||||
if not win:
|
||||
# PipeWire dynamically loads support and protocol modules. ldd cannot
|
||||
# discover those; bundle them with a private client-only config instead
|
||||
# of accidentally loading a different distro's client modules/config.
|
||||
libdir = plugin_dir.parent
|
||||
for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so',
|
||||
'pipewire-0.3/libpipewire-module-client-node.so',
|
||||
'pipewire-0.3/libpipewire-module-adapter.so',
|
||||
'spa-0.2/support/libspa-support.so',
|
||||
'spa-0.2/support/libspa-dbus.so',
|
||||
'spa-0.2/videoconvert/libspa-videoconvert.so'):
|
||||
source = libdir / relative
|
||||
if not source.is_file():
|
||||
raise SystemExit('Missing PipeWire runtime library: ' + str(source))
|
||||
target = destination / 'lib' / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
pending.append(source)
|
||||
config = destination / 'share' / 'pipewire'
|
||||
config.mkdir(parents=True, exist_ok=True)
|
||||
(config / 'client.conf').write_text(
|
||||
'context.spa-libs = { support.* = support/libspa-support video.* = videoconvert/libspa-videoconvert }\n'
|
||||
'context.modules = [\n'
|
||||
' { name = libpipewire-module-protocol-native }\n'
|
||||
' { name = libpipewire-module-client-node }\n'
|
||||
' { name = libpipewire-module-adapter }\n'
|
||||
']\n')
|
||||
copied = set()
|
||||
while pending:
|
||||
binary = pending.pop()
|
||||
if win:
|
||||
imported = re.findall(r'DLL Name:\s*(\S+)', output('objdump', '-p', str(binary)))
|
||||
dependencies = [prefix / 'bin' / name for name in imported]
|
||||
else:
|
||||
dependencies = [Path(p) for p in re.findall(r'=>\s+(/\S+)', output('ldd', str(binary)))]
|
||||
for dep in dependencies:
|
||||
if not dep.is_file() or dep.name in copied:
|
||||
continue
|
||||
if not win and re.match(r'lib(c|m|dl|rt|pthread|resolv)\.so', dep.name):
|
||||
continue
|
||||
shutil.copy2(dep, libs_out / dep.name)
|
||||
copied.add(dep.name)
|
||||
pending.append(dep)
|
||||
# License texts and package provenance accompany the dynamically linked
|
||||
# libraries. Distribution builders keep the upstream package/source URLs.
|
||||
licenses = destination / 'licenses'
|
||||
licenses.mkdir(exist_ok=True)
|
||||
if win:
|
||||
source = prefix / 'share' / 'licenses'
|
||||
if source.exists():
|
||||
shutil.copytree(source, licenses, dirs_exist_ok=True)
|
||||
packages = output('pacman', '-Q') if shutil.which('pacman') else 'See MSYS2 build log'
|
||||
else:
|
||||
packages = output('dpkg-query', '-W', '-f=${Package} ${Version}\n') if shutil.which('dpkg-query') else ''
|
||||
# Debian copyright files contain licenses and source homepage details.
|
||||
for path in Path('/usr/share/doc').glob('*/copyright'):
|
||||
shutil.copy2(path, licenses / (path.parent.name + '.copyright'))
|
||||
(destination / 'libraries.json').write_text(json.dumps(dict(gstreamer=output('pkg-config', '--modversion', 'gstreamer-1.0'),
|
||||
plugins=names, libraries=sorted(copied), packages=packages), indent=2))
|
||||
print(lib)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--out', type=Path, default=ROOT / 'bundle')
|
||||
parser.add_argument('--with-x264', action='store_true', help='add the GPL x264 encoder (CI tests only, never shipped)')
|
||||
args = parser.parse_args()
|
||||
build(args.out.resolve(), args.with_x264)
|
||||
@@ -0,0 +1,134 @@
|
||||
/* Frame Control's capture/encode adapter. GStreamer is a bundled library;
|
||||
* capture uses WGC on Windows and the consented PipeWire fd on Linux. */
|
||||
#include "controller.h"
|
||||
#include <gst/gst.h>
|
||||
#include <gst/app/gstappsink.h>
|
||||
#include <gst/video/video-event.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef int (*Gate)(int stage, int64_t pts, int64_t capture, int64_t arrived);
|
||||
typedef struct {
|
||||
GstElement *pipeline, *encoder, *sink, *raw_queue;
|
||||
Gate gate;
|
||||
GstSample *sample;
|
||||
GstMapInfo map;
|
||||
int mapped;
|
||||
char error[512];
|
||||
} Capture;
|
||||
typedef struct { const unsigned char *data; int size, key, width, height; int64_t pts; } Encoded;
|
||||
FC_API int64_t fc_now(void) {return g_get_monotonic_time();}
|
||||
FC_API void fc_gst_init(void) {gst_init(NULL,NULL);}
|
||||
FC_API int fc_has_element(const char *name) {
|
||||
GstElementFactory *f=gst_element_factory_find(name);
|
||||
if(!f)return 0;gst_object_unref(f);return 1;
|
||||
}
|
||||
FC_API int fc_has_property(const char *element, const char *property) {
|
||||
/* Probe the same initialized GStreamer instance that capture uses. The
|
||||
* bundled MSYS2 plugin can omit optional WGC properties entirely. */
|
||||
GstElement *source=gst_element_factory_make(element,NULL);
|
||||
if(!source)return 0;
|
||||
int found=g_object_class_find_property(G_OBJECT_GET_CLASS(source),property)!=NULL;
|
||||
gst_object_unref(source);
|
||||
return found;
|
||||
}
|
||||
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
|
||||
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
|
||||
if(!b)return GST_PAD_PROBE_OK;
|
||||
int64_t now=fc_now(), cap=now;
|
||||
GstClock *clock=gst_element_get_clock(c->pipeline);
|
||||
if(clock && GST_BUFFER_PTS_IS_VALID(b)) {
|
||||
GstClockTime current=gst_clock_get_time(clock),origin=gst_element_get_base_time(c->pipeline);
|
||||
if(current>=origin && current-origin>=GST_BUFFER_PTS(b))
|
||||
cap-= (int64_t)((current-origin-GST_BUFFER_PTS(b))/1000);
|
||||
}
|
||||
if(clock)gst_object_unref(clock);
|
||||
int stage=GPOINTER_TO_INT(g_object_get_data(G_OBJECT(pad),"stage"));
|
||||
if(stage==1) return c->gate(stage,(int64_t)GST_BUFFER_PTS(b),cap,now)>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
|
||||
int phase=0;
|
||||
for(;;) {
|
||||
/* While congested, prefer a newer raw picture queued upstream. If
|
||||
* nothing changed, retain this last picture until the gate opens;
|
||||
* an idle window must not stay stale after a dropped final update. */
|
||||
guint queued=0;
|
||||
if(phase && c->raw_queue)g_object_get(c->raw_queue,"current-level-buffers",&queued,NULL);
|
||||
if(queued)return GST_PAD_PROBE_DROP;
|
||||
int decision=c->gate(phase,(int64_t)GST_BUFFER_PTS(b),cap,now);
|
||||
if(decision)return decision>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
|
||||
phase=2;g_usleep(2000);
|
||||
}
|
||||
}
|
||||
FC_API Capture *fc_capture_open(const char *pipeline,Gate gate,char *error,int capacity) {
|
||||
GError *e=NULL;Capture *c=g_new0(Capture,1);c->gate=gate;
|
||||
c->pipeline=gst_parse_launch(pipeline,&e);
|
||||
if(e || !c->pipeline) {
|
||||
g_strlcpy(error,e?e->message:"No pipeline",capacity);
|
||||
if(e)g_error_free(e);if(c->pipeline)gst_object_unref(c->pipeline);g_free(c);return NULL;
|
||||
}
|
||||
c->encoder=gst_bin_get_by_name(GST_BIN(c->pipeline),"enc");
|
||||
c->sink=gst_bin_get_by_name(GST_BIN(c->pipeline),"out");
|
||||
GstElement *raw=gst_bin_get_by_name(GST_BIN(c->pipeline),"gate");
|
||||
if(!c->encoder || !c->sink || !raw) {
|
||||
g_strlcpy(error,"Pipeline is missing enc, out or gate",capacity);
|
||||
if(raw)gst_object_unref(raw);if(c->encoder)gst_object_unref(c->encoder);
|
||||
if(c->sink)gst_object_unref(c->sink);gst_object_unref(c->pipeline);g_free(c);return NULL;
|
||||
}
|
||||
c->raw_queue=gst_bin_get_by_name(GST_BIN(c->pipeline),"raw_queue");
|
||||
GstPad *p=gst_element_get_static_pad(raw,"src");
|
||||
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(0));
|
||||
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);gst_object_unref(raw);
|
||||
p=gst_element_get_static_pad(c->encoder,"sink");
|
||||
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(1));
|
||||
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);
|
||||
gst_element_set_state(c->pipeline,GST_STATE_PLAYING);
|
||||
return c;
|
||||
}
|
||||
FC_API int fc_capture_pull(Capture *c,Encoded *out) {
|
||||
if(c->mapped) {gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);c->mapped=0;}
|
||||
if(c->sample){gst_sample_unref(c->sample);c->sample=NULL;}
|
||||
GstBus *bus=gst_element_get_bus(c->pipeline);
|
||||
GstMessage *m=gst_bus_pop_filtered(bus,GST_MESSAGE_ERROR);gst_object_unref(bus);
|
||||
if(m) {
|
||||
if(GST_MESSAGE_TYPE(m)==GST_MESSAGE_ERROR) {
|
||||
GError *e=NULL;char *debug=NULL;gst_message_parse_error(m,&e,&debug);
|
||||
g_strlcpy(c->error,e->message,sizeof(c->error));g_error_free(e);g_free(debug);
|
||||
} else g_strlcpy(c->error,"The capture source closed",sizeof(c->error));
|
||||
gst_message_unref(m);return -1;
|
||||
}
|
||||
c->sample=gst_app_sink_try_pull_sample(GST_APP_SINK(c->sink),100*GST_MSECOND);
|
||||
if(!c->sample) {
|
||||
if(gst_app_sink_is_eos(GST_APP_SINK(c->sink))) {
|
||||
g_strlcpy(c->error,"The capture source closed",sizeof(c->error));return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
GstBuffer *b=gst_sample_get_buffer(c->sample);
|
||||
if(!gst_buffer_map(b,&c->map,GST_MAP_READ))return 0;
|
||||
c->mapped=1;out->data=c->map.data;out->size=(int)c->map.size;
|
||||
out->key=!GST_BUFFER_FLAG_IS_SET(b,GST_BUFFER_FLAG_DELTA_UNIT);out->pts=(int64_t)GST_BUFFER_PTS(b);
|
||||
const GstStructure *s=gst_caps_get_structure(gst_sample_get_caps(c->sample),0);
|
||||
gst_structure_get_int(s,"width",&out->width);gst_structure_get_int(s,"height",&out->height);return 1;
|
||||
}
|
||||
FC_API const char *fc_capture_error(Capture *c) {return c->error;}
|
||||
FC_API void fc_capture_bitrate(Capture *c,int bps) {
|
||||
g_object_set(c->encoder,"bitrate",(guint)MAX(1,bps/1000),NULL);
|
||||
}
|
||||
FC_API void fc_capture_test(Capture *c,uint32_t input) {
|
||||
GstElement *source=gst_bin_get_by_name(GST_BIN(c->pipeline),"source");
|
||||
if(source) {
|
||||
/* Each benchmark click visibly changes the ball, before injection is
|
||||
* timestamped. This is a response, not merely a protocol input echo. */
|
||||
guint color=0xff000000u | ((input*2654435761u)&0x00ffffffu);
|
||||
g_object_set(source,"foreground-color",color,NULL);gst_object_unref(source);
|
||||
}
|
||||
}
|
||||
FC_API void fc_capture_key(Capture *c) {
|
||||
GstPad *p=gst_element_get_static_pad(c->encoder,"src");
|
||||
gst_pad_send_event(p,gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE,TRUE,0));gst_object_unref(p);
|
||||
}
|
||||
FC_API void fc_capture_close(Capture *c) {
|
||||
gst_element_set_state(c->pipeline,GST_STATE_NULL);
|
||||
if(c->mapped)gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);
|
||||
if(c->sample)gst_sample_unref(c->sample);
|
||||
if(c->raw_queue)gst_object_unref(c->raw_queue);
|
||||
gst_object_unref(c->sink);gst_object_unref(c->encoder);gst_object_unref(c->pipeline);g_free(c);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* A C port of the Mac agent's RateController (mac/frame-mac-view/Sources/
|
||||
* Controller.swift) for the Windows host. Same gate, demand protection, bitrate
|
||||
* recovery and tier hysteresis; bounded sample buffers. The Mac agent keeps its
|
||||
* Swift original, so a change to either must be made to both:
|
||||
* tests/test_pc_controller.py checks this one against the Swift trace. */
|
||||
#include "controller.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#define CAP 1024
|
||||
#define MIN(a,b) ((a)<(b)?(a):(b))
|
||||
#define MAX(a,b) ((a)>(b)?(a):(b))
|
||||
typedef struct { int64_t t, v; uint32_t seq; } Sample;
|
||||
typedef struct { Sample a[CAP]; int n; } Samples;
|
||||
struct FCController {
|
||||
int max_fps, enabled, ceiling, target, tier, frame_bytes, held, signal, saw_ack;
|
||||
int64_t decrease, increase, below, above, slack;
|
||||
Samples flight, rtts, acked, sent, captures;
|
||||
};
|
||||
static const int fps[] = {60,45,30,30,30}, scale[] = {100,100,100,75,50};
|
||||
static const double floors[] = {.45,.28,.16,.08,0};
|
||||
static void remove_first(Samples *s, int n) {
|
||||
s->n -= n; memmove(s->a, s->a+n, (size_t)s->n*sizeof(Sample));
|
||||
}
|
||||
static void add(Samples *s, Sample v, int cap) {
|
||||
if (s->n >= cap) remove_first(s, 1);
|
||||
s->a[s->n++] = v;
|
||||
}
|
||||
static void expire(Samples *s, int64_t oldest) {
|
||||
int n=0; while(n<s->n && s->a[n].t<oldest) n++;
|
||||
remove_first(s,n);
|
||||
}
|
||||
static int64_t base(FCController *c) {
|
||||
int64_t v=0;
|
||||
for(int i=0;i<c->rtts.n;i++) if(!i || c->rtts.a[i].v<v) v=c->rtts.a[i].v;
|
||||
return v;
|
||||
}
|
||||
static int compare(const void *a, const void *b) {
|
||||
int64_t x=*(const int64_t*)a, y=*(const int64_t*)b;
|
||||
return (x>y)-(x<y);
|
||||
}
|
||||
static int64_t quantile(Samples *s,int64_t since,int numerator,int denominator) {
|
||||
int64_t a[CAP]; int n=0;
|
||||
for(int i=0;i<s->n;i++) if(s->a[i].t>since) a[n++]=s->a[i].v;
|
||||
if(!n) return 0;
|
||||
qsort(a,(size_t)n,sizeof(int64_t),compare);
|
||||
return a[n*numerator/denominator];
|
||||
}
|
||||
static int rate(Samples *s) {
|
||||
int64_t total=0; for(int i=0;i<s->n;i++) total+=s->a[i].v;
|
||||
return (int)MIN(total*16,2147483647);
|
||||
}
|
||||
FCController *fc_new(int max_fps,int enabled) {
|
||||
FCController *c=calloc(1,sizeof(*c));
|
||||
if(c) {c->max_fps=MAX(1,max_fps);c->enabled=enabled;c->slack=40000;}
|
||||
return c;
|
||||
}
|
||||
void fc_free(FCController *c) {free(c);}
|
||||
void fc_ceiling(FCController *c,int bps) {
|
||||
if(!c->target || c->target>bps) c->target=bps;
|
||||
c->ceiling=bps;
|
||||
}
|
||||
int fc_gate(FCController *c,int64_t now,int counts) {
|
||||
if(!c->enabled || !c->saw_ack) return 1;
|
||||
expire(&c->flight,now-2000000);
|
||||
if(!c->flight.n) return 1;
|
||||
int64_t interval=1000000/MIN(c->max_fps,fps[c->tier]);
|
||||
int window=MAX(3,(int)((base(c)+c->slack)/interval)+1);
|
||||
if(c->flight.n<window && now-c->flight.a[0].t<=base(c)+c->slack) return 1;
|
||||
if(counts) c->held++;
|
||||
return 0;
|
||||
}
|
||||
void fc_capture(FCController *c,int64_t now) {add(&c->captures,(Sample){now,0,0},256);}
|
||||
void fc_sent(FCController *c,uint32_t seq,int bytes,int64_t now) {
|
||||
Sample s={now,bytes,seq};add(&c->flight,s,512);add(&c->sent,s,CAP);
|
||||
}
|
||||
int fc_ack(FCController *c,uint32_t seq,int64_t now) {
|
||||
c->saw_ack=1;
|
||||
for(int i=0;i<c->flight.n;i++) if(c->flight.a[i].seq==seq) {
|
||||
Sample s=c->flight.a[i];remove_first(&c->flight,i+1);
|
||||
add(&c->rtts,(Sample){now,now-s.t,0},CAP);
|
||||
add(&c->acked,(Sample){now,s.v,0},CAP);return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
int fc_update(FCController *c,int64_t now) {
|
||||
expire(&c->rtts,now-10000000);expire(&c->acked,now-500000);
|
||||
expire(&c->sent,now-500000);expire(&c->flight,now-2000000);
|
||||
expire(&c->captures,now-1000000);
|
||||
if(!c->enabled || c->ceiling<=0) return 0;
|
||||
int64_t baseline=base(c), spread=quantile(&c->rtts,now-2000000,9,10);
|
||||
int64_t jitter=spread ? spread-baseline : 0;
|
||||
c->slack=1000000/MIN(c->max_fps,fps[c->tier])+MIN(MAX(jitter*3/2,25000),80000);
|
||||
int64_t recent=quantile(&c->rtts,now-300000,1,2);
|
||||
int64_t queue=recent ? recent-baseline : 0;
|
||||
int64_t age=c->flight.n ? now-c->flight.a[0].t : 0;
|
||||
int delivered=rate(&c->acked),sending=rate(&c->sent);
|
||||
if(c->sent.n) c->frame_bytes=sending/16/c->sent.n;
|
||||
int demand=(int)MIN((int64_t)MIN(c->captures.n,MIN(c->max_fps,fps[c->tier]))*c->frame_bytes*8,2147483647);
|
||||
int signal=(sending>=c->target/2 && queue>40000)||c->held>=3||age>baseline+100000;
|
||||
int congested=signal && c->signal;c->signal=signal;c->held=0;
|
||||
if(congested && now-c->decrease>300000) {
|
||||
int next=MAX(300000,MIN((int64_t)c->target*4/5,MAX((int64_t)delivered*9/10,c->target/2)));
|
||||
if(demand>0 && (int64_t)demand*2<=(int64_t)c->target*5/4) next=MAX(next,MIN(c->target,(int64_t)demand*2));
|
||||
c->target=next;c->decrease=now;
|
||||
} else if(!congested && now-c->decrease>1000000 && now-c->increase>250000 && c->target<c->ceiling &&
|
||||
(sending>(int64_t)c->target*6/10 || now-c->decrease>3000000)) {
|
||||
c->target=MIN(c->ceiling,(int64_t)(c->target*1.1)+50000);c->increase=now;
|
||||
}
|
||||
double share=(double)c->target/MAX(c->ceiling,1);
|
||||
if(c->tier<4 && share<floors[c->tier] && sending>=(int64_t)c->target*7/10) {
|
||||
if(!c->below)c->below=now;
|
||||
if(now-c->below>500000) {
|
||||
for(c->tier=0;c->tier<4 && share<floors[c->tier];c->tier++) {}
|
||||
c->below=0;
|
||||
}
|
||||
} else c->below=0;
|
||||
if(c->tier>0 && share>floors[c->tier-1]*1.25) {
|
||||
if(!c->above)c->above=now;
|
||||
if(now-c->above>2000000) {c->tier--;c->above=0;}
|
||||
} else c->above=0;
|
||||
return c->target;
|
||||
}
|
||||
int64_t fc_value(FCController *c,int field) {
|
||||
switch(field) {
|
||||
case 0:return c->target;case 1:return c->ceiling;case 2:return c->tier;
|
||||
case 3:return MIN(c->max_fps,fps[c->tier]);case 4:return scale[c->tier];
|
||||
case 5:return base(c);case 6:return c->flight.n;case 7:return c->slack;
|
||||
default:return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Shared latency controller. Times are monotonic host microseconds.
|
||||
* Callers serialize access. Capture is gated BEFORE encoding: dropping an
|
||||
* encoded inter frame would break the decoder's reference chain. */
|
||||
#ifndef FRAME_CONTROLLER_H
|
||||
#define FRAME_CONTROLLER_H
|
||||
#include <stdint.h>
|
||||
#ifdef _WIN32
|
||||
#define FC_API __declspec(dllexport)
|
||||
#else
|
||||
#define FC_API
|
||||
#endif
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
typedef struct FCController FCController;
|
||||
FC_API FCController *fc_new(int fps, int enabled);
|
||||
FC_API void fc_free(FCController *c);
|
||||
FC_API void fc_ceiling(FCController *c, int bps);
|
||||
FC_API int fc_gate(FCController *c, int64_t now, int counts);
|
||||
FC_API void fc_capture(FCController *c, int64_t now);
|
||||
FC_API void fc_sent(FCController *c, uint32_t seq, int bytes, int64_t now);
|
||||
FC_API int fc_ack(FCController *c, uint32_t seq, int64_t now);
|
||||
FC_API int fc_update(FCController *c, int64_t now);
|
||||
/* target, ceiling, tier, fps, scale percent, baseline us, in flight, slack us */
|
||||
FC_API int64_t fc_value(FCController *c, int field);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,147 @@
|
||||
/* xdg-desktop-portal RemoteDesktop + ScreenCast, one consented session per
|
||||
* panel. PipeWire receives the portal fd, never the unrestricted daemon fd.
|
||||
* Input uses only the devices granted in Start's response. */
|
||||
#ifndef _WIN32
|
||||
#include "controller.h"
|
||||
#include <gio/gio.h>
|
||||
#include <gio/gunixfdlist.h>
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#define BUS "org.freedesktop.portal.Desktop"
|
||||
#define PATH "/org/freedesktop/portal/desktop"
|
||||
#define RD "org.freedesktop.portal.RemoteDesktop"
|
||||
#define SC "org.freedesktop.portal.ScreenCast"
|
||||
typedef struct {
|
||||
GDBusConnection *bus;
|
||||
GMainContext *context;
|
||||
char *session;
|
||||
int fd;
|
||||
unsigned node, devices;
|
||||
int width,height;
|
||||
} Portal;
|
||||
typedef struct {GVariant *result;int done;unsigned code;} Response;
|
||||
static void response(GDBusConnection *c,const char *sender,const char *path,const char *iface,
|
||||
const char *signal,GVariant *params,gpointer data) {
|
||||
(void)c;(void)sender;(void)path;(void)iface;(void)signal;
|
||||
Response *r=data;g_variant_get(params,"(u@a{sv})",&r->code,&r->result);r->done=1;
|
||||
}
|
||||
static void option(GVariantBuilder *b,const char *key,GVariant *v) {g_variant_builder_add(b,"{sv}",key,v);}
|
||||
static GVariant *request(Portal *p,const char *iface,const char *method,GVariant *args,
|
||||
const char *token,char *error,int capacity) {
|
||||
char *sender=g_strdup(g_dbus_connection_get_unique_name(p->bus)+1);
|
||||
for(char *c=sender;*c;c++)if(*c=='.')*c='_';
|
||||
char *path=g_strdup_printf(PATH "/request/%s/%s",sender,token);g_free(sender);
|
||||
Response r={0};GError *e=NULL;
|
||||
guint sub=g_dbus_connection_signal_subscribe(p->bus,BUS,"org.freedesktop.portal.Request","Response",
|
||||
path,NULL,G_DBUS_SIGNAL_FLAGS_NONE,response,&r,NULL);
|
||||
GVariant *reply=g_dbus_connection_call_sync(p->bus,BUS,PATH,iface,method,args,G_VARIANT_TYPE("(o)"),
|
||||
G_DBUS_CALL_FLAGS_NONE,10000,NULL,&e);
|
||||
if(reply)g_variant_unref(reply);
|
||||
int64_t deadline=g_get_monotonic_time()+120000000;
|
||||
while(!e && !r.done && g_get_monotonic_time()<deadline) {
|
||||
while(g_main_context_iteration(p->context,FALSE)) {}
|
||||
g_usleep(10000);
|
||||
}
|
||||
if(!r.done) {
|
||||
GVariant *closed=g_dbus_connection_call_sync(p->bus,BUS,path,"org.freedesktop.portal.Request","Close",
|
||||
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
|
||||
if(closed)g_variant_unref(closed);
|
||||
}
|
||||
g_dbus_connection_signal_unsubscribe(p->bus,sub);g_free(path);
|
||||
if(e || !r.done || r.code) {
|
||||
g_strlcpy(error,e?e->message:!r.done?"Screen sharing request timed out":"Screen sharing was cancelled or refused",capacity);
|
||||
if(e)g_error_free(e);if(r.result)g_variant_unref(r.result);return NULL;
|
||||
}
|
||||
return r.result;
|
||||
}
|
||||
FC_API void fc_portal_close(Portal *p) {
|
||||
if(!p)return;
|
||||
if(p->session && p->bus) {
|
||||
GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,p->session,"org.freedesktop.portal.Session","Close",
|
||||
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
|
||||
if(r)g_variant_unref(r);
|
||||
}
|
||||
if(p->fd>=0){close(p->fd);p->fd=-1;}
|
||||
g_free(p->session);if(p->bus)g_object_unref(p->bus);
|
||||
if(p->context)g_main_context_unref(p->context);g_free(p);
|
||||
}
|
||||
/* Each pipeline gets a fresh restricted PipeWire connection. A dup of a
|
||||
* previously consumed protocol socket is not a new connection.
|
||||
* Ownership: the Portal owns p->fd for its whole life and is its only closer.
|
||||
* pipewiresrc never takes the fd it is given: its core connects with
|
||||
* pw_context_connect_fd(ctx, fcntl(fd, F_DUPFD_CLOEXEC, 3), ...) and that
|
||||
* duplicate is what PipeWire closes on teardown (src/gst/gstpipewirecore.c,
|
||||
* unchanged from 0.3.19 through 1.x). So closing p->fd here is not a double
|
||||
* close; not closing it would leak one socket per pipeline reopen. The
|
||||
* caller closes the previous pipeline before asking for a new fd. */
|
||||
FC_API int fc_portal_refresh(Portal *p,char *error,int capacity) {
|
||||
GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
GUnixFDList *fds=NULL;GError *e=NULL;
|
||||
GVariant *r=g_dbus_connection_call_with_unix_fd_list_sync(p->bus,BUS,PATH,SC,"OpenPipeWireRemote",
|
||||
g_variant_new("(oa{sv})",p->session,&b),G_VARIANT_TYPE("(h)"),G_DBUS_CALL_FLAGS_NONE,10000,NULL,&fds,NULL,&e);
|
||||
if(!r){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
|
||||
int handle;g_variant_get(r,"(h)",&handle);g_variant_unref(r);
|
||||
int fd=g_unix_fd_list_get(fds,handle,&e);g_object_unref(fds);
|
||||
if(fd<0){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
|
||||
if(p->fd>=0)close(p->fd);
|
||||
p->fd=fd;return fd;
|
||||
}
|
||||
FC_API Portal *fc_portal_select(char *error,int capacity) {
|
||||
Portal *p=g_new0(Portal,1);p->fd=-1;p->context=g_main_context_new();
|
||||
g_main_context_push_thread_default(p->context);
|
||||
GError *e=NULL;GVariant *r=NULL;
|
||||
p->bus=g_bus_get_sync(G_BUS_TYPE_SESSION,NULL,&e);
|
||||
if(!p->bus) {g_strlcpy(error,e->message,capacity);g_error_free(e);goto fail;}
|
||||
GVariantBuilder b;char token[64],session[64];
|
||||
g_snprintf(session,sizeof(session),"fc_%u",g_random_int());
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());
|
||||
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"session_handle_token",g_variant_new_string(session));
|
||||
r=request(p,RD,"CreateSession",g_variant_new("(a{sv})",&b),token,error,capacity);if(!r)goto fail;
|
||||
g_variant_lookup(r,"session_handle","s",&p->session);g_variant_unref(r);r=NULL;
|
||||
if(!p->session){g_strlcpy(error,"Portal did not return a session",capacity);goto fail;}
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
|
||||
r=request(p,RD,"SelectDevices",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
|
||||
option(&b,"multiple",g_variant_new_boolean(FALSE));option(&b,"cursor_mode",g_variant_new_uint32(2));
|
||||
r=request(p,SC,"SelectSources",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));
|
||||
r=request(p,RD,"Start",g_variant_new("(osa{sv})",p->session,"",&b),token,error,capacity);if(!r)goto fail;
|
||||
g_variant_lookup(r,"devices","u",&p->devices);
|
||||
GVariant *streams=g_variant_lookup_value(r,"streams",G_VARIANT_TYPE("a(ua{sv})"));
|
||||
if(streams && g_variant_n_children(streams)>0) {
|
||||
GVariant *entry=g_variant_get_child_value(streams,0),*props=NULL;
|
||||
g_variant_get(entry,"(u@a{sv})",&p->node,&props);
|
||||
g_variant_lookup(props,"size","(ii)",&p->width,&p->height);
|
||||
g_variant_unref(props);g_variant_unref(entry);
|
||||
}
|
||||
if(streams)g_variant_unref(streams);g_variant_unref(r);r=NULL;
|
||||
if(!p->node || p->width<=0 || p->height<=0) {g_strlcpy(error,"Portal returned no stream size",capacity);goto fail;}
|
||||
if(fc_portal_refresh(p,error,capacity)<0)goto fail;
|
||||
g_main_context_pop_thread_default(p->context);return p;
|
||||
fail:
|
||||
g_main_context_pop_thread_default(p->context);fc_portal_close(p);return NULL;
|
||||
}
|
||||
FC_API int fc_portal_value(Portal *p,int field) {
|
||||
switch(field){case 0:return p->fd;case 1:return (int)p->node;case 2:return p->width;
|
||||
case 3:return p->height;case 4:return (int)p->devices;default:return 0;}
|
||||
}
|
||||
FC_API int fc_portal_input(Portal *p,int type,double x,double y,int code,int down) {
|
||||
const char *method=NULL;GVariant *args=NULL;GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
if(type==0 && (p->devices&2)) {
|
||||
method="NotifyPointerMotionAbsolute";args=g_variant_new("(oa{sv}udd)",p->session,&b,p->node,x,y);
|
||||
} else if(type==1 && (p->devices&2)) {
|
||||
method="NotifyPointerButton";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
|
||||
} else if(type==2 && (p->devices&2)) {
|
||||
method="NotifyPointerAxis";args=g_variant_new("(oa{sv}dd)",p->session,&b,x,y);
|
||||
} else if(type==3 && (p->devices&1)) {
|
||||
method="NotifyKeyboardKeysym";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
|
||||
} else return 0;
|
||||
GError *e=NULL;GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,PATH,RD,method,args,NULL,
|
||||
G_DBUS_CALL_FLAGS_NONE,2000,NULL,&e);
|
||||
if(e)g_error_free(e);if(r)g_variant_unref(r);return r!=NULL;
|
||||
}
|
||||
#endif
|
||||
+1
-1
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
|
||||
|
||||
## Assistant panel
|
||||
|
||||
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
|
||||
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
|
||||
To put the same page in the headset, with the HTTP server still running:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Import an Android game backup
|
||||
|
||||
A SideQuest backup can contain a small APK and much larger `main.…obb` and
|
||||
`patch.…obb` files. All of them belong to the same game. Installing only the APK
|
||||
can leave the game unable to start because its assets are missing.
|
||||
|
||||
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
|
||||
You can also drop them anywhere in the window. In the desktop app, dropping the
|
||||
APK alone finds matching OBB files beside it; dropping a folder containing the
|
||||
APK and OBBs also works. Browsers can read only the files you explicitly choose.
|
||||
|
||||
The import screen shows the game app and its game-data files together. **Import
|
||||
game & data** installs the APK, starts its own Steam library instance, waits for
|
||||
Android and the installed package, copies the OBBs, and closes the game. Open it
|
||||
again from the Steam library to play. The screen explains this before import;
|
||||
choosing files alone never starts a game.
|
||||
|
||||
Files are matched using the package inside the APK and the standard
|
||||
`main|patch.<version>.<package>.obb` name, not the APK's filename. Both main and
|
||||
patch files are preserved. OBB version numbers need not equal the APK's current
|
||||
version code: updates can reuse older expansion files. The copy uses the existing
|
||||
SHA-256 transfer check inside Android and writes to that game's own
|
||||
`/sdcard/Android/obb/<package>` folder.
|
||||
|
||||
If no data files are selected, the screen reminds you to include any OBBs in the
|
||||
backup before offering **Install game app**. An APK cannot reliably tell Frame
|
||||
Control whether separate expansion files are required. Game-data files are never
|
||||
silently sent to Downloads by this flow.
|
||||
|
||||
If setup fails, the import screen keeps the error and retry instructions visible.
|
||||
The APK may already be installed, but the game is not reported ready. Import the
|
||||
same backup again, or choose just its OBB files to add data to an existing install.
|
||||
The importer checks the installed package and version before writing data and
|
||||
closes the instance after a failed copy. Unrelated or duplicate files, running
|
||||
games that would be replaced, and downgrades are rejected. Uploaded staging files
|
||||
are discarded after use or cancellation; abandoned imports expire after an hour
|
||||
when the importer is next used, and dead-server staging folders are swept at
|
||||
startup.
|
||||
|
||||
This flow handles one ordinary APK and standard OBB files for one game at a time.
|
||||
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
|
||||
archives need their own import handling. PC ZIP/folder/EXE imports keep their
|
||||
existing route. Quest USB and Meta library imports keep their existing workflow.
|
||||
|
||||
Validation covers actual APK parsing, the three-file backup layout, package
|
||||
matching, sibling discovery, browser upload staging and cleanup, instance/version
|
||||
guards and failure recovery with mocked headset operations. Browser verification
|
||||
uses the real page and upload/inspection endpoints with simulated installation.
|
||||
**Verified 2026-10-20 on a real Steam Frame**, BUILD_ID `20260925.6191901`:
|
||||
F-Droid Basic 2.0.2 with two synthetic main/patch OBBs completed the automatic
|
||||
install/start/copy/close sequence. Reopening its persistent instance confirmed
|
||||
both SHA-256 hashes in `/sdcard/Android/obb/org.fdroid.basic`. The test app,
|
||||
shortcut and instance were removed afterward, the shared device lock was
|
||||
released, and the battery remained at 99%. See [device evidence](evidence/android-import/device.json).
|
||||
The headset was unworn; this checks transfer and persistence, not rendering,
|
||||
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
|
||||
flow does not change APK compatibility or remove licence checks.
|
||||
+54
-1
@@ -58,8 +58,61 @@ form shows the pinned one ("Trusted on first use: …") so you can compare it.
|
||||
Re-adding an existing URL preserves its pin; changing it requires deliberately
|
||||
removing and re-adding it.
|
||||
|
||||
### Repositories behind a login
|
||||
|
||||
Some self-hosted repositories sit behind HTTP basic auth. As in F-Droid's own
|
||||
client, the login can be part of the address
|
||||
(`https://user:pass@example.org/fdroid/repo`), or given separately: in the
|
||||
store's **Add a source** form under "This repository needs a login", in the
|
||||
server API (`username` and `password` beside `url`), or on the command line,
|
||||
where the password is read from standard input so it stays out of shell history
|
||||
and `ps`:
|
||||
|
||||
```sh
|
||||
printf '%s\n' "$PASSWORD" | python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo' --username me --password-stdin
|
||||
```
|
||||
|
||||
How the login is kept and used:
|
||||
|
||||
- **Kept out of the address.** The `user:pass@` part is removed before anything
|
||||
is saved, so the saved repository URL, its id, the source list, job results
|
||||
and the page never contain it. The store only says "Uses a login".
|
||||
- **Stored separately.** Logins live in
|
||||
`frame_host.data_dir('source-credentials.json')`
|
||||
(`~/Library/Application Support/Frame Control/source-credentials.json` on
|
||||
macOS), written with mode 0600 and keyed by source id. They are stored in
|
||||
plain text there, protected by file permissions only (on Windows,
|
||||
the folder's normal ACLs). Removing the repository deletes its login, and
|
||||
the file goes when the last login is removed. Each change holds a lock
|
||||
file (`source-credentials.lock`), so the app and the command line can't undo
|
||||
each other's saves or removals.
|
||||
- **HTTPS only.** An `http://` address with a login is refused before any request.
|
||||
- **Inside the repository only.** The `Authorization` header is decided
|
||||
separately for every request, redirects included: index, `entry.jar`,
|
||||
APKs and the store's artwork proxy. It is sent only when the request's
|
||||
scheme, host and port match the repository's and its path is inside the
|
||||
repository's directory by whole path segments. A redirect to another host
|
||||
or port, or to another path on the same server, gets no login, and that
|
||||
request's own `Authorization` header is removed. Paths with `.` or `..`
|
||||
segments (plain or percent-encoded), encoded `/` or `\`, double encoding
|
||||
or `;` get no login, since a server could normalise them to a path
|
||||
outside the repository. Index file names like that are refused outright.
|
||||
- **Never echoed.** Logins are not written to logs, error messages, API
|
||||
responses or the cache. The CLI prints only the saved source.
|
||||
- **Checked before saving.** As with any repository, the whole index must
|
||||
download and verify before anything is saved. HTTP 401 (or 403 when a login
|
||||
was sent) stops the add with "check the username and password" and saves
|
||||
nothing: no repository, no login, no rollback state. A 403 with a login is
|
||||
not treated as rate limiting.
|
||||
- **Same verification.** The login changes only who may download. Signature,
|
||||
fingerprint pin or TOFU, index hash and rollback checks apply exactly as for
|
||||
any other repository.
|
||||
|
||||
Re-adding the same address with a new login replaces the saved one. Re-adding
|
||||
it without a login keeps the saved one.
|
||||
|
||||
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
|
||||
`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
|
||||
`add_repo(url, fingerprint=None, name=None, username=None, password=None)`, `remove_repo(source_id)`,
|
||||
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
|
||||
`sources`, `search`, `details`, and `download` from the shared source contract.
|
||||
This change supplies the CLI and API; the integrated source-management UI is
|
||||
|
||||
+17
-4
@@ -45,10 +45,23 @@ search. Results outside the curated list stay page-only, even if a repository
|
||||
claims an open-source license. This prevents an arbitrary tagged mirror from
|
||||
becoming a trusted downloader. Extend the curated JSON after provenance review.
|
||||
|
||||
Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API
|
||||
quota. Tokens are sent only to `api.github.com`, never written to the cache,
|
||||
never sent to asset hosts, and removed on redirects. The adapter does not
|
||||
read `gh` credentials automatically. Metadata is cached for one hour under
|
||||
For a higher API quota, add a GitHub access token in the store's source
|
||||
settings (the GitHub row's token field) or through the server API
|
||||
(`POST /api/sources` with `action: "set-token"` or `"clear-token"` and
|
||||
`source: "github"`). `FRAME_GITHUB_TOKEN` in the process environment still
|
||||
works and wins over a saved token. A saved token is kept with repository
|
||||
logins in `source-credentials.json` (mode 0600, see
|
||||
[APK repositories](apk-repos.md#repositories-behind-a-login)). The source list
|
||||
reports only whether a token is set, where it came from and whether GitHub
|
||||
rejected it, never its value. Tokens are sent as `Authorization: Bearer` only to
|
||||
`api.github.com`, never written to the cache, never sent to asset hosts (public
|
||||
release downloads don't need them), and removed on redirects. If GitHub answers
|
||||
401 to the token, the request is retried once without it, as Obtainium does.
|
||||
The rejection is logged without the token and shown in source settings. The
|
||||
token is not tried again until it is saved again or the app restarts. Rate-limit
|
||||
backoff is kept per login (anonymous, or each token), so saving a token after
|
||||
anonymous requests were limited lets the next request go out with it. The
|
||||
adapter does not read `gh` credentials automatically. Metadata is cached for one hour under
|
||||
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
|
||||
fetches at most ten releases. Rate-limit errors are surfaced without retry
|
||||
loops. Asset IDs and release tags are not Android version codes: metadata
|
||||
|
||||
+138
@@ -39,6 +39,12 @@ section below), instead of going into Lepton Development:
|
||||
run at once alongside Lepton Development, each in its own container
|
||||
(`lepton-steamlaunch-<id>`, ADB on 5556, 5557, …).
|
||||
|
||||
Split APKs (a base plus `split_*.apk` files) install as one app:
|
||||
`python3 ui/frame_android.py install base.apk split1.apk …`. Lepton runs one
|
||||
APK, so splits that add native libraries or assets are merged into the base
|
||||
and re-signed; density and language splits are refused. See
|
||||
[quest-import.md](quest-import.md#split-apks). Not yet tried on a real Frame.
|
||||
|
||||
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
|
||||
from the shortcut, stopped, and relaunched with their data intact. ADB and
|
||||
Lepton Development aren't involved.
|
||||
@@ -177,6 +183,92 @@ See
|
||||
[compat-db/README.md](../compat-db/README.md) and
|
||||
[apk-catalog/README.md](../apk-catalog/README.md).
|
||||
|
||||
## Store and Library in Frame Control
|
||||
|
||||
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
|
||||
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
|
||||
Frame as cards, with Play and a menu for Test, Report and Remove; the display
|
||||
settings and recent reports are below it.
|
||||
|
||||
Store search cards and app details also show **Reported not working** or
|
||||
**Reported issues** when saved compatibility reports say so. Details include
|
||||
the report date, version and notes. These are advisories: Install remains
|
||||
available unless the selected build has an existing compatibility blocker
|
||||
(for example, no arm64-v8a build or Android API above 30). Reports never clear
|
||||
those blockers. Apps reported not working are excluded from Home's featured
|
||||
app and recommendation shelves; search and category results still show them.
|
||||
An unrated install failure caused by the wrong APK architecture or Android API
|
||||
is ignored, using the same rules as the catalogue. A person's rating takes
|
||||
priority over automated results, with the latest rating winning.
|
||||
|
||||
Reports warm in a background worker at server start. Search and details read
|
||||
only the last complete in-memory snapshot and schedule a single background
|
||||
refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or
|
||||
the report database. During initial loading they show the usual build verdict;
|
||||
after a refresh, the next search or detail request sees the reports. Failed
|
||||
refreshes keep the previous snapshot and retry after another 60 seconds.
|
||||
|
||||
**Availability:** public copies benefit from their own saved reports, including
|
||||
reports for apps outside the bundled catalogue, across every source with the
|
||||
same package name. This does not give public users the private shared ratings.
|
||||
The checked-in `apk-catalog/site/apps.js` contains predictions and no tested
|
||||
report verdicts, so it cannot supply a useful public report snapshot. No private
|
||||
reports, free-text notes or credentials are exported or published. The existing
|
||||
maintainer loader still uses its configured database access in the background;
|
||||
the search feature adds no sharing, endpoint or network destination. Tests leave
|
||||
the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in
|
||||
server subprocesses) and use synthetic reports to exercise refresh behavior.
|
||||
|
||||
The store groups apps by what a headset is good for, not by F-Droid's list:
|
||||
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
|
||||
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
|
||||
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
|
||||
*Social Network* is Web & chat).
|
||||
|
||||
Apps built around a phone are hidden from browsing unless **Show phone-only
|
||||
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
|
||||
a category that only makes sense in a phone's own system (launchers, keyboards,
|
||||
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
|
||||
fitness, alarms, system tools) unless a useful category says otherwise
|
||||
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
|
||||
part (a widget, a launcher, a battery tool), and apps that need hardware the
|
||||
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
|
||||
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
|
||||
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
|
||||
|
||||
Without a search, each source offers its best 300 for the chosen category:
|
||||
apps with a banner first, then with screenshots, then the newest.
|
||||
|
||||
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
|
||||
|
||||
The **APK Store** panel is the Store above, drawn for the laser: the same
|
||||
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
|
||||
its own floating panel:
|
||||
|
||||
```sh
|
||||
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
|
||||
scripts/store-on-frame.sh close
|
||||
```
|
||||
|
||||
`open` copies the same bundle the iPhone app uses
|
||||
(`ios/scripts/make_frame_bundle.py`) to
|
||||
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
|
||||
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
|
||||
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
|
||||
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
|
||||
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
|
||||
all run on the Frame, so the Mac can go away after `open`.
|
||||
|
||||
The page has a category rail (Home, the store's categories, Installed), eight
|
||||
art cards a page, and a featured VR app on Home. It turns pages instead of
|
||||
scrolling, because a tagged panel gets mouse clicks and a drag would select
|
||||
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
|
||||
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
|
||||
the real sources): Home, categories, Installed, the detail sheet. **Still to
|
||||
check in the headset:** that the panel opens and takes laser clicks, that an
|
||||
install completes when run on the Frame, and that **Open** brings up the app's
|
||||
panel.
|
||||
|
||||
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
|
||||
|
||||
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
|
||||
@@ -339,3 +431,49 @@ app instance and back up/restore a stopped instance's private app data. See
|
||||
[SideQuest features and limits](sidequest.md) for commands, archive scope and
|
||||
verification status. These paths have offline coverage; real Frame storage and
|
||||
permissions remain unverified. They do not change APK install or launch behavior.
|
||||
|
||||
|
||||
## Local APK/OBB backups and Android bundles
|
||||
|
||||
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
|
||||
standalone APK and its matching `main.<version>.<package>.obb` and/or
|
||||
`patch.<version>.<package>.obb` files together. The desktop app also discovers
|
||||
matching OBB files beside a lone selected APK. Browser uploads use only the
|
||||
files you selected. Installed apps can receive OBB-only imports.
|
||||
|
||||
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
|
||||
one standalone APK and optional matching OBBs. These extensions describe
|
||||
containers, not a guarantee that their contents are supported. OBBs may be
|
||||
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
|
||||
one enclosing backup folder is allowed. Known manifest/icon/signature
|
||||
sidecars are ignored; the APK manifest determines the package. Unknown files,
|
||||
unsafe paths, links, encrypted entries, duplicate paths (including case
|
||||
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
|
||||
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
|
||||
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
|
||||
extract an Android ZIP yourself and select its APK/OBBs.
|
||||
|
||||
The importer previews the app and files before **Import game & data**.
|
||||
Confirming authorizes installing the app, starting it to prepare Android,
|
||||
checking the running package/version, copying OBBs through the existing
|
||||
verified data helper, and closing the game. Open it from Steam afterwards.
|
||||
If setup fails, read the error and retry the same backup; do not uninstall
|
||||
first, since that deletes saves. An app that cannot start may also prevent
|
||||
data setup. Uploaded and extracted staging is removed on cancellation,
|
||||
inspection failure, or job completion/failure; abandoned imports expire
|
||||
through the existing one-hour staging lifecycle. Original local files stay intact.
|
||||
|
||||
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
|
||||
bases marked as requiring splits. It does not select device variants or restore
|
||||
`Android/data`, private app data, saves, or entitlements. For the separate
|
||||
restricted native-library/asset split workflow see
|
||||
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
|
||||
code and feature splits are not supported by that merger.
|
||||
|
||||
For issue #102, the RE4 report only establishes that an APK attempt failed.
|
||||
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
|
||||
and runtime compatibility remain unknown. Record those details, Frame Control
|
||||
and SteamOS versions, and sanitized server/crash errors before diagnosing it.
|
||||
`python3 ui/frame_android.py info /path/to/game.apk` inspects the APK locally.
|
||||
Successful installation or fixture tests do not establish RE4 compatibility;
|
||||
OBBs cannot fix ABI/API, VrApi/OpenXR, entitlement or Android-service failures.
|
||||
@@ -0,0 +1,187 @@
|
||||
# Backup and recovery
|
||||
|
||||
**Tools → Backup & recovery** copies what a factory reset would lose from the
|
||||
Steam Frame to your computer, puts it back afterwards, and gets the headset out
|
||||
of Desktop Mode. It runs from Frame Control on a computer, not from the iPhone
|
||||
app or the Frame itself.
|
||||
|
||||
**Partly tested on a headset.** On a Frame running SteamOS 0.5.6 (build
|
||||
20261015.6141207), a full backup saved 26 parts (40.7 MB): 14 games' Proton
|
||||
saves, Linux game saves, Steam and desktop settings and 9 Android apps' data,
|
||||
with no sign-in files or keys in the archives. Restoring one game's saves on top
|
||||
of identical files wrote nothing and left nothing behind. The session check
|
||||
reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a
|
||||
headset: a restore after a real factory reset, a restore that replaces files,
|
||||
Android data restore. Unit tests and the fake Frame cover those (see
|
||||
[Verification](#verification)).
|
||||
On 2026-10-24, the real-device reproduction in [issue #142](https://github.com/saphid/frame-control/issues/142)
|
||||
confirmed full Plasma Desktop Mode on the same build: the Plasma session target
|
||||
was active and the Game Mode target inactive. Desktop Mode also ran gamescope
|
||||
for `steamvr-plasma.service`, so the old process check incorrectly reported
|
||||
Game Mode. SteamOS's `steamosctl switch-to-game-mode` restored Game Mode and
|
||||
reversed the target states; battery stayed at 99%, and the rollback timer and
|
||||
shared lock were cleaned up. After #143 passed CI, the corrected code was
|
||||
tested in another full Plasma session under a three-minute rollback timer.
|
||||
`ui/frame_backup.py session` reported Desktop Mode despite gamescope, and
|
||||
`ui/frame_backup.py game-mode` invoked the same guarded Return function used by
|
||||
the button. Game Mode became active and a second call made no switch. Battery
|
||||
remained at 99%; the timer and lock were removed. The literal desktop button
|
||||
was not clicked in this test, and older SteamOS builds remain fixture-tested.
|
||||
|
||||
## Back up
|
||||
|
||||
Choose **Game saves**, **Steam and desktop settings** and **Android app data**
|
||||
(all three by default), then **Back up now**. **See what's included** lists each
|
||||
part and its size first. Backing up only reads the headset.
|
||||
|
||||
Each backup is a folder named after the time it started:
|
||||
|
||||
```
|
||||
~/Documents/Frame Control Backups/2026-10-23-142530/
|
||||
manifest.json
|
||||
saves/saves-1234.tar.gz one per game
|
||||
saves/saves-linux.tar.gz
|
||||
settings/settings-steam.tar.gz
|
||||
settings/settings-system.tar.gz
|
||||
android/org.example.game.tar.gz one per Android app
|
||||
```
|
||||
|
||||
`manifest.json` records the headset's name, SteamOS version and BUILD_ID, every
|
||||
file with its size and SHA-256, and each archive's SHA-256. It also lists what
|
||||
was skipped and why. A backup is written under a hidden `.…partial` name and
|
||||
renamed when complete, so a cut-off backup never shows up as one.
|
||||
|
||||
### What's saved
|
||||
|
||||
| Part | From the headset (`~` is `/home/steamos`) | Restored? |
|
||||
|---|---|---|
|
||||
| Windows game saves | `~/.local/share/Steam/steamapps/compatdata/<appid>/pfx/drive_c/users/steamuser/` → `AppData/Roaming`, `AppData/Local`, `AppData/LocalLow`, `Documents`, `Saved Games`. Wine's own folders in them (`Temp`, `Microsoft`, `D3DSCache`, `CrashDumps`) are left out. Sideloaded Windows games (non-Steam shortcuts) have prefixes here too | Yes |
|
||||
| Linux game saves | `~/.config/unity3d`, `~/.local/share/godot/app_userdata`: where Unity and Godot games save by default | Yes |
|
||||
| Steam controller layouts | `userdata/<account>/241100/remote/controller_config`, `steamapps/common/Steam Controller Configs/<account>/config` | Yes |
|
||||
| Steam library settings | `userdata/<account>/config/localconfig.vdf`, `shortcuts.vdf`, `grid/` (custom artwork) | Kept for reference |
|
||||
| Desktop settings | `~/.config/kdeglobals`, `kwinrc`, `kxkbrc` (keyboard layout), `konsolerc`, `dolphinrc` | Yes |
|
||||
| SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` | Kept for reference |
|
||||
| Android app data | Each Frame Control Android app's private data, through the existing `backup-data` helper ([sidequest.md](sidequest.md#private-app-data-backups)) | Yes |
|
||||
|
||||
"Kept for reference" files are in the backup but not put back automatically:
|
||||
Steam and SteamVR rewrite them while they run, and an old `shortcuts.vdf` would
|
||||
point at apps that aren't installed after a reset. Open the backup folder to
|
||||
copy one by hand if you need it.
|
||||
|
||||
### What's never copied
|
||||
|
||||
- **Steam sign-in:** `steam.token`, `config/config.vdf`, `loginusers.vdf`,
|
||||
`registry.vdf` and `ssfn*` files.
|
||||
- **Keys and passwords:** `~/.ssh`, `~/.gnupg`, keyrings and KWallet, any
|
||||
`*.token`, `*.key` or `*.pem` file, Tailscale state and proxy settings
|
||||
(`secrets.env`). These are refused by name wherever they turn up, including
|
||||
inside a game's folder, and a restore refuses them too.
|
||||
- Games, Proton and the Steam runtimes: reinstall them from Steam.
|
||||
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
|
||||
screenshots (**Tools → Capture & Live** saves those to Pictures).
|
||||
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
|
||||
- Symbolic links (never followed), and single files over 2 GiB.
|
||||
|
||||
Backups aren't encrypted. Game folders can still hold a launcher's login, and
|
||||
Android app data can hold an app's, so keep backups private.
|
||||
|
||||
Android apps that are open are skipped (the helper needs the app closed); close
|
||||
them and back up again.
|
||||
|
||||
## Restore after a factory reset
|
||||
|
||||
1. Set up the headset again: Developer Mode, **Set Up Connection**, sign in to
|
||||
Steam with the same account.
|
||||
2. Reinstall your games, and **start each once** so Proton creates its folders.
|
||||
3. Reinstall your Android apps from Frame Control.
|
||||
4. **Tools → Backup & recovery → Restore…** on a backup. Frame Control checks
|
||||
each part against the headset and says what it would do: how many files it
|
||||
replaces and how many are new.
|
||||
5. Tick the parts to restore and **Restore selected**. It asks before replacing
|
||||
anything.
|
||||
|
||||
Parts that can't go back yet are skipped, not failed, with the reason:
|
||||
|
||||
| Reason | What to do |
|
||||
|---|---|
|
||||
| Install the game first | Install it from Steam, then restore |
|
||||
| Start the game once | Proton hasn't made its folders yet; start the game, quit, restore |
|
||||
| Sign in to Steam first | Controller layouts go under your account's folder |
|
||||
| Install the app first / close the app first | Android app data needs the app installed and closed |
|
||||
| The backup file doesn't match its checksum | The archive changed since it was made |
|
||||
|
||||
A restore writes only the files in the parts you chose, and only inside that
|
||||
part's folders: it never deletes a file, and a save archive can't write outside
|
||||
its game. Before replacing a file it copies the current one to
|
||||
`~/.local/share/frame-control/restore-previous/<time>/` on the headset. Files
|
||||
already identical are left alone. Android app data goes through the existing
|
||||
`restore-data` helper, which keeps the previous data folder beside it.
|
||||
|
||||
## Stuck in Desktop Mode
|
||||
|
||||
On the Frame, Game Mode is gamescope running Steam, and the Linux desktop
|
||||
normally runs *inside* it as one panel (`steamos-nested-desktop`, see
|
||||
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
|
||||
is what you see, you're already in Game Mode: close it from the Steam dashboard.
|
||||
|
||||
Full Desktop Mode is a different session. **Check** reads the main user's
|
||||
systemd session targets over SSH: `gamescope-session.target` and
|
||||
`plasma-workspace-wayland.target`. Game Mode requires the former active and the
|
||||
latter inactive; Desktop Mode requires the reverse. This takes precedence over
|
||||
process names: full Desktop Mode can run gamescope for its VR overlay, and
|
||||
Game Mode can run a nested Plasma panel. Check changes nothing.
|
||||
|
||||
Conflicting, transitional, failed or unreadable targets produce **unknown**.
|
||||
On older images where both targets are explicitly absent, the fallback accepts
|
||||
only gamescope without Plasma/nested desktop as Game Mode, or Plasma without
|
||||
gamescope/nested desktop as Desktop Mode. Mixed processes remain unknown.
|
||||
The SDDM autologin file is still reported as a login preference; it cannot
|
||||
establish which session is running.
|
||||
|
||||
After confirmation, **Return to Game Mode** runs exactly
|
||||
`steamosctl switch-to-game-mode` when available, or the older
|
||||
`holo-session-select gamescope` / `steamos-session-select gamescope` command.
|
||||
It closes the desktop and anything open in it. Frame Control never edits the
|
||||
SDDM files itself, and runs no switch when the headset is in Game Mode, when
|
||||
its mode is unknown, or when no supported switch command is installed. A
|
||||
refused command reports an error; a request being sent does not verify that
|
||||
the new session has started.
|
||||
|
||||
### Manual fallback
|
||||
|
||||
If the button can't help, or the headset isn't reachable:
|
||||
|
||||
1. **Restart it:** hold Power about 10 seconds until the light goes off, then
|
||||
power on. On the Steam Deck, **Switch to Desktop** starts a one-shot
|
||||
Desktop Mode session, so a restart brings Game Mode back. That the Frame
|
||||
behaves the same is **inferred**, not tested.
|
||||
2. **Over SSH** (Developer Mode on), run Valve's switch yourself:
|
||||
`ssh frame steamosctl switch-to-game-mode` (verified on SteamOS 0.5.6).
|
||||
Older images may instead provide `steamos-session-select gamescope` or
|
||||
`holo-session-select gamescope`.
|
||||
3. **Boot menu:** hold Power 10 seconds, then power on while holding the AUX
|
||||
button on top of Power. Choose `Current`, or `Previous` to start the other
|
||||
SteamOS slot; both keep your data. Details and the further options
|
||||
(`Repair Steam Installation`, `Erase User Data`, a full re-image) are in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing) and
|
||||
[recovery-and-images.md](recovery-and-images.md). Back up first if you can:
|
||||
`Erase User Data` is a factory reset.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
|
||||
for real against a folder laid out like the Frame's home, with only SSH faked:
|
||||
which paths are picked, that sign-in files and keys never are (also inside game
|
||||
folders), links never followed, the manifest and its checksums, restore checks
|
||||
after a reset (game not installed, no Proton folders yet, not signed in), that a
|
||||
restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
(`..`, absolute paths, links, other games' folders) writing nothing, backup
|
||||
names that try to leave the backups folder, and the exact Game Mode command.
|
||||
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
|
||||
the server, deletes it and restores it. Neither involves a real headset.
|
||||
|
||||
Isolated session tests in the same suite reproduce full Plasma plus gamescope,
|
||||
Game Mode with and without the nested desktop, conflicting/transitional targets,
|
||||
missing targets on older builds, failed/timed-out systemd reads, selector
|
||||
compatibility, and the confirmed Return action with SSH mocked. Run them with
|
||||
`python3 -m unittest discover -s tests -p test_backup.py -v`.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Battery and health monitoring
|
||||
|
||||
Open **Control Center → Battery & thermal health**, or expand **System
|
||||
information**, to read the Frame's charge, charging state, signed battery
|
||||
power, battery temperature and driver-reported health. Optional readings show
|
||||
charge cycles, full charge capacity, design capacity and full / design capacity.
|
||||
The existing battery chip also shows charging state and temperature.
|
||||
|
||||
These are read-only readings from the Linux host's `/sys/class/power_supply`,
|
||||
not Android's container battery view. The probe prefers the documented Frame
|
||||
fuel gauge, `max1720x_bat_7-36`, over other battery nodes, excludes peripheral
|
||||
(`scope=Device`) and absent batteries, and otherwise uses a deterministic
|
||||
battery fallback. The UI shows the selected source path.
|
||||
|
||||
| Reading | Linux attribute | Interpretation |
|
||||
|---|---|---|
|
||||
| Charge | `capacity` | Integer 0–100 percent; zero is a real reading |
|
||||
| Charging state | `status` | Charging, Full, Discharging or Not charging; missing/unknown states stay unavailable |
|
||||
| Battery power | `voltage_now × current_now` | Microvolts and microamps converted to signed watts; positive means charging on the Frame |
|
||||
| Temperature | `temp` | Tenths of a degree Celsius, including valid negative readings |
|
||||
| Battery health | `health` | Driver status such as Good or Overheat; not a wear percentage |
|
||||
| Charge cycles | `cycle_count` | Driver-reported count, including zero |
|
||||
| Full/design capacity | `charge_full`, `charge_full_design` | Microamp-hours converted to mAh |
|
||||
| Full / design capacity | Ratio of those two capacities | Fuel-gauge estimate; not a diagnosis or a guaranteed runtime |
|
||||
| Time estimate | `time_to_full_now`, `time_to_empty_now` | Seconds; displayed only for the corresponding charging/discharging state |
|
||||
|
||||
Unavailable means the field is absent, unreadable or invalid. One missing
|
||||
sensor does not hide the others. Zero design capacity cannot produce a ratio. Time estimates outside 1 second
|
||||
to 7 days and battery temperatures outside −273.15 to 200 °C are rejected
|
||||
as invalid readings; these bounds are not thermal warning thresholds.
|
||||
The ratio may exceed 100% because full capacity is a gauge estimate, so the app
|
||||
does not clamp it or invent a wear warning. The app does not estimate health
|
||||
from charge percentage or assign temperature thresholds. Existing thermal
|
||||
alerts use the kernel's own hot/critical trip points.
|
||||
|
||||
A connected charger does not necessarily mean charging: the Frame can still
|
||||
drain under load or sit idle near full charge. A 100% capacity reading alone
|
||||
does not prove the driver reports Full. Missing status is explicitly unknown;
|
||||
near-zero measured battery power with a charger is shown as not charging.
|
||||
Failed, stale or disconnected status samples clear Control Center readings.
|
||||
|
||||
**No charge controls are provided.** Frame's SteamOS charge-limit backend is
|
||||
unsupported; see [charge-limit research](charge-limit.md). Monitoring never
|
||||
writes to power supplies, pauses charging, changes current or sets a limit.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/test_battery.py` uses temporary sysfs-like files for conversion,
|
||||
malformed/missing values, permission failures, source selection and partial
|
||||
sensor support. `tests/control_center.cjs` exercises charging states, optional
|
||||
metrics and failed/stale/disconnected sample handling. These tests do not
|
||||
connect to a headset. On 2026-10-24, the PR's uninstalled parser was also
|
||||
piped over SSH to a real Frame running SteamOS 0.5.6 (build
|
||||
`20261015.6141207`). Its read-only sysfs result selected
|
||||
`max1720x_bat_7-36` and reported 99% charge, Not charging, 24.9 °C,
|
||||
Good driver health, 14 cycles, 2,920 mAh full and 2,700 mAh design capacity
|
||||
(108.1% fuel-gauge estimate). The Control Center UI was not run against this
|
||||
headset; Windows and macOS were not tested. Optional fields are not claimed to
|
||||
be available on every firmware build.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Battery monitoring now selects the Frame fuel gauge predictably, handles
|
||||
missing or invalid readings, and shows optional cycle count, full/design
|
||||
capacity and estimated capacity health in Control Center. Unknown charging
|
||||
status stays explicit. Monitoring is read-only; charge limits remain
|
||||
unsupported on Frame. The read-only parser was checked on a real Frame; the UI was not.
|
||||
@@ -0,0 +1,168 @@
|
||||
# Battery charge limit (80%): research
|
||||
|
||||
**Question:** can Frame Control set an 80% battery charge limit on the Frame
|
||||
over SSH or adb, safely?
|
||||
|
||||
**Verdict: don't build it.** Valve's supported mechanism (the one Steam's own
|
||||
charge-limit setting uses on the Deck) is not configured for the Frame. The
|
||||
only control the Frame has is a vendor charger attribute that Valve's own
|
||||
charging daemon also manages. Research as of 2026-10-23, confirmed with the
|
||||
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
|
||||
|
||||
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
|
||||
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
|
||||
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
|
||||
- Power supplies: `max1720x_bat_7-36` (Battery, driver `max1720x`),
|
||||
`pm8550b-charger` (driver `qcom,pm8550b-charger`) and the USB-C `tcpm-source-psy-…`.
|
||||
No `charge_control_end_threshold` anywhere; `pm8550b-charger` has
|
||||
`charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`).
|
||||
- `deckard-charger.service` (Deckard Battery Charger service) is enabled and running.
|
||||
|
||||
For safe read-only readings in Control Center, see [battery and health
|
||||
monitoring](battery-health.md). It does not provide charging controls.
|
||||
|
||||
## How Steam does it on other devices
|
||||
|
||||
- Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in
|
||||
SteamOS 3.7.8 (beta client 2025-05-08)
|
||||
([GamingOnLinux](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)).
|
||||
- Steam calls SteamOS Manager on the session bus:
|
||||
`com.steampowered.SteamOSManager1.BatteryChargeLimit1`, with properties
|
||||
`MaxChargeLevel` (`i`, read/write; `-1` resets to the default) and
|
||||
`SuggestedMinimumLimit` (`i`, read-only). The CLI is
|
||||
`steamosctl get-max-charge-level` / `set-max-charge-level` /
|
||||
`suggested-minimum-charge-limit`. Source: `data/interfaces/com.steampowered.SteamOSManager1.xml`
|
||||
and `steamos-manager/src/bin/steamosctl.rs` in
|
||||
[holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager),
|
||||
read at commit `83981ab` (2026-10-21).
|
||||
- The root daemon writes the value to sysfs only if the device's TOML in
|
||||
`data/devices/` has a `[battery_charge_limit]` section. Two methods exist
|
||||
(`steamos-manager/src/power.rs`, `BatteryChargeLimitMethod`):
|
||||
- `hwmon_attribute`: the Deck writes `max_battery_charge_level` on
|
||||
`steamdeck_hwmon` (`data/devices/steam-deck.toml`).
|
||||
- `acpi_sb`: ROG Ally, MSI Claw and others write the standard
|
||||
`charge_control_end_threshold` on the ACPI battery
|
||||
(`/sys/bus/{platform,acpi}/drivers/…battery…/PNP0C0A:00/…/power_supply/*/`).
|
||||
On ARM there's no ACPI, so this path can't match there either.
|
||||
- With no `[battery_charge_limit]` section, the interface fails with
|
||||
`UnknownInterface` (Steam can still show its toggle on unsupported hardware:
|
||||
[steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044)).
|
||||
|
||||
## What the Frame has
|
||||
|
||||
- **Upstream `data/devices/steam-frame.toml`** (matched by
|
||||
`dt.compatible = "qcom,sm8650"`, variant `Deckard`) configures GPU clocks
|
||||
and performance profiles only. It has **no `[battery_charge_limit]`**, so
|
||||
SteamOS Manager on the Frame has no charge-limit backend. Its git history
|
||||
(2026-09-10 to 2026-10-12) never added one.
|
||||
- **Battery driver:** the Frame's battery is
|
||||
`/sys/class/power_supply/max1720x_bat_7-36`, and the charger is a
|
||||
`tcpm-source-psy-…` USB-PD supply ([how-the-frame-works.md](how-the-frame-works.md)).
|
||||
Both were verified 2026-09-25, and `ui/frame_status.py` `battery()` and
|
||||
`power_source()` read them. The Maxim MAX1720x is a **fuel gauge** and
|
||||
can't control charging. Mainline `drivers/power/supply/max1720x_battery.c`
|
||||
exposes only read-only properties (capacity, voltage, current, temp,
|
||||
charge_full…), with no `charge_control_end_threshold`. Valve's build is
|
||||
patched: it reports `time_to_full_now`, which mainline doesn't have. Even
|
||||
so, a charge stop would have to come from the separate charger IC, not this
|
||||
node.
|
||||
- **Not the qcom_battmgr path.** Some other SM8650/SM8750 handhelds get
|
||||
`charge_control_end_threshold` from Qualcomm's `qcom_battmgr`
|
||||
(pmic_glink) driver, clamped at 55% or above. A third-party distro enabled
|
||||
Steam's limit on those by patching steamos-manager
|
||||
([armada-os/armada#758](https://github.com/armada-os/armada/pull/758)). The
|
||||
Frame's battery isn't exposed by `qcom_battmgr`, so that doesn't carry over.
|
||||
(Unverified: whether a `qcom_battmgr` node also exists on the Frame. The
|
||||
read-only checks below answer this.)
|
||||
- **What third parties found:**
|
||||
[y0usaf/frame-charge](https://github.com/y0usaf/frame-charge) reports that
|
||||
the Frame has no `charge_control_end_threshold` and falls back to a vendor
|
||||
`charging_pause` attribute. It also reports that `constant_charge_current`
|
||||
changes on its own, which it attributes to Valve's `deckard_charge_daemon`
|
||||
(`deckard-charger.service`). By its own README, that tool has only run its
|
||||
read-only `status` on a Frame and has **not** been tested against real
|
||||
charging. Two questions are still open: whether pausing stops the current,
|
||||
and whether Valve's daemon undoes it.
|
||||
|
||||
## Why not build it now
|
||||
|
||||
- **No supported endpoint.** The only Valve-supported interface,
|
||||
`BatteryChargeLimit1`, isn't backed on the Frame. Writing sysfs ourselves
|
||||
would bypass Steam and SteamOS Manager, and it needs root (`sudo -S` with
|
||||
the user's password).
|
||||
- **A rival owner.** Valve's charge daemon already adjusts charging
|
||||
parameters. Toggling `charging_pause` underneath it could be undone without
|
||||
warning, fight it in a loop, or leave charging paused.
|
||||
- **Stuck at a low charge.** If the app or the Frame dies while charging is
|
||||
paused, the Frame could sit at a low charge on the charger. Whether a
|
||||
reboot clears `charging_pause` is unknown.
|
||||
- **The UI would mislead.** Steam's setting (if it shows at all) wouldn't
|
||||
reflect our limit.
|
||||
- **adb doesn't help.** Wi-Fi adb (`frame:5555`) reaches the Lepton Android
|
||||
container only. USB-C adb reaches the Linux side as `steamos` (non-root, the
|
||||
same as SSH, [ssh.md](ssh.md)). Neither adds a charging-control route.
|
||||
Android's `dumpsys battery` in Lepton is a container view, not the hardware.
|
||||
|
||||
**Revisit when** a SteamOS update adds `[battery_charge_limit]` to
|
||||
`steam-frame.toml` (or `steamosctl get-max-charge-level` stops returning
|
||||
`UnknownInterface` on the Frame). Then the feature is just a SteamOS Manager
|
||||
call:
|
||||
|
||||
- Read: `steamosctl get-max-charge-level`
|
||||
- Set: `steamosctl set-max-charge-level 80`
|
||||
- Undo: `steamosctl set-max-charge-level -- -1`
|
||||
|
||||
That runs as `steamos` with no sudo. It's the same value Steam's own
|
||||
Settings → Power shows, so both stay in sync.
|
||||
|
||||
## Headset checks (read-only first)
|
||||
|
||||
Run these over SSH as `steamos`. Steps 1–4 only read.
|
||||
|
||||
```sh
|
||||
# 1. Does SteamOS Manager offer the interface on this build?
|
||||
steamosctl get-max-charge-level; steamosctl suggested-minimum-charge-limit
|
||||
busctl --user introspect com.steampowered.SteamOSManager1 \
|
||||
/com/steampowered/SteamOSManager1 | grep -i -A3 ChargeLimit
|
||||
grep -n battery_charge_limit /usr/share/steamos-manager/devices/*frame* 2>/dev/null
|
||||
|
||||
# 2. Which power supplies exist, and which drivers back them?
|
||||
for d in /sys/class/power_supply/*; do
|
||||
echo "== $d ($(cat $d/type)) driver=$(basename "$(readlink -f $d/device/driver)" 2>/dev/null)"
|
||||
ls $d
|
||||
done
|
||||
|
||||
# 3. Any charge-control attributes, with their values and permissions?
|
||||
ls -l /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
|
||||
grep -H . /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
|
||||
|
||||
# 4. What is Valve's charger daemon, and what does it touch?
|
||||
systemctl status deckard-charger.service --no-pager
|
||||
systemctl cat deckard-charger.service
|
||||
journalctl -b -u deckard-charger.service --no-pager | tail -50
|
||||
```
|
||||
|
||||
If step 1 succeeds on some build, test `steamosctl set-max-charge-level 80`
|
||||
on a charger at a charge above 80%. Watch `current_now` on the
|
||||
`max1720x_bat_*` node fall to about 0, then `steamosctl set-max-charge-level -- -1` and
|
||||
confirm charging resumes. Do this on a supervised Frame only. If only step 3
|
||||
finds a vendor attribute, **stop**: don't write it, and leave the question
|
||||
with Valve.
|
||||
|
||||
## Sources
|
||||
|
||||
- steamos-manager source, commit `83981ab` (2026-10-21):
|
||||
`data/devices/steam-frame.toml`, `data/devices/steam-deck.toml`,
|
||||
`data/interfaces/com.steampowered.SteamOSManager1.xml`,
|
||||
`steamos-manager/src/power.rs`, `steamos-manager/src/manager/user.rs`.
|
||||
[gitlab.steamos.cloud/holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager)
|
||||
- Mainline [`max1720x_battery.c`](https://github.com/torvalds/linux/blob/master/drivers/power/supply/max1720x_battery.c)
|
||||
- [ValveSoftware/steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044):
|
||||
the Steam toggle shows on unsupported hardware
|
||||
- [armada-os/armada#758](https://github.com/armada-os/armada/pull/758):
|
||||
`qcom_battmgr` threshold on other SM8650/SM8750 devices
|
||||
- [y0usaf/frame-charge](https://github.com/y0usaf/frame-charge): third-party,
|
||||
untested on real charging
|
||||
- [GamingOnLinux: SteamOS 3.7.7 beta charge limit](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)
|
||||
- Repo: [how-the-frame-works.md](how-the-frame-works.md) (battery and
|
||||
charger nodes), [ssh.md](ssh.md) (adb scope), `ui/frame_status.py`
|
||||
+76
-9
@@ -3,8 +3,9 @@
|
||||
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||
reached at more than one address: a LAN IP at home, another at the office, its
|
||||
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||
tab (key 5) lists them, and the connection pill in the header shows what the
|
||||
app is doing to reach the one in use, step by step, as it happens.
|
||||
tab (key 5) lists them. The connection pill in the header says whether the one in
|
||||
use is connected and how (Tailscale, or the network's name); click it to add,
|
||||
edit or reorder that headset's addresses, or to see each step of connecting.
|
||||
|
||||
The code is in three modules, all stdlib-only Python on your computer:
|
||||
|
||||
@@ -57,7 +58,7 @@ and ranks them:
|
||||
5. addresses that only ever worked on other networks;
|
||||
6. Tailscale addresses while Tailscale is off.
|
||||
|
||||
Your order on the Devices tab breaks ties. The best-ranked address that answers
|
||||
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
|
||||
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
||||
still trying. If SSH to the winner fails in a way another address could fix
|
||||
(a different device answered there, or the link dropped), the next one that
|
||||
@@ -72,6 +73,16 @@ status --json`, including the Mac app's own CLI) with buttons to add their
|
||||
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
||||
services and checks `ALIAS.local` and `frame.local`.
|
||||
|
||||
**The pill's dialog** lists the same addresses, with what each one answered, and
|
||||
can add, edit, reorder and remove them without leaving the page you're on. When
|
||||
the headset reports a LAN IP on the same network as this computer and that IP
|
||||
isn't saved, it offers to add it. The offer puts the address first in the list,
|
||||
so on that network it wins over the Tailscale name; away from home the Tailscale
|
||||
name still leads. A new or edited address is tested straight away. While
|
||||
connected, **Reconnect** applies your changes now rather than at the next
|
||||
connection: it tries the addresses again, ranked as above, and the best-ranked
|
||||
one that answers promptly wins. It doesn't pick a particular address.
|
||||
|
||||
## Networks
|
||||
|
||||
A network is told apart by its default gateway: the router's IP address plus its
|
||||
@@ -86,6 +97,12 @@ The app rereads the gateway every 5 seconds and Tailscale's state every
|
||||
|
||||
## The connection, stage by stage
|
||||
|
||||
When a connection fails, the header pill keeps its compact offline state and
|
||||
retry countdown; its tooltip and connection dialog contain the full reason.
|
||||
Devices shows a short reason in the headset row (with the full reason in its
|
||||
tooltip) and a wrapping recovery explanation in the selected headset's details.
|
||||
**Use this headset** also selects that headset's details.
|
||||
|
||||
The connector runs in the server (`frame_link.Link`) and moves through:
|
||||
|
||||
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
|
||||
@@ -104,7 +121,9 @@ connection is an SSH ControlMaster that every command shares; when it dies (the
|
||||
headset slept or left the network) the connector notices and starts again. On
|
||||
Windows, where OpenSSH can't share a connection, the same handshake runs once
|
||||
and each command then connects on its own; a command that can't reach the
|
||||
headset makes the connector start again.
|
||||
headset makes the connector start again. There ssh's stderr goes to a file that
|
||||
the connector reads as it grows, not a pipe: Windows' OpenSSH can block writing
|
||||
to a redirected pipe.
|
||||
|
||||
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
|
||||
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
|
||||
@@ -121,9 +140,57 @@ lease that moved) would look like a new host there. The app keeps one known_host
|
||||
file per headset instead, so saving or forgetting one headset's key never touches
|
||||
another's: a different device answering at one of its
|
||||
addresses is refused, and the pill says so. A headset's first connection trusts
|
||||
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
|
||||
headset has a new key; **Forget identity** on the Devices tab lets the next
|
||||
connection save the new one.
|
||||
the key it shows, as Set Up Connection does: while a headset has never had an
|
||||
identity here, every ssh command gets `StrictHostKeyChecking=accept-new` (the
|
||||
first contact saves it, whichever command makes it), and from then on
|
||||
`StrictHostKeyChecking=yes`. `accept-new` never replaces a saved key, so it
|
||||
can't hide a change. The saved key's fingerprint is also recorded in
|
||||
`devices.json` (`identity`), along with a flag that never clears
|
||||
(`identity_established`), as soon as ssh saves the key: even if the login is then
|
||||
refused. A headset counts as having had an identity if the flag is set, a
|
||||
fingerprint is recorded, its pin file exists at all, or a connection to it ever
|
||||
worked (older files get the flag from that when loaded, so editing or removing
|
||||
the address that worked doesn't undo it); then
|
||||
a pin file that's deleted, empty, unreadable or not parseable **fails closed**
|
||||
(*Checking the headset's identity* fails, "missing or can't be read") rather than
|
||||
trusting whatever answers, and only **Check its key…** saves a key again. It
|
||||
says when the key it reads matches the recorded fingerprint.
|
||||
|
||||
**When the key changes** (SteamOS reinstalled or reset, a new headset at the
|
||||
same address, or a different device answering there), nothing is accepted by
|
||||
itself. The connection fails at *Checking the headset's identity*; the banner
|
||||
and the headset on the Devices tab say so, with **Check the new key…** (also
|
||||
**Check its key…** on the Devices tab at any time). That reads the key the
|
||||
headset shows now, and nothing more: ssh writes it to a throwaway known_hosts
|
||||
file during the key exchange, before any login, and is stopped as soon as it
|
||||
has. Every other kind of contact is ruled out on the command line, ahead of
|
||||
anything `~/.ssh/config` says: no authentication of any kind, no agent
|
||||
(`IdentityAgent=none`, `ForwardAgent=no`), no X11 or port forwarding, no local
|
||||
or remote command (`-N`), no terminal, and `-F none`. Through a ProxyJump host
|
||||
it builds that hop itself, as a ProxyCommand whose ssh forwards nothing and runs
|
||||
no local command (ProxyJump's own ssh would take `ForwardAgent` and
|
||||
`LocalCommand` from `~/.ssh/config`); it still logs in to the jump host as
|
||||
configured. Every word of that ProxyCommand is quoted for the shell that runs it
|
||||
(and `%` doubled for ssh's own tokens), the forward is `[%h]:%p` so an IPv6
|
||||
headset works, and an IPv6 jump host is passed bare with its port. Through a ProxyCommand from `~/.ssh/config`, or several jump hosts,
|
||||
it refuses, since those can't be made safe from here: add an address it can
|
||||
reach directly, or compare the key by hand. It shows the key's SHA256 fingerprint next to
|
||||
the saved one, with the command that prints it on the headset
|
||||
(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`, in Konsole on its desktop).
|
||||
**This is my headset: trust its new key** then saves exactly that key, replacing
|
||||
the old one, and reconnects. The server keeps the key it showed for 10 minutes
|
||||
and saves it only if the page sends back the same fingerprint, and only while
|
||||
the headset's port, user and that address are unchanged; it never saves whatever
|
||||
answers next. The API's old `forget-identity`, which deleted the pin so that the
|
||||
next contact saved any key unseen, is refused. Set Up Connection can't fix a changed key: it checks your
|
||||
own `known_hosts`, not the headset's file.
|
||||
|
||||
**When the headset refuses this computer's key** (`Permission denied
|
||||
(publickey,…)`), the connection stops at *Logging in*, and the banner and the
|
||||
Devices tab offer **Set Up Connection…**, which pairs this computer again.
|
||||
|
||||
Neither is reported as a fault: the diagnostics report each kind at most once
|
||||
a session ([privacy.md](privacy.md)).
|
||||
|
||||
## One server at a time
|
||||
|
||||
@@ -144,7 +211,7 @@ All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
|
||||
| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
|
||||
| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
|
||||
| `GET /api/devices/mdns?id=` | Headsets found on this network |
|
||||
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `name-network`, `setup` (alias, optional host), `retry` |
|
||||
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `identity-scan` (read the key the headset shows: `scan` with `type`, `fingerprint`, `host`, `saved`, `recorded`, `matches`, `recorded_match`), `identity-trust` (`fingerprint`: save the key `identity-scan` showed), `forget-identity` (refused: use the two before), `name-network`, `setup` (alias, optional host), `retry` |
|
||||
|
||||
Every host, alias and user is checked against strict patterns before it's
|
||||
stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
|
||||
@@ -161,7 +228,7 @@ share the format later (it still connects to one host; see
|
||||
```json
|
||||
{"version": 1, "active": "f67f8b7e",
|
||||
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
|
||||
"identity_files": ["~/.ssh/id_ed25519_frame"],
|
||||
"identity_files": ["~/.ssh/id_ed25519_frame"], "identity": ["SHA256:…"], "identity_established": true,
|
||||
"addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
|
||||
"networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
|
||||
"networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"date": "2026-10-20T03:53:25Z",
|
||||
"build_id": "20260925.6191901",
|
||||
"app": "F-Droid Basic 2.0.2 (org.fdroid.basic, version code 2000052)",
|
||||
"input": "One real APK plus synthetic main and patch OBBs named with older version code 2000050",
|
||||
"sibling_discovery": true,
|
||||
"automatic_import": "passed",
|
||||
"closed_after_import": true,
|
||||
"independent_readback": [
|
||||
"5023b41e7ac80f8b26a868db1f66884976bf864fab81a070ad37539f3481b352 /sdcard/Android/obb/org.fdroid.basic/main.2000050.org.fdroid.basic.obb",
|
||||
"aecbed7433e81aaeef40942069d03ad23130f09d93b7e78bbe224865a9573157 /sdcard/Android/obb/org.fdroid.basic/patch.2000050.org.fdroid.basic.obb"
|
||||
],
|
||||
"cleanup": {
|
||||
"test_install_removed": true,
|
||||
"running_instances_before": {},
|
||||
"running_instances_after": {},
|
||||
"battery_before": [
|
||||
"100",
|
||||
"Full",
|
||||
"Charging"
|
||||
],
|
||||
"battery_after": [
|
||||
"99",
|
||||
"Discharging",
|
||||
"Charging"
|
||||
],
|
||||
"shared_lock_released": true
|
||||
},
|
||||
"limits": [
|
||||
"No headset rendering or gameplay assertion; headset unworn.",
|
||||
"Synthetic expansion files verify transport and persistence, not Resident Evil 4 compatibility.",
|
||||
"A game that exits before its Lepton instance can accept data was not tested."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"date_utc": "2026-10-24T07:54:37Z",
|
||||
"frame_control_tag": "v0.4.16",
|
||||
"steamos_version": "0.5.6",
|
||||
"steamos_build": "20261015.6141207",
|
||||
"host": "lxso2",
|
||||
"interface": "source UI served by a temporary private Frame Control server, opened in T3 Code's desktop browser",
|
||||
"action": "Clicked the visible Export button for Steam's retained real saved clip in Capture & Live",
|
||||
"clip_id": "10230640/clips/clip_13009719723623972864_20261024_072115",
|
||||
"api_observed": {
|
||||
"post_clips_export_http": 200,
|
||||
"get_job_http": 200,
|
||||
"get_clips_http": 200
|
||||
},
|
||||
"ui_result": "Saved clip_13009719723623972864_20261024_072115.mp4 to ~/Videos/SteamFrame; button changed to Export again",
|
||||
"output": {
|
||||
"path": "~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4",
|
||||
"bytes": 159829,
|
||||
"sha256": "f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1",
|
||||
"matches_previously_fully_decoded_export": true,
|
||||
"duration_seconds": 10.288532,
|
||||
"video": "H.264 1920x1080",
|
||||
"audio": "AAC stereo"
|
||||
},
|
||||
"cleanup": {
|
||||
"temporary_server_stopped": true,
|
||||
"shared_device_lock_released": true,
|
||||
"battery_percent_after": 99,
|
||||
"desktop_mode_active_after": false,
|
||||
"test_title_created": false,
|
||||
"steam_source_clip_retained": true
|
||||
},
|
||||
"limits": [
|
||||
"The test used the source UI, not a packaged desktop installer.",
|
||||
"The retained source clip was nearly black; visible gameplay capture remains unverified.",
|
||||
"Perceptual audio/video sync, long clips, trims and automatic copying were not tested."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"date_utc": "2026-10-24",
|
||||
"version": "v0.4.17",
|
||||
"steam_os_version": "0.5.6",
|
||||
"steam_os_build": "20261015.6141207",
|
||||
"result": "PASS: the real Steam-saved clip exported beyond 60 seconds",
|
||||
"method": "Steam manual recording of a reserved test title, then Frame Control's production frame_clips.export_clip path; no packaged GUI button click",
|
||||
"source": {
|
||||
"kind": "saved Steam clip",
|
||||
"duration_seconds": 100.801,
|
||||
"chunks": 68,
|
||||
"gaps": 0
|
||||
},
|
||||
"export": {
|
||||
"format": "MP4",
|
||||
"bytes": 1377849,
|
||||
"sha256": "82ef31f65e6933d122bc326d664beb2eb2e3ba6969f26c0032552e80fbe8d823",
|
||||
"duration_seconds_ffprobe": 100.833335,
|
||||
"video": "H.264, 1920x1080, 6049 frames",
|
||||
"audio": "AAC, 48000 Hz, stereo",
|
||||
"full_ffmpeg_decode": "passed without errors"
|
||||
},
|
||||
"cleanup": "Restored Steam recording mode to its prior off setting; stopped and removed only the reserved title and its processes; no recording or running Steam apps remained; Game Mode active, battery 100%, shared test lock released",
|
||||
"limits": [
|
||||
"This exercised the production export code path, not a packaged GUI Export click.",
|
||||
"Visible gameplay quality, perceptual audio/video sync, trims and longer recordings were not checked.",
|
||||
"The newly saved Steam source clip was retained; no user recording was deleted."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"command": "scripts/frame-power-sample.sh --count 2 --interval 1",
|
||||
"exitCode": 0,
|
||||
"stopped": null,
|
||||
"lockPolicy": "Existing sampler checked local and remote /tmp/frame-test.lock before reading; no lock acquired, bypassed or removed. Read-only run only.",
|
||||
"samples": [
|
||||
{
|
||||
"time": 1792828506.4317346,
|
||||
"osBuild": "20261015.6141207",
|
||||
"elapsedSeconds": 0.057539,
|
||||
"battery": {
|
||||
"battery.percent": {
|
||||
"value": 99,
|
||||
"unit": "%",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.status": {
|
||||
"value": "Not charging",
|
||||
"unit": "state",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.watts": {
|
||||
"value": -0.06,
|
||||
"unit": "W",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.tempC": {
|
||||
"value": 24.0,
|
||||
"unit": "C",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": 1792828507.4357972,
|
||||
"osBuild": "20261015.6141207",
|
||||
"elapsedSeconds": 1.061612,
|
||||
"battery": {
|
||||
"battery.percent": {
|
||||
"value": 99,
|
||||
"unit": "%",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.status": {
|
||||
"value": "Not charging",
|
||||
"unit": "state",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.watts": {
|
||||
"value": -0.05,
|
||||
"unit": "W",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.tempC": {
|
||||
"value": 24.0,
|
||||
"unit": "C",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"limitations": [
|
||||
"No real low-charge or thermal condition induced.",
|
||||
"No native notification delivery verified.",
|
||||
"No worn-headset or native package tests.",
|
||||
"Read-only parser/transport check; no background monitor or power-saving claim."
|
||||
],
|
||||
"browserCheck": {
|
||||
"page": "Production ui/index.html served statically; API calls intentionally unavailable",
|
||||
"viewport": {
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
"bridge": "Synthetic desktop notification bridge; no native OS notification sent",
|
||||
"result": "Initially unchecked and visible only after desktop host/bridge setup. Checkbox enabled through browser. Synthetic 12%, Discharging, -4 W samples at 0/30/60 seconds produced one bridge request, an in-page status and a toast. No horizontal overflow.",
|
||||
"apiLimit": "Static server returned expected 404/501 API errors; not an end-to-end live desktop connection test."
|
||||
},
|
||||
"offlineValidation": [
|
||||
"test_desktop_battery_comfort.py: 2 tests, production JS with monotonic fake clock and notification bridge",
|
||||
"test_comfort*.py: 25 tests",
|
||||
"test_battery.py: 7 tests",
|
||||
"test_power*.py: 36 tests (loopback HTTP test run outside socket-restricted sandbox)",
|
||||
"test_ui_workspace.py: 1 Python wrapper / Node workspace suite",
|
||||
"test_ui_review.py: 1 Python wrapper / 9 Node regression tests, including switch reset and retained cooldown"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# Linux PC host feasibility on lxso2, 2026-10-24
|
||||
|
||||
**Blocked on this host's current desktop environment. No feature enabled and
|
||||
no feature PR opened.** Investigation for [issue #23](https://github.com/saphid/frame-control/issues/23),
|
||||
starting at `4e45648` (main after #154). This concerns flat desktop panels,
|
||||
not Linux PC VR streaming.
|
||||
|
||||
## Decision
|
||||
|
||||
The smallest useful candidate is one explicitly selected **whole screen**,
|
||||
captured through the ScreenCast portal's restricted PipeWire connection,
|
||||
encoded locally with GStreamer/x264, and sent to the existing viewer through
|
||||
the shared SSH controller. Input, individual windows and virtual displays
|
||||
would remain unsupported until separately verified. This candidate was **not
|
||||
implemented**: lxso2 currently cannot exercise the real capture permission path.
|
||||
|
||||
Development packages could unblock compilation, but would not create a desktop,
|
||||
portal backend or consented screen. Synthetic encoding, an Xvfb screen grab or
|
||||
using the Frame as its own host would not verify Linux PC desktop capture.
|
||||
No host packages or services were changed.
|
||||
|
||||
## Evidence
|
||||
|
||||
Host: `lxso2`, Linux x86-64, kernel `6.8.0-139-generic`.
|
||||
|
||||
- `DISPLAY`, `WAYLAND_DISPLAY` and `XDG_SESSION_TYPE` were unset. The user bus
|
||||
was reachable at `/run/user/1000/bus`.
|
||||
- `/run/user/1000/pipewire-0` and `/run/user/1000/wayland-0` did not exist.
|
||||
No processes matched Xorg, Xwayland, Xvfb, GNOME Shell, KWin Wayland, Weston,
|
||||
PipeWire, WirePlumber or xdg-desktop-portal at the process check.
|
||||
- D-Bus `ListNames` returned D-Bus, systemd and anonymous connections, with no
|
||||
`org.freedesktop.portal.Desktop`. `ListActivatableNames` also had no desktop
|
||||
portal. These calls queried D-Bus itself without activating capture services.
|
||||
- `dpkg-query` found no `xdg-desktop-portal*` or `pipewire*` packages.
|
||||
`libgstreamer1.0-0:amd64` was installed at `1.24.2-1ubuntu0.1`, but
|
||||
`gstreamer1.0-tools` was uninstalled. `gst-launch-1.0` and `gst-inspect-1.0`
|
||||
were absent from PATH. Xvfb was installed, but supplies no portal by itself.
|
||||
- `pkg-config --modversion` failed for `gstreamer-1.0`,
|
||||
`gstreamer-app-1.0`, `gstreamer-video-1.0` and `libpipewire-0.3`.
|
||||
`gio-unix-2.0` reported `2.80.0`.
|
||||
- `desktop/bundle/pc-host.so` did not exist.
|
||||
`python3 desktop/build.py --with-x264 --out /tmp/frame-linux-pc-feasibility`
|
||||
exited **1** before compilation: required GStreamer pkg-config modules were
|
||||
missing. No native build or encoder runtime success is claimed.
|
||||
|
||||
Reproduce the non-activating portal checks in the intended desktop user's
|
||||
session, using that session's actual runtime directory and bus:
|
||||
|
||||
```sh
|
||||
gdbus call --session --dest org.freedesktop.DBus \
|
||||
--object-path /org/freedesktop/DBus --method org.freedesktop.DBus.ListNames
|
||||
gdbus call --session --dest org.freedesktop.DBus \
|
||||
--object-path /org/freedesktop/DBus --method org.freedesktop.DBus.ListActivatableNames
|
||||
pkg-config --modversion gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0 gio-unix-2.0
|
||||
```
|
||||
|
||||
## Existing implementation reviewed
|
||||
|
||||
- `ui/frame_pcview.py` selects PCView only on Windows and rejects Linux even
|
||||
when enabled. Linux installers do not carry its native bundle.
|
||||
- `desktop/portal.c` creates a **RemoteDesktop** session, requests pointer and
|
||||
keyboard devices, and selects monitors and windows (`types=3`). It is not
|
||||
ScreenCast-only or screen-only. Its restricted fd/node and fresh-connection
|
||||
handling are reusable.
|
||||
- `ui/frame_pc_agent.py` calls the selection a shared window or screen and
|
||||
picks VA-API when the element is present, falling back to x264. A bounded
|
||||
x264 slice needs explicit encoder policy and unsupported-input behavior;
|
||||
exposing the existing Linux path alone would not meet that scope.
|
||||
- `app/main.js` starts Python with the inherited environment; it does not
|
||||
supply a compositor, portal backend or capture permission.
|
||||
- `.github/workflows/pc-host.yml` installs native dependencies and tests
|
||||
synthetic x264/protocol behavior with fake capture consent/input. This does
|
||||
not establish interactive Linux desktop capture.
|
||||
- Issue #23 and its six existing comments distinguish prior synthetic
|
||||
helper/viewer results from untested Linux desktop capture and consent.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
`python3 -m unittest discover -s tests -p 'test_pc*.py' -v` exited **0**:
|
||||
27 tests ran successfully, with one additional native-class setup skip
|
||||
(`native PC libraries not built on this host`). Coverage includes the compiled
|
||||
C controller trace, Python adapters, grants and default-off/Windows-only gates.
|
||||
It does not prove native GStreamer capture or local-client streaming.
|
||||
|
||||
No fake/local native stream, consent prompt, real desktop capture, Frame viewer
|
||||
run, benchmark, input or window mode was tested in this investigation. No SSH
|
||||
connection to the Frame was needed; no lock was acquired and no headset install,
|
||||
launch, stop or settings change was attempted. Issue #113 was left alone.
|
||||
No issue comments or other public messages were sent.
|
||||
|
||||
## Concrete unblock and next slice
|
||||
|
||||
1. Supply an interactive Linux desktop, physical or VM, with a working
|
||||
ScreenCast portal/backend, PipeWire and accessible consent dialog. Installing
|
||||
the portal frontend alone is insufficient. A VM can establish software
|
||||
capture/x264 behavior while physical-host behavior remains unverified.
|
||||
2. Install the native dependencies listed in `pc-host.yml` in that environment
|
||||
and build a local `--with-x264` bundle. Resolve x264 distribution
|
||||
licensing/source obligations before shipping it: the current build labels
|
||||
that option test-only and the Windows installer omits it.
|
||||
3. Implement a ScreenCast-only session with monitor-only selection, no
|
||||
RemoteDesktop/device request, disabled input in protocol and viewer, hidden
|
||||
window/virtual-display modes, and explicit x264 selection. Preserve the
|
||||
default-off gate and existing Mac/Windows paths.
|
||||
4. Verify consent acceptance/refusal/cancellation, Stop/reopen, connection loss
|
||||
and portal closure with a local client decoding actual captured content.
|
||||
Run the shared benchmark and record results without inferring hardware or
|
||||
physical laser behavior.
|
||||
5. Only after local capture works, acquire the shared Frame lock once and
|
||||
follow [the headset procedure](../testing.md#headset-smoke-test) for a brief
|
||||
viewer test. If occupied, leave the lock alone and defer device testing.
|
||||
|
||||
These are prerequisites and proposed scope, not a ready feature. The Linux
|
||||
host request in #23 remains open.
|
||||
@@ -0,0 +1,122 @@
|
||||
# UEVR from Frame Control, 2026-09-29
|
||||
|
||||
**Verified observations**, with limits below. Same device and software as
|
||||
[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID
|
||||
`20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the
|
||||
Frame's AEST clock. Headset tests ran under the shared
|
||||
`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40,
|
||||
12:44–12:52 and 13:01–13:03. Battery 45 % →
|
||||
93 %, on charge throughout.
|
||||
|
||||
**Not observed:** the VR image, head tracking and controller input. SteamVR
|
||||
reported `activity_level` 3 (user interaction timeout; the headset was not
|
||||
being worn) with `average_target_fps` 1. Every `frame_vrshot.py` stereo
|
||||
capture was a uniform dark frame. Frame submits prove the app is presenting
|
||||
to SteamVR, not that the image is correct.
|
||||
|
||||
## Manual injection (11:13–11:28)
|
||||
|
||||
Game launched with `steam steam://rungameid/1067310`. `UEVRInjector.exe` was
|
||||
started with the game process's Wine variables, and its WPF window drew
|
||||
through DXVK's D3D9. It listed `Drop-Win64-Shipping (pid: 320) (SkyArk
|
||||
(64-bit, PCD3D_SM5))`, a Wine-side process id.
|
||||
|
||||
- `--attach=Drop-Win64-Shipping` didn't inject. The button still read
|
||||
**Inject**, and the saved `OpenVRRadio=True` in `AppData/Local/praydog/…/user.config`
|
||||
was not restored on the next start.
|
||||
- XTest clicks landed on the game: `XQueryPointer` reported child
|
||||
`0x2000001` (the game) at the injector's coordinates, and
|
||||
`XLowerWindow`/`XRaiseWindow`/`XSetInputFocus` didn't change that.
|
||||
- `XSendEvent` button events to the injector window worked. After OpenVR and
|
||||
Inject, `/proc/<game>/maps` contained
|
||||
`drive_c/frame26/uevr/UEVRBackend.dll` and `openvr_api.dll`, next to
|
||||
Proton's `vrclient_x64.dll`. UEVR's log:
|
||||
|
||||
```text
|
||||
[11:17:32.948] Hooking D3D11
|
||||
[11:17:33.020] Hooked DirectX 11
|
||||
[11:17:34.263] Framework initialized
|
||||
[11:17:34.475] [VR] Requested runtime: openvr_api.dll
|
||||
[11:17:39.791] UGameViewportClient::Draw called for the first time.
|
||||
[11:17:39.792] is stereo enabled called!
|
||||
```
|
||||
|
||||
It also logged repeated `Failed to initialize Framework on DirectX 12` (probing
|
||||
before it chose D3D11) and failed Unreal scans, for example `Failed to locate
|
||||
r.EnableStereoEmulation cvar` and `Failed to find FSceneView constructor`.
|
||||
SteamVR loaded UEVR's action manifest and `bindings_oculus_touch.json` for
|
||||
`steam.app.1067310`. `vrcmd --stats` then showed `"key" :
|
||||
"steam.app.1067310"` with `frame_submits` 4439, then 5506 a few seconds later.
|
||||
|
||||
A second session's injector exited within about 20 seconds. The third,
|
||||
started fresh, injected again (`frame_submits` 1412). After the game was
|
||||
stopped, a plain launch had no UEVR modules and no SteamVR scene app. All test
|
||||
files were removed.
|
||||
|
||||
## Through Frame Control (11:33–11:49)
|
||||
|
||||
Local `ui/server.py` against the `frame` alias. First the API, then the
|
||||
page's **VR mod** dialog, driven with a headless Chromium:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| `status` for 2177750 (HL2 VR) | `eligible: false`; `install` refused ("doesn't look like an Unreal Engine game") |
|
||||
| `install` for 1067310 | 3 s. Three downloads, hashes matched, receipt written |
|
||||
| `start`, game running | First run refused: `the UEVR window is 960 px wide, not 625`. It was measured before WPF laid it out; `start` now waits for the width to settle. Rerun: injected, `frame_submits` 27 (36 s) |
|
||||
| `uninstall` while running | Refused: "the game is running; quit it first" |
|
||||
| `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) |
|
||||
| UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes |
|
||||
| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's (the relaunch-on-failure added next is gone with the GUI injector; see below) |
|
||||
| UI: Play in VR ×4 | 4 of 4 injected. The three cold starts took 54, 58 and 58 s; frame submits 56, 71, 80 |
|
||||
| UI: Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipt gone |
|
||||
|
||||
## The GUI injector wasn't reliable (12:02–12:40)
|
||||
|
||||
After review fixes, cold starts through the API with UEVR's own injector:
|
||||
|
||||
- Batches of 4, 6 and 8 runs: 3/4, 4/6 and 6/8 injected. A retry that
|
||||
relaunched the injector didn't rescue a bad session. In both failures of
|
||||
the last batch, the first injector ignored the clicks and the next two
|
||||
never showed a window within 60 s.
|
||||
- Captures of the ignoring injector (`xwd`) showed its first-drawn frame,
|
||||
with OpenVR still unselected. The injector's log had no error.
|
||||
|
||||
Lock released at 12:40 before redesigning.
|
||||
|
||||
## Frame Control's own injector (12:44–12:52)
|
||||
|
||||
`start` now runs `frame_inject.py` under python.org's embeddable Python
|
||||
3.14.7 (SHA-256 `d297e5ff…1f15`, matching python.org's release-file API), in
|
||||
the game's Wine session. The old install was removed and the new one
|
||||
installed without the headset lock, since neither launches anything. The
|
||||
prefix listing after removal matched the pre-install state; the new install
|
||||
is 47 MB.
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| API `start`, cold | Injected in 35 s, `frame_submits` 39. UEVR log: `Hooked DirectX 11`, `Framework initialized`, `Requested runtime: openvr_api.dll`; `config.txt` has `Frontend_RequestedRuntime=openvr_api.dll` |
|
||||
| API `start` ×8, cold | **8/8** injected, 33–34 s each; `frame_submits` 33–42 at the check |
|
||||
| UI: dialog while running | "UEVR is running in the game now." Remove disabled (screenshot in `docs/img/uevr-dialog.png`) |
|
||||
| UI: Remove → Install → Play in VR → Remove | Each worked; Play in VR injected (`frame_submits` 106 a few seconds later) |
|
||||
| After the last Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipts gone; no game or `python.exe` processes. The empty `/tmp/frame-control-mods.lock` was deleted by hand (tmpfs) |
|
||||
|
||||
Battery 98–100 % on charge. Lock released at 12:52.
|
||||
|
||||
## After the second review (13:01–13:03)
|
||||
|
||||
`frame_inject.py` now declares every Win32 signature, checks each result,
|
||||
frees its remote buffers and closes its handles; uninstall accepts only our
|
||||
folder and UEVR's own settings folders. Rechecked: install; three cold starts
|
||||
injected in 35, 34 and 33 s (`frame_submits` 42, 38, 47), UEVR logging
|
||||
`Framework initialized` and `Requested runtime: openvr_api.dll`; uninstall
|
||||
left `drive_c`, `AppData/Roaming` and `AppData/Local` as before, with no cache,
|
||||
receipts or processes. Frame Control's injector total: 12 of 12 cold starts
|
||||
through the API, plus the UI run.
|
||||
|
||||
## Left on the Frame
|
||||
|
||||
- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still
|
||||
installed; nothing else from this work is left.
|
||||
- No `UEVRInjector.exe` or game processes were left running. Steam and
|
||||
SteamVR were running at the end.
|
||||
- The test lock was released at 13:03.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Panel discovery investigation, 2026-10-24
|
||||
|
||||
Base: v0.4.16 (`040581a`). Scope: issue #73, Desktop Capture and Control.
|
||||
|
||||
## Evidence and change
|
||||
|
||||
Issue #73 reports SteamOS 0.4.5 with its desktop visibly running, without a
|
||||
Frame Control version or steps. The maintainer's 2026-10-12 comment identifies
|
||||
the duplicate `loadPanels` UI function fixed in 0.4.3 and requests the exact
|
||||
remaining message and SteamOS build if it persists. There is no reporter
|
||||
follow-up. At this base, the functions are distinct and the existing Node
|
||||
regression checks their uniqueness and the Desktop GET request.
|
||||
|
||||
Read-only source inspection found a remaining diagnostic ambiguity:
|
||||
`read_props()` returns `{}` for failed/timed-out X queries and missing root
|
||||
properties. `panels()` treated that like a confirmed empty focusable-window
|
||||
list. With no detected Plasma process it claimed no panel was open; with Plasma
|
||||
it claimed SteamOS was not listing the desktop. Neither claim follows from an
|
||||
unreadable list.
|
||||
|
||||
The change checks for the presence of `GAMESCOPE_FOCUSABLE_WINDOWS` before
|
||||
those claims. An absent list now reports unknown panel state, suggests checking
|
||||
SteamVR and retrying/Headset view, and asks for the exact message and SteamOS
|
||||
version if persistent. Missing-tool and whole-run deadline explanations retain
|
||||
precedence. No new probes, retries, timeouts, input or capture routes are added.
|
||||
|
||||
## Real-device boundary
|
||||
|
||||
A preliminary read-only SSH inspection returned SteamOS 0.5.6, BUILD_ID
|
||||
`20261015.6141207`, battery 99%, discharging, and X0/X1 socket names. It made
|
||||
no headset changes. The shared lock was absent at that moment.
|
||||
|
||||
The subsequent atomic `mkdir /tmp/frame-test.lock` failed with `File exists`.
|
||||
The guarded command exited with status 3 before the session, X-property or
|
||||
panel-helper probes ran. The other task's lock was left alone. No application
|
||||
was installed, started, stopped or focused; no headset settings changed.
|
||||
|
||||
That implementation probe was blocked by the occupied shared device. The
|
||||
coordinator later supplied a separate narrow read-only observation on SteamOS
|
||||
0.5.6, BUILD_ID `20261015.6141207`, at battery 99%, with the shared lock absent
|
||||
and both `xprop` and `xwininfo` installed:
|
||||
|
||||
- In **Game Mode**, `DISPLAY=:0 xprop -root GAMESCOPE_FOCUSABLE_WINDOWS
|
||||
GAMESCOPE_FOCUSED_WINDOW` returned both properties **present but empty**.
|
||||
- On `DISPLAY=:1`, both properties were reported **not found**.
|
||||
|
||||
This observation distinguishes a confirmed empty list on `:0` from absent
|
||||
properties on `:1`. It does **not** reproduce #73's Desktop Mode report on
|
||||
SteamOS 0.4.5, and it does **not** test this PR's code on the headset. It is a
|
||||
coordinator-supplied observation, separate from the blocked implementation probe
|
||||
above; no new headset probe was run for this follow-up.
|
||||
|
||||
Remaining limit: there is no reproduction establishing a further
|
||||
capture/discovery fix for the reported Desktop Mode/build combination. This PR
|
||||
improves a demonstrated diagnostic error; it must not close #73 as a verified
|
||||
new capture fix.
|
||||
|
||||
## Offline verification
|
||||
|
||||
Focused Python regressions distinguish absent, focus-only, failed/timed-out X
|
||||
queries, confirmed empty and desktop-running lists. Existing tests cover
|
||||
missing tools, alternate displays, successful discovery and the 12-second
|
||||
deadline. The production page's Node behavior test verifies that both Capture
|
||||
and Control preserve the unreadable-list advice. These use fake X/HTTP/DOM
|
||||
boundaries and do not prove real SteamOS 0.4.5 capture or physical input.
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"base": "c7489a8 (v0.4.14)",
|
||||
"feature": "Desktop Tools power and heat observations",
|
||||
"automated": {
|
||||
"targeted": "python3 -m unittest discover -s tests -p 'test_power*.py': 35 passed",
|
||||
"uiAndResourceGuard": "node --test tests/power_desktop.cjs: 7 passed",
|
||||
"desktopApp": "node --test app/test/*.test.js: 37 passed",
|
||||
"packaging": "Materialized ui/scripts resources selected by app/package.json; isolated Python -I imported frame_power, constructed remote payload including frame_status, and read idle status. Missing resource detection tested. Shared phone bundle import passed.",
|
||||
"syntax": "Python compilation, sampler shell syntax and git diff --check passed",
|
||||
"fullSuite": "python3 -m unittest discover -s tests -v: 1363 tests, OK (skipped=15), 153.274 seconds",
|
||||
"transientFailure": "An earlier full run had one nested sandbox-test failure: the existing OneServer second-server child exited -11 (SIGSEGV), expected 1. Isolated sandbox rerun passed (6 tests), then the complete suite passed. No unrelated server-lifecycle code was changed."
|
||||
},
|
||||
"browser": {
|
||||
"environment": "T3 collaborative Chromium preview, Linux, local Python Handler server; synthetic observation subprocess at accelerated one-second intervals; normal headset SSH disabled during start/stop/download verification",
|
||||
"observations": [
|
||||
"Opened #powerReport and verified labeled duration selector, disabled Stop/downloads before start and accessible sample progress.",
|
||||
"Started a synthetic observation and observed 11 of 12 samples in progress; completion allowed another start.",
|
||||
"Started again, observed progress, stopped, and saw 7 of 12 samples retained with explicit incomplete state and original frame alias.",
|
||||
"Clicked HTML download and saw the UI acknowledge the request; T3's download list did not record the blob download, so filesystem save was not verified.",
|
||||
"Fetched all three authenticated export endpoints in the browser: HTML 200 (7599 characters), JSON 200 (2618 characters, 7 samples), CSV 200 (977 characters); each retained the stop reason."
|
||||
]
|
||||
},
|
||||
"realFrame": {
|
||||
"lock": "ssh frame 'mkdir /tmp/frame-test.lock' succeeded before real-device probe; this task removed only that owned empty lock with rmdir after verification",
|
||||
"probe": "Ran the production desktop Observation using strict, batch SSH to frame. The first attempt exposed a missing structured reason on early remote lock refusal; fixed and reran successfully.",
|
||||
"result": "0 of 12 samples; running=false; stopped='Smoke session active (/tmp/frame-test.lock); no sensors read'. Exported HTML included the same incomplete reason.",
|
||||
"scope": "Lock refusal only. No successful real sensor session, installation, app launch/stop, power control or thermal control was performed for this feature."
|
||||
},
|
||||
"limitations": [
|
||||
"Native Electron installers were not built or launched; resource staging and build-hook contracts were tested.",
|
||||
"One latest report is retained only in server memory. Starting again, restarting the server or quitting discards it; the UI warns users to download first.",
|
||||
"Stopping ends only the owned local SSH process. The bounded remote reader discovers closure on its next write; immediate remote cancellation is not guaranteed.",
|
||||
"Phone-hosted observation starts are unsupported; the shared server bundle still imports without desktop sampler resources."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"scope": "Read-only three-sample HTML export and browser download check; no installs, app launches, or remote process-control commands",
|
||||
"preflight": {
|
||||
"smokeLock": "absent",
|
||||
"version": "0.5.6",
|
||||
"build": "20261015.6141207"
|
||||
},
|
||||
"command": "scripts/frame-power-sample.sh --count 3 --interval 1 --html /tmp/frame-power-export-check.html",
|
||||
"exitCode": 0,
|
||||
"sampleCount": 3,
|
||||
"metricCount": 91,
|
||||
"unavailableGroups": [
|
||||
"proximity"
|
||||
],
|
||||
"browser": {
|
||||
"engine": "T3 collaborative Chromium preview",
|
||||
"widths": [
|
||||
1280,
|
||||
390
|
||||
],
|
||||
"jsonDownloadMatches": true,
|
||||
"csvDownloadMatches": true,
|
||||
"csvDataRows": 276,
|
||||
"pageScripts": 0,
|
||||
"horizontalOverflow": "confined to focusable table region"
|
||||
},
|
||||
"limits": [
|
||||
"No real-device Ctrl+C cancellation test; local child termination and retained samples verified with subprocess fixtures",
|
||||
"No screen-reader, Windows or macOS verification",
|
||||
"No controlled baseline, load or worn-headset testing",
|
||||
"Sensor accuracy not established"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"osVersion": "0.5.6",
|
||||
"osBuild": "20261015.6141207",
|
||||
"commands": [
|
||||
"scripts/frame-power-sample.sh --count 3 --interval 1",
|
||||
"scripts/frame-power-sample.sh --count 1 --csv"
|
||||
],
|
||||
"jsonSampleCount": 3,
|
||||
"elapsedSeconds": [
|
||||
0.078011,
|
||||
1.088542,
|
||||
2.107291
|
||||
],
|
||||
"stopped": null,
|
||||
"unavailableGroups": [
|
||||
[
|
||||
"proximity"
|
||||
],
|
||||
[
|
||||
"proximity"
|
||||
],
|
||||
[
|
||||
"proximity"
|
||||
]
|
||||
],
|
||||
"selectedSummary": {
|
||||
"battery.percent": {
|
||||
"unit": "%",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 99,
|
||||
"max": 99,
|
||||
"mean": 99.0,
|
||||
"first": 99,
|
||||
"last": 99
|
||||
},
|
||||
"battery.status": {
|
||||
"unit": "state",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"states": [
|
||||
"Not charging"
|
||||
]
|
||||
},
|
||||
"battery.tempC": {
|
||||
"unit": "C",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 26.400000000000002,
|
||||
"max": 26.400000000000002,
|
||||
"mean": 26.4,
|
||||
"first": 26.400000000000002,
|
||||
"last": 26.400000000000002
|
||||
},
|
||||
"battery.watts": {
|
||||
"unit": "W",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": -0.12,
|
||||
"max": -0.02,
|
||||
"mean": -0.07,
|
||||
"first": -0.02,
|
||||
"last": -0.07
|
||||
},
|
||||
"cpu.percent": {
|
||||
"unit": "%",
|
||||
"sources": [
|
||||
"/proc/stat"
|
||||
],
|
||||
"available": 2,
|
||||
"unavailable": 1,
|
||||
"min": 8.7,
|
||||
"max": 11.0,
|
||||
"mean": 9.85,
|
||||
"first": 11.0,
|
||||
"last": 8.7
|
||||
},
|
||||
"fan.pwmRaw": {
|
||||
"unit": "raw",
|
||||
"sources": [
|
||||
"/sys/class/hwmon/hwmon50/pwm1"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 41,
|
||||
"max": 41,
|
||||
"mean": 41.0,
|
||||
"first": 41,
|
||||
"last": 41
|
||||
},
|
||||
"fan.slg4ax46073v.fan1_input": {
|
||||
"unit": "RPM",
|
||||
"sources": [
|
||||
"/sys/class/hwmon/hwmon50/fan1_input"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 8624,
|
||||
"max": 8638,
|
||||
"mean": 8628.666667,
|
||||
"first": 8638,
|
||||
"last": 8624
|
||||
},
|
||||
"proximity.raw": {
|
||||
"unit": "raw",
|
||||
"sources": [
|
||||
"/sys/bus/iio/devices/iio:device2/in_proximity_raw"
|
||||
],
|
||||
"available": 0,
|
||||
"unavailable": 3,
|
||||
"states": []
|
||||
},
|
||||
"rail.max34417_9_10.vph.power1": {
|
||||
"unit": "W",
|
||||
"sources": [
|
||||
"/sys/class/hwmon/hwmon53/power1_input"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 4.656299,
|
||||
"max": 4.804066,
|
||||
"mean": 4.705555,
|
||||
"first": 4.656299,
|
||||
"last": 4.804066
|
||||
}
|
||||
},
|
||||
"csvRows": 92,
|
||||
"csvSummaryMetrics": 91,
|
||||
"csvHasExplicitUnavailable": true,
|
||||
"limits": [
|
||||
"Read-only short transport/parser check; no controlled baseline or worn/load/battery experiment.",
|
||||
"Proximity unavailable; numeric thermistors are not proof of sensor accuracy.",
|
||||
"No settings, services, device files or application state changed; no remote installation.",
|
||||
"No smoke lock at preflight or sampler checks; advisory checks cannot exclude a concurrent lock appearing during a read."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# Windows VM and real Frame test, 2026-10-24
|
||||
|
||||
The v0.4.10 installer could enumerate windows/screens and encode a synthetic
|
||||
Media Foundation stream, but every desktop capture failed at pipeline parsing:
|
||||
`no property "capture-api" in element "d3d11screencapturesrc"`.
|
||||
MSYS2's GStreamer 1.28.8 package disables WGC. After the fix, the same installed
|
||||
bundle captured the actual Windows desktop with Desktop Duplication and sent
|
||||
H.264 to a real Frame: 219 sent frame records, 215 decoded and drawn before the
|
||||
snapshot. Individual windows are explicitly unavailable with this bundle.
|
||||
|
||||
The base was `e9cbb8fd5555f5cfceeba1babadf2d7d35dcd3be` (`v0.4.10`, origin/main,
|
||||
including merged #121), in isolated branch `t3/test-windows-vm-headset`.
|
||||
|
||||
## Evidence
|
||||
|
||||
- [Capture before the fix](windows-vm-2026-10-24/capture-before.json): five
|
||||
synthetic H.264 frames and five JPEG frames; zero desktop/window frames,
|
||||
with the missing-property error for both codecs.
|
||||
- [Actual bundled properties](windows-vm-2026-10-24/plugin-properties.json):
|
||||
`monitor-handle` exists; `capture-api`, `window-handle`, `show-border` do not.
|
||||
[MSYS2 package build](https://github.com/msys2/MINGW-packages/blob/master/mingw-w64-gst-plugins-bad/PKGBUILD)
|
||||
sets `-Dd3d11-wgc=disabled`. Upstream
|
||||
[installs those properties conditionally](https://github.com/GStreamer/gstreamer/blob/main/subprojects/gst-plugins-bad/sys/d3d11/gstd3d11screencapturesrc.cpp).
|
||||
- [Capture after the fix](windows-vm-2026-10-24/capture-after.json): the real
|
||||
display produces five H.264 and five JPEG frames. Window sources are hidden
|
||||
with an explicit reason, and the display selects `capture_api=dxgi`.
|
||||
- [Raw locked headset session](windows-vm-2026-10-24/headset-session.json):
|
||||
Windows helper, Media Foundation encoder, reverse SSH tunnel, production
|
||||
panel launcher and shared viewer. The image below was captured from that
|
||||
viewer's own X window on the Frame, rather than the VM's console.
|
||||
- [Windows tests](windows-vm-2026-10-24/windows-tests.txt) and
|
||||
[guest system details](windows-vm-2026-10-24/windows-system.txt).
|
||||
|
||||

|
||||
|
||||
## Host and VM prerequisites
|
||||
|
||||
lxso2: Intel Core i7-8700, 12 logical CPUs, VT-x/KVM; Docker 29.1.3 and Compose
|
||||
2.40.3. Before starting: 31 GiB RAM, 15 GiB available; 147 GiB free disk.
|
||||
Swap was already full. No other VM or service workload was started or stopped.
|
||||
Docker listed no Windows VM, and ports 2222/8006/13389 were unused.
|
||||
|
||||
The execution sandbox hid KVM and denied Docker/network access. Checks outside
|
||||
it found `/dev/kvm`, `/dev/net/tun` and the Docker daemon. Direct user access to
|
||||
KVM was denied, but a disposable Docker container with `--device /dev/kvm`
|
||||
opened it successfully; no host permissions changed.
|
||||
|
||||
Created only `frame-winvm-priority3`, labelled
|
||||
`frame-control.task=priority3-windows-test`: 4 vCPUs, 8 GiB guest RAM, 32 GiB
|
||||
growable disk, no restart, 150-second stop timeout. SSH 2222 and console 8006
|
||||
bind only 127.0.0.1; RDP is unpublished. No GPU passthrough. Image:
|
||||
`dockurr/windows@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea`
|
||||
(Windows for Docker v6.06, QEMU v11.1.1).
|
||||
|
||||
The container downloaded Windows 11 from Microsoft, with no activation changes.
|
||||
It took about 16 minutes to reach the first desktop. Guest build 26200.8037,
|
||||
Red Hat VirtIO GPU DOD driver 100.103.104.30200. The old WindowsProductName
|
||||
registry field still says Windows 10 Pro; the actual desktop/setup is Windows
|
||||
11. After installing the app, the 32 GiB disk had about 9.3 GB free.
|
||||
|
||||
Official `Frame-Control-Setup-x64.exe` v0.4.10 SHA-256:
|
||||
`30165bc5aa39f941cacc697d195dc5c30b01ad0b9ffb472da8a18a5220549202`.
|
||||
Its installer exited 0. The VM stream used an intermediate Python/UI-only
|
||||
fix with the released DLL bundle and bundled Python 3.12.14. A later CI failure
|
||||
showed that probing GStreamer through a separately loaded DLL could see a
|
||||
different, uninitialized instance. The final fix probes optional properties
|
||||
through the capture DLL itself. Windows CI checks that native change; the VM
|
||||
stream was not repeated with the final DLL.
|
||||
|
||||
OEM setup added OpenSSH Server, PowerShell and a dedicated public login key;
|
||||
password authentication is disabled. Its config needed an explicit
|
||||
`AuthorizedKeysFile C:/ProgramData/ssh/administrators_authorized_keys` before
|
||||
key login worked. The default informational share was replaced with a private
|
||||
writable staging share inside this container. These were test-VM setup steps,
|
||||
not app defects. Host setup files and the stopped VM disk are retained under
|
||||
`/tmp/frame-winvm-priority3/`; no private keys are committed or printed.
|
||||
The Frame test key was generated inside Windows and its private key stayed
|
||||
there. Only its public key was temporarily authorized on the Frame. After
|
||||
testing, the guest key and scheduled task were removed; `docker stop -t 150`
|
||||
shut down the dedicated VM. Inspection confirmed `exited`, restart `no`
|
||||
(container exit 143; logs show the ACPI shutdown signal, waiting for Windows,
|
||||
and `Shutdown completed!`). The disk is retained
|
||||
for reuse; the VM is not running.
|
||||
|
||||
## Checks
|
||||
|
||||
- Baseline PC-focused suite: 24 tests passed, one native class skipped.
|
||||
- After fix: full Linux unittest suite **1260 tests, OK, 15 skipped**, 144.366 s;
|
||||
focused final PC suite **27 tests, OK, one native class skipped**. The C
|
||||
controller's frozen 600-state trace ran on Linux.
|
||||
- Actual Windows installer bundle with the intermediate Python/UI fix:
|
||||
**33 tests, OK, four skipped** (no C compiler, Linux PipeWire test, two
|
||||
CI-only x264 tests). Its GObject property probe, host/encoder check and
|
||||
x264-absence check ran. The final native probe is checked in the PR's
|
||||
Windows CI build, not by this VM run.
|
||||
- Browser check with the real production HTML/JavaScript and mocked Windows API
|
||||
responses: DXGI whole-screen Show remains available, the limitation appears
|
||||
once, WGC window sources retain Show, off hides controls, and the limitation
|
||||
text is escaped. This used a static asset server, without headset API access.
|
||||
- `node --test app/test/*.test.js`: passed, three test files.
|
||||
- `node --test site/test/*.test.mjs`: passed, one test file.
|
||||
- The first full Linux run inside the sandbox failed (11 failures, 139 errors),
|
||||
including loopback socket `PermissionError`. The successful full runs were
|
||||
outside that socket restriction.
|
||||
- An initial guest test archive omitted `apk-catalog`, causing one import
|
||||
error; the complete archive produced the 33-test passing Windows run.
|
||||
|
||||
Raw local logs: `/tmp/frame-windows-{pc,app,site}-tests.log`,
|
||||
`/tmp/frame-windows-full-tests-unrestricted.log`,
|
||||
`/tmp/frame-windows-full-fixed-tests.log`, and
|
||||
`/tmp/frame-winvm-priority3/`. The committed artifacts above preserve the
|
||||
capture, Windows test and headset evidence independently of those local files.
|
||||
|
||||
## Shared Frame session and cleanup
|
||||
|
||||
Read-only checks first: SteamOS 0.5.6, BUILD_ID `20261015.6141207`, battery 99%,
|
||||
Not charging (another supply reports Full). The lock was free.
|
||||
|
||||
Both attempts acquired `/tmp/frame-test.lock` atomically before any key or
|
||||
viewer changes, and removed only the lock acquired by this task. The first,
|
||||
35-second attempt failed because the test wrapper sent CRLF script input and
|
||||
left SSH stdin open. Production already sends script bytes and closes stdin;
|
||||
correcting the wrapper resolved both. Its key was removed and lock released.
|
||||
|
||||
The successful session held the lock **03:09:36–03:10:05 UTC (29 seconds)**.
|
||||
It used a dedicated `pc-view-priority3` profile and port range 47960–47964,
|
||||
leaving existing viewers/profiles alone. The app's Windows `PCView` captured
|
||||
`display:65537`, 1280×800, Light preset (30 fps ceiling), and opened panel
|
||||
`valve.steam.desktopgame.2001396898`. The viewer reported H.264 software decode,
|
||||
218 received frames, 215 decoded/drawn before the snapshot (219 sent records).
|
||||
|
||||
This mostly idle, roughly ten-second sample reported 19 fps and content delay
|
||||
p50/p95 51.6/75.2 ms. These are observations, not a performance-target pass.
|
||||
|
||||
Cleanup stopped the helper and tunnel, closed the own viewer, removed its two
|
||||
possible profile paths and log, then removed the exact public-key line. The
|
||||
`authorized_keys` SHA-256 was identical before/after. A follow-up login from
|
||||
Windows with that key failed with exit 255. Separate checks found no task-owned
|
||||
viewer processes, the profile absent, and the shared lock released. Battery
|
||||
remained 99%; the sampled gamescope focus properties matched before/after.
|
||||
No reboot, global settings changes, game launch or Steam/SteamVR stop occurred.
|
||||
|
||||
## Limits
|
||||
|
||||
This verifies a Windows VM's desktop capture, Media Foundation encoding and
|
||||
real Frame viewer decode/rendering. It does not verify physical-PC hardware
|
||||
encoding, WGC or individual-window capture, mixed DPI, multiple displays,
|
||||
physical laser alignment/clicks, keyboard layouts, resize/minimise, busy-scene
|
||||
performance, bitrate/encoder restarts, or worn-headset comfort. No input was
|
||||
injected during the real Frame session. The installed app’s Tools navigation was not exercised; its streaming
|
||||
controller, tunnel and viewer were exercised directly. No independent review was performed.
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
|
||||
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": true,
|
||||
"mfh264enc": true,
|
||||
"jpegenc": true,
|
||||
"x264enc": false
|
||||
},
|
||||
"windowCaptureReason": "Individual windows are unavailable in this build. Choose a whole screen instead.",
|
||||
"windows": [],
|
||||
"displays": [
|
||||
{
|
||||
"src": "display:65537",
|
||||
"name": "Display 1",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1280,
|
||||
"h": 800,
|
||||
"capture_api": "dxgi"
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 154
|
||||
},
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 7336
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 65
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 113339
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
|
||||
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": true,
|
||||
"mfh264enc": true,
|
||||
"jpegenc": true,
|
||||
"x264enc": false
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"src": "window:131836",
|
||||
"id": 131836,
|
||||
"title": "Administrator: C:\\WINDOWS\\system32\\cmd.exe - powershell -noprofile -executionpolicy bypass -file \\\\host.lan\\data\\bootstrap.ps1",
|
||||
"app": "Windows",
|
||||
"w": 1115,
|
||||
"h": 628,
|
||||
"x": 51,
|
||||
"y": 52
|
||||
},
|
||||
{
|
||||
"src": "window:327926",
|
||||
"id": 327926,
|
||||
"title": "Administrator: Install",
|
||||
"app": "Windows",
|
||||
"w": 979,
|
||||
"h": 512,
|
||||
"x": 85,
|
||||
"y": 78
|
||||
},
|
||||
{
|
||||
"src": "window:65856",
|
||||
"id": 65856,
|
||||
"title": "Program Manager",
|
||||
"app": "Windows",
|
||||
"w": 1280,
|
||||
"h": 800,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"displays": [
|
||||
{
|
||||
"src": "display:65537",
|
||||
"name": "Display 1",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1280,
|
||||
"h": 800
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 154
|
||||
},
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 7336
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "h264",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "jpeg",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "window:131836",
|
||||
"codec": "h264",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "window:131836",
|
||||
"codec": "jpeg",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"gstreamer": "1.28.8",
|
||||
"plugins": [
|
||||
"coreelements",
|
||||
"videotestsrc",
|
||||
"videoconvertscale",
|
||||
"app",
|
||||
"jpeg",
|
||||
"videoparsersbad",
|
||||
"d3d11",
|
||||
"mediafoundation"
|
||||
],
|
||||
"packages": [
|
||||
"mingw-w64-ucrt-x86_64-gst-plugins-bad 1.28.8-2",
|
||||
"mingw-w64-ucrt-x86_64-gst-plugins-bad-libs 1.28.8-2",
|
||||
"mingw-w64-ucrt-x86_64-gstreamer 1.28.8-1"
|
||||
],
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": {
|
||||
"name": "The name of the object",
|
||||
"parent": "The parent of the object",
|
||||
"blocksize": "Size in bytes to read per buffer (-1 = default)",
|
||||
"num-buffers": "Number of buffers to output before sending EOS (-1 = unlimited)",
|
||||
"typefind": "Run typefind before negotiating (deprecated, non-functional)",
|
||||
"do-timestamp": "Apply current stream time to buffers",
|
||||
"automatic-eos": "Automatically EOS when the segment is done",
|
||||
"monitor-index": "Zero-based index for monitor to capture (-1 = primary monitor)",
|
||||
"monitor-handle": "A HMONITOR handle of monitor to capture",
|
||||
"show-cursor": "Whether to show mouse cursor",
|
||||
"crop-x": "Horizontal coordinate of top left corner for the screen capture area",
|
||||
"crop-y": "Vertical coordinate of top left corner for the screen capture area",
|
||||
"crop-width": "Width of screen capture area (0 = maximum)",
|
||||
"crop-height": "Height of screen capture area (0 = maximum)"
|
||||
},
|
||||
"d3d12screencapturesrc": null,
|
||||
"mfh264enc": {
|
||||
"name": "The name of the object",
|
||||
"parent": "The parent of the object",
|
||||
"qos": "Handle Quality-of-Service events from downstream",
|
||||
"min-force-key-unit-interval": "Minimum interval between force-keyunit requests in nanoseconds",
|
||||
"bitrate": "Bitrate in kbit/sec",
|
||||
"rc-mode": "Rate Control Mode",
|
||||
"adaptive-mode": "Adaptive Mode",
|
||||
"vbv-buffer-size": "VBV(HRD) Buffer Size in bytes (0 = MFT default)",
|
||||
"max-bitrate": "The maximum bitrate applied when rc-mode is \"pcvbr\" in kbit/sec",
|
||||
"quality-vs-speed": "Quality and speed tradeoff, [0, 33]: Low complexity, [34, 66]: Medium complexity, [67, 100]: High complexity",
|
||||
"cabac": "Enable CABAC entropy coding",
|
||||
"sps-id": "The SPS id to use",
|
||||
"pps-id": "The PPS id to use",
|
||||
"bframes": "The maximum number of consecutive B frames",
|
||||
"gop-size": "The number of pictures from one GOP header to the next. Depending on GPU vendor implementation, zero gop-size might produce only one keyframe at the beginning (-1 for automatic)",
|
||||
"threads": "The number of worker threads used by a encoder, (0 = MFT default)",
|
||||
"content-type": "Indicates the type of video content",
|
||||
"qp": "QP applied when rc-mode is \"qvbr\"",
|
||||
"low-latency": "Enable low latency encoding",
|
||||
"min-qp": "The minimum allowed QP applied to all rc-mode",
|
||||
"max-qp": "The maximum allowed QP applied to all rc-mode",
|
||||
"qp-i": "QP applied to I frames",
|
||||
"qp-p": "QP applied to P frames",
|
||||
"qp-b": "QP applied to B frames",
|
||||
"ref": "The number of reference frames",
|
||||
"d3d11-aware": "Whether device can support Direct3D11 interop"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
WindowsProductName WindowsVersion OsBuildNumber
|
||||
------------------ -------------- -------------
|
||||
Windows 10 Pro 2009 26200
|
||||
|
||||
|
||||
|
||||
Name DriverVersion Status
|
||||
---- ------------- ------
|
||||
Red Hat VirtIO GPU DOD controller 100.103.104.30200 OK
|
||||
|
||||
|
||||
|
||||
Used Free
|
||||
---- ----
|
||||
23951253504 9314816000
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
python.exe : test_original_mac_congestion_and_recovery
|
||||
(test_pc_controller.ControllerTrace.test_original_mac_congestion_and_recovery) ... skipped 'C
|
||||
compiler not installed; native build jobs cover this trace'
|
||||
At line:12 char:1
|
||||
+ & $python -B -m unittest discover -s tests -p 'test_pc*.py' -v 2>&1 | ...
|
||||
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
+ CategoryInfo : NotSpecified: (test_original_m...ver this trace':String) [], Remote
|
||||
Exception
|
||||
+ FullyQualifiedErrorId : NativeCommandError
|
||||
|
||||
test_controller_is_inert_after_close
|
||||
(test_pcview.AdapterTests.test_controller_is_inert_after_close) ... ok
|
||||
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties (test_pcview.AdapterTests.
|
||||
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties) ... ok
|
||||
test_idle_source_is_not_a_stall (test_pcview.AdapterTests.test_idle_source_is_not_a_stall) ... ok
|
||||
test_library_path_prepend_adds_no_empty_entry
|
||||
(test_pcview.AdapterTests.test_library_path_prepend_adds_no_empty_entry) ... ok
|
||||
test_linux_uses_only_portal_fd_and_node
|
||||
(test_pcview.AdapterTests.test_linux_uses_only_portal_fd_and_node) ... ok
|
||||
test_malformed_viewer_input_is_dropped_not_fatal
|
||||
(test_pcview.AdapterTests.test_malformed_viewer_input_is_dropped_not_fatal) ... ok
|
||||
test_pc_card_receives_window_capture_limitation
|
||||
(test_pcview.AdapterTests.test_pc_card_receives_window_capture_limitation) ... ok
|
||||
test_pc_reuses_launcher_and_namespaces_panel_ids
|
||||
(test_pcview.AdapterTests.test_pc_reuses_launcher_and_namespaces_panel_ids) ... ok
|
||||
test_portal_input_clamps_and_releases_held_state
|
||||
(test_pcview.AdapterTests.test_portal_input_clamps_and_releases_held_state) ... ok
|
||||
test_refused_windows_focus_never_clicks_the_covering_app
|
||||
(test_pcview.AdapterTests.test_refused_windows_focus_never_clicks_the_covering_app) ... ok
|
||||
test_session_end_releases_native_input_only_once
|
||||
(test_pcview.AdapterTests.test_session_end_releases_native_input_only_once) ... ok
|
||||
test_stats_keep_capture_time_and_bound_records
|
||||
(test_pcview.AdapterTests.test_stats_keep_capture_time_and_bound_records) ... ok
|
||||
test_windows_release_and_negative_monitor_origin
|
||||
(test_pcview.AdapterTests.test_windows_release_and_negative_monitor_origin) ... ok
|
||||
test_windows_sources_follow_actual_wgc_capabilities
|
||||
(test_pcview.AdapterTests.test_windows_sources_follow_actual_wgc_capabilities) ... ok
|
||||
test_windows_uses_wgc_and_media_foundation
|
||||
(test_pcview.AdapterTests.test_windows_uses_wgc_and_media_foundation) ... ok
|
||||
test_never_offered_off_windows_even_when_turned_on
|
||||
(test_pcview.GatingTests.test_never_offered_off_windows_even_when_turned_on) ... ok
|
||||
test_off_by_default_starts_nothing (test_pcview.GatingTests.test_off_by_default_starts_nothing)
|
||||
... ok
|
||||
test_only_windows_gets_the_pc_host (test_pcview.GatingTests.test_only_windows_gets_the_pc_host)
|
||||
... ok
|
||||
test_server_enable_action (test_pcview.GatingTests.test_server_enable_action) ... ok
|
||||
test_turning_off_stops_streams_and_the_helper
|
||||
(test_pcview.GatingTests.test_turning_off_stops_streams_and_the_helper) ... ok
|
||||
test_turning_on_checks_for_the_bundled_libraries
|
||||
(test_pcview.GatingTests.test_turning_on_checks_for_the_bundled_libraries) ... ok
|
||||
test_expiry_and_stop_before_redeem (test_pcview.GrantsTests.test_expiry_and_stop_before_redeem)
|
||||
... ok
|
||||
test_source_bound_retry_ack_and_stop
|
||||
(test_pcview.GrantsTests.test_source_bound_retry_ack_and_stop) ... ok
|
||||
test_agent_reports_its_host_and_encoder
|
||||
(test_pcview.NativeTests.test_agent_reports_its_host_and_encoder) ... ok
|
||||
test_auth_and_real_h264_stats (test_pcview.NativeTests.test_auth_and_real_h264_stats) ... skipped
|
||||
'x264 is only in CI test bundles, not the installer'
|
||||
test_bundled_pipewire_context_needs_no_system_modules
|
||||
(test_pcview.NativeTests.test_bundled_pipewire_context_needs_no_system_modules) ... skipped
|
||||
'Linux PipeWire client libraries'
|
||||
test_installer_bundle_leaves_out_gpl_x264
|
||||
(test_pcview.NativeTests.test_installer_bundle_leaves_out_gpl_x264) ... ok
|
||||
test_last_raw_picture_survives_a_closed_gate
|
||||
(test_pcview.NativeTests.test_last_raw_picture_survives_a_closed_gate) ... skipped 'x264 is only
|
||||
in CI test bundles, not the installer'
|
||||
test_native_property_probe_checks_the_instantiated_element
|
||||
(test_pcview.NativeTests.test_native_property_probe_checks_the_instantiated_element) ... ok
|
||||
test_card_says_experimental_and_what_is_unverified
|
||||
(test_pcview.PageTests.test_card_says_experimental_and_what_is_unverified) ... ok
|
||||
test_opt_in_is_remembered_with_the_client_preferences
|
||||
(test_pcview.PageTests.test_opt_in_is_remembered_with_the_client_preferences) ... ok
|
||||
test_tool_link_is_windows_only_and_marked_experimental
|
||||
(test_pcview.PageTests.test_tool_link_is_windows_only_and_marked_experimental) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 33 tests in 1.684s
|
||||
|
||||
OK (skipped=4)
|
||||
+40
-1
@@ -6,6 +6,45 @@ This is Frame Control code using Python, Steam and SteamVR already on the Frame.
|
||||
|
||||

|
||||
|
||||
## Desktop low-battery alert without a session
|
||||
|
||||
In the desktop app, open **Tools → Family & comfort** and check **Alert on this
|
||||
computer at 15% or less**. This separate option does not start a session, install
|
||||
a worker or change the headset. It starts off whenever the page opens and turns
|
||||
off when you switch headsets. The choice is only for the current open page.
|
||||
|
||||
It consumes the battery parser already used by normal status refreshes (also
|
||||
shared by the read-only power sampler). It adds no SSH calls, timers, sensor
|
||||
reads, third-party push or heat thresholds. Normal automatic status refreshes
|
||||
pause while the page is hidden, so this is **not a background or safety monitor**.
|
||||
Keep the app visible and connected. Use **Enable / test notifications** to check
|
||||
system delivery; operating-system settings may block notifications.
|
||||
|
||||
Two fresh readings, at least 20 seconds and at most 90 seconds apart, must show
|
||||
15% or less, driver state `Discharging`, and signed battery power below −0.5 W.
|
||||
This avoids alerting on plugged-in idle readings, which can report discharging.
|
||||
Missing, invalid, stale or interrupted readings break confirmation. Disconnect,
|
||||
a failed status request, hiding the page or toggling the option also breaks
|
||||
confirmation. Unknown readings never mean zero charge.
|
||||
|
||||
An alert appears in the page and requests a native desktop notification once
|
||||
per low-charge episode. Two readings at 20% or more rearm it, and at least ten
|
||||
minutes must pass between alerts (including across headset switches). A single
|
||||
recovery spike cannot rearm it. Toggling off/on does not clear the episode latch.
|
||||
Notification failure stays visible in the page and does not retry on every
|
||||
refresh. While an active family session has battery alerts enabled, that session
|
||||
handles battery alerts instead. Multiple app windows have independent state;
|
||||
this does not coordinate notifications between computers or windows.
|
||||
|
||||
A read-only check on 2026-10-24, build **20261015.6141207**, received two samples
|
||||
at 99%, `Not charging`, approximately −0.06 W and 24 °C. No low-battery condition
|
||||
was induced, no notification was sent to the real headset, and no headset state
|
||||
was changed. The sampler's local and remote smoke-lock checks allowed the run;
|
||||
no lock was acquired or removed. Deterministic tests exercise low charge,
|
||||
recovery, missing readings, idle drain, timing and notification denial. See
|
||||
[recorded evidence](evidence/desktop-battery-comfort-2026-10-24.json). Native OS
|
||||
notification display, worn use and macOS/Windows packaging were not verified.
|
||||
|
||||
## Sessions
|
||||
|
||||
Set a limit of 1–240 minutes, optional break and check-in intervals, then
|
||||
@@ -58,7 +97,7 @@ not been verified and is not guaranteed.
|
||||
|
||||
## Casting
|
||||
|
||||
**Cast headset view** starts the existing headset Live view and requests full
|
||||
**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full
|
||||
screen where supported. Show that screen to people in the room, or use the
|
||||
computer/phone's own screen mirroring. It creates no new stream transport,
|
||||
public URL or LAN server. iPhone uses the inline viewer if full screen is not
|
||||
|
||||
@@ -15,6 +15,10 @@ depends on SSH working through the `frame` alias from `scripts/connect.sh`.
|
||||
| KDE Connect | KDE Connect on both | Guess | There's a macOS build of KDE Connect, but whether it's present or installable on the Frame wasn't confirmed. It would give you clipboard sync, file send, and remote input. Worth checking on-device. |
|
||||
| microSD | Physical card | Confirmed that the slot exists ([Wikipedia](https://en.wikipedia.org/wiki/Steam_Frame)) | Offline fallback. |
|
||||
|
||||
Screenshots and Steam clips have their own buttons on **Tools → Capture &
|
||||
Live**, and **Settings → Copy to this computer** copies new ones here
|
||||
automatically: see [recording.md](recording.md#copying-new-ones-automatically).
|
||||
|
||||
## Clipboard
|
||||
|
||||
`scripts/paste-to-frame.sh` sends the Mac clipboard (or stdin) to the
|
||||
@@ -36,3 +40,34 @@ desktop has to be running in the headset. It's text only, and pastes over about
|
||||
|
||||
A simpler fallback: `ssh frame 'cat > ~/clip.txt'` < file, then open it in the
|
||||
headset.
|
||||
|
||||
### In Frame Control
|
||||
|
||||
**Tools → Type on the headset** (also on Home, and press `T` anywhere) has the
|
||||
clipboard next to the keyboard and trackpad, both ways:
|
||||
|
||||
- **Send text** or **Send this computer's clipboard** puts text on the headset's
|
||||
clipboard, as above.
|
||||
- **Copy the headset's clipboard** brings it back: copy something in a desktop
|
||||
app on the Frame, then press it, and paste on your computer. It calls
|
||||
Klipper's `getClipboardContents` on the same D-Bus bus, and sends the text
|
||||
back as base64, so accents, emoji and line breaks arrive unchanged. The
|
||||
desktop app and the iPhone app put it on their own clipboard. With the server
|
||||
in a browser, the server sets the clipboard of the computer it runs on:
|
||||
`pbcopy` on macOS, PowerShell's `Set-Clipboard` (or `clip.exe`) on Windows,
|
||||
`wl-copy`, `xclip` or `xsel` on Linux.
|
||||
|
||||
It's text only, and copying back takes up to 1 MB; a bigger clipboard is
|
||||
refused with a message, so send big things as a file. An empty clipboard, or
|
||||
one holding an image, says so. Neither the server nor its log ever records what
|
||||
was on the clipboard.
|
||||
|
||||
**Verified on a Frame, 2026-10-24:** SteamOS 0.5.6, build
|
||||
`20261015.6141207`. With a temporary nested desktop open, Frame Control sent
|
||||
a 47-character test marker to Klipper, then **Copy the headset's clipboard**
|
||||
read it through `getClipboardContents` and set a Linux PC's X11 clipboard.
|
||||
Reading that PC clipboard returned the exact marker. The prior headset
|
||||
clipboard was restored and the test desktop was closed. The PC side used a
|
||||
temporary X display on lxso2; macOS and Windows clipboard backends, a user's
|
||||
copy action in a headset app, and image/size error cases remain untested on a
|
||||
real headset. The fake Frame (`tests/fakeframe`) covers the protocol cases.
|
||||
+103
-13
@@ -17,14 +17,21 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has five tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset, which network this
|
||||
computer is on, the address in use or being tried, and each step of connecting
|
||||
as it happens; click it for the whole timeline. When the Frame can't be
|
||||
The window has six tabs: **Home** (what the headset's doing and what you'd do
|
||||
next, the headset view, screenshots, family and comfort, the keyboard and
|
||||
trackpad, and VR performance, folded away), **Games** (installed games,
|
||||
sideloaded titles, getting games), **Android** (apps, the catalogue, display
|
||||
settings, reports), **Tools** (in the headset: your computer's windows, the media
|
||||
player and the panel switcher; then sending files and text, Flatpaks, remote and
|
||||
power), **Devices** (your headsets and their addresses), and **Control** (the
|
||||
searchable Control Center, metrics, supported settings and headset guidance).
|
||||
Keys 1–6 switch between them, and `T` opens Type on the headset. The gear in the header (key 7) opens **Settings**:
|
||||
privacy and updates, library artwork and the assistant. The battery chip opens
|
||||
the headset's details: storage, memory, temperature, Wi-Fi, uptime and SteamOS
|
||||
build. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset and whether it's
|
||||
connected, and how (Tailscale, or the network's name). Click it to add, edit or
|
||||
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
|
||||
reached, a banner says why in plain words, what was tried, and counts down to
|
||||
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
counts them while they run.
|
||||
@@ -42,11 +49,27 @@ counts them while they run.
|
||||
gamescope's flat layer instead.
|
||||
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
|
||||
save them to `~/Pictures/SteamFrame`.
|
||||
- **Record** what the headset shows, with its sound, to an `.mp4` in
|
||||
`~/Videos/SteamFrame`, and **Steam clips**: export clips and background
|
||||
recordings from Steam's game recording at any length, without re-encoding.
|
||||
See [recording.md](recording.md). Not tested on a headset yet.
|
||||
- **Backup & recovery** (Tools): copy game saves, settings and Android app data
|
||||
to `~/Documents/Frame Control Backups/<time>/` with a checksummed manifest,
|
||||
restore chosen parts after a factory reset, and **Return to Game Mode** when
|
||||
the headset is stuck in Desktop Mode. Sign-in files and keys are never copied.
|
||||
Not tested on a headset yet. See [backup-and-recovery.md](backup-and-recovery.md).
|
||||
- **Battery** with charging state: charge rate in watts, time to full or empty,
|
||||
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
|
||||
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
|
||||
the desktop, Lepton and xrdp are running.
|
||||
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
|
||||
- **Force quit** a game that has stopped responding: Steam games, sideloaded
|
||||
titles, non-Steam shortcuts and Android apps. **Running now** at the top of
|
||||
Games lists what's running; each game's own **Force quit…** (on its library
|
||||
tile, or in a sideloaded title's ⋯ menu) and the Control Center's **Force quit
|
||||
a game** card do the same. Each asks first, because unsaved progress is lost.
|
||||
Steam is asked to quit the game, then its processes get SIGTERM and SIGKILL;
|
||||
see [steam-games.md](steam-games.md#force-quitting-a-running-game).
|
||||
- **Get games**: every game you own with its Steam Frame rating (Verified,
|
||||
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
|
||||
with live progress. Search the Steam store with prices and Frame ratings; Buy
|
||||
@@ -73,8 +96,14 @@ counts them while they run.
|
||||
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
|
||||
the Steam library (Valve's Devkit Game path, with Proton or the Steam Linux
|
||||
Runtime picked from the program's header), listed under **Sideloaded titles**
|
||||
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
|
||||
Frame clipboard.
|
||||
with Launch and Remove; see [sideloading.md](sideloading.md).
|
||||
- **Type on the headset** (on Home, in Tools, in the Control Center, or press
|
||||
`T`): use your computer's keyboard and mouse, or a phone's touchscreen, in the
|
||||
Frame's apps through KDE Connect, and move clipboard text both ways. Send
|
||||
typed text or your computer's clipboard to the headset, or copy the headset's
|
||||
clipboard back: copy in a desktop app on the Frame, then **Copy the headset's
|
||||
clipboard**. The clipboard needs the desktop panel open in the headset; it's
|
||||
text only, up to 1 MB. See [file-transfer.md](file-transfer.md#clipboard).
|
||||
- **Mac in the headset** (macOS): show any Mac window, or a whole screen, as
|
||||
its own panel in the headset. Place it with the SteamVR dashboard, click and
|
||||
scroll with the laser, and type on the Mac. Streams hardware H.264 through
|
||||
@@ -96,6 +125,67 @@ counts them while they run.
|
||||
shut down open a terminal window because SteamOS asks for the sudo password
|
||||
over SSH.
|
||||
|
||||
## Control Center
|
||||
|
||||
Open **Control** or `#control` to search controls, status and help. Categories
|
||||
cover device/audio, VR/comfort, apps/files, connection/remote and Frame Control
|
||||
preferences. Search is local: it matches labels, descriptions and familiar terms
|
||||
such as “DPI”, “microphone”, “recenter” and “keep awake”. Queries are not sent to
|
||||
Steam, app catalogues, analytics or models. Choosing a workflow link opens its
|
||||
existing screen; it does not install, cast, open a helper or change a setting.
|
||||
Home, Games, Android, Tools and Devices retain their existing features.
|
||||
|
||||
The overview and detail cards use the shared `/api/status` sample: charge and
|
||||
power, user/system storage, memory, Wi-Fi, IP, OS/build, uptime, detected services
|
||||
and VR timings. Values refresh with the normal 30-second poll. A failed status
|
||||
request, lost connection, headset switch or sample received more than 90 seconds
|
||||
ago clears current readings and disables audio writes. The client receipt time
|
||||
and failed-read state are retained through page startup and UI redraws. Only a
|
||||
new successful status read clears a failure; reusing the retained sample does not.
|
||||
The headset timestamp is shown without assuming
|
||||
its clock matches this computer. Missing sensors show **Unavailable**;
|
||||
they are not converted to zero. Compositor FPS is separate from application FPS;
|
||||
GPU time is render time, not GPU utilisation. The temperature is the hottest
|
||||
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
|
||||
not a guessed universal temperature threshold.
|
||||
|
||||
**Force quit a game** lists what's running only when you choose **Check what's
|
||||
running**, and says so when nothing is. Each **Force quit…** names the game and
|
||||
warns that unsaved progress will be lost before anything is sent; it uses the
|
||||
selected headset, refuses a second quit while one is running, and drops the
|
||||
list when you switch headsets.
|
||||
|
||||
**Audio** controls the default output sink through the existing `/api/volume`
|
||||
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
|
||||
and **Unmute** send explicit mute states. The UI captures the selected headset,
|
||||
blocks duplicate/pending actions, and requests status after command acknowledgment
|
||||
for readback, draining an earlier poll first. Mute preserves an unapplied volume
|
||||
draft; switching headsets discards it. It reports
|
||||
unconfirmed or differing output separately from confirmed state. API validation
|
||||
rejects invalid types, out-of-range levels and invalid mute states before SSH.
|
||||
|
||||
Other settings link to the existing forms and their validation, progress,
|
||||
confirmations and reset behavior: per-instance Android resolution/density/text,
|
||||
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
|
||||
sources and artwork. Android display links reveal the **Library** before
|
||||
scrolling to the form. Mac streaming remains searchable on all platforms, with
|
||||
its action shown only when the server is on macOS.
|
||||
|
||||
Hardware guidance is deliberately separate from controls. Microphone/routing,
|
||||
brightness, refresh selection, optics, controllers, Bluetooth, passthrough,
|
||||
tracking-origin changes, saved spatial layouts, firmware updates and recovery
|
||||
have no verified remote setting here. Use the headset's own controls or the
|
||||
documented manual procedures. Movement comfort remains per-game; automatic
|
||||
sleep is in Steam Settings → Power → Sleep after inactivity. Keep-awake and
|
||||
Android data backup/restore remain script/MCP capabilities, not new UI switches.
|
||||
|
||||
**Verification:** the Control Center's JavaScript runs under Node with mocked
|
||||
API and DOM boundaries; the server tests mock SSH and reject invalid values
|
||||
before any device command. Packaging tests cover the shared CSS/JS in desktop
|
||||
and iPhone bundles. These checks establish wiring, search, navigation, offline,
|
||||
error and device-switch behavior; they do not establish wearer experience,
|
||||
physical settings persistence or a new hardware capability.
|
||||
|
||||
## How it works
|
||||
|
||||
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
|
||||
@@ -158,8 +248,8 @@ Google publishes no arm64 Linux platform-tools. Set Up Connection runs
|
||||
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
|
||||
and others). The log is at
|
||||
`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
|
||||
instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
|
||||
`xclip` (X11).
|
||||
instead of the app, sending the clipboard, or copying the headset's to this
|
||||
computer, needs `wl-clipboard` (Wayland), `xclip` or `xsel` (X11).
|
||||
|
||||
## Building
|
||||
|
||||
@@ -183,5 +273,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes
|
||||
require a separate user approval; power also retains its password prompt. The
|
||||
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
||||
for a message. Screenshot context is separately opt-in. Model replies cannot
|
||||
operate the headset. Tools → Open assistant opens the page; the linked guide
|
||||
operate the headset. Settings → Open assistant settings opens the page; the linked guide
|
||||
covers putting it in a Chromium panel on the Frame.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Frame doctor
|
||||
|
||||
Run `scripts/frame-doctor.sh` from a checkout on macOS or Linux to diagnose
|
||||
first-time setup, pairing and common Frame failures without applying repairs.
|
||||
It needs Bash, Python 3 and OpenSSH on the computer, and Python 3 on the Frame.
|
||||
Windows users can run `python scripts/frame_doctor.py` with Python and OpenSSH
|
||||
installed; the desktop app does not bundle this command.
|
||||
|
||||
```sh
|
||||
scripts/frame-doctor.sh
|
||||
scripts/frame-doctor.sh --alias bedroom --lan frame.local
|
||||
scripts/frame-doctor.sh --alias frame --lan 192.168.1.40
|
||||
```
|
||||
|
||||
Use only addresses belonging to your intended headset. The command tries the
|
||||
existing `frame` SSH alias first (Tailscale if setup configured it that way),
|
||||
then `frame.local`, then USB-C at `10.86.200.233`. `--alias`, `--lan` and `--usb`
|
||||
override those defaults. It reports the successful path without printing its
|
||||
address. It neither discovers other devices nor enables Tailscale.
|
||||
|
||||
The existing alias supplies the user, port, keys and known-hosts file. LAN and
|
||||
USB overrides preserve its `HostKeyAlias`, or its configured hostname if no
|
||||
alias was set. A first connection therefore needs trusted setup first. The
|
||||
doctor never accepts a new host key, deletes a saved key, generates keys or
|
||||
sends a pairing request. It uses your trusted OpenSSH configuration; OpenSSH
|
||||
can evaluate `Match exec` conditions in that configuration. Proxy commands,
|
||||
jumps, local commands, forwarding and connection sharing are disabled for
|
||||
these direct diagnostic connections.
|
||||
|
||||
## Reading the result
|
||||
|
||||
Each of the eleven runbook sections has one line: `OK`, `WARN`, `BROKEN` or
|
||||
`UNKNOWN`. `OK` means only that the stated check passed, not that the whole
|
||||
feature works. Optional features that are off may produce a warning.
|
||||
|
||||
| Section | Read-only evidence and limits |
|
||||
|---|---|
|
||||
| 1. Boot and crash history | Previous-boot kernel-oops signatures; one day of coredump history. An oops alone does not prove an unclean reset. |
|
||||
| 2. Wi-Fi | NetworkManager's wlan0 state, without SSIDs or connection profiles. |
|
||||
| 3. Displays and SteamVR | DSI timeout signatures in the recent kernel journal; no display, compositor or game is launched. |
|
||||
| 4. Steam and storage | Free space in home and `devkit-game`, including separate mounts; missing Steam install marker. No CRC scan or restart. |
|
||||
| 5. Idle sleep and keep-awake | Existing keep-awake unit state; never takes ownership or changes idle timers. |
|
||||
| 6. Remote wake | WoWLAN state, without arming it or testing suspend. Wake with the power button. |
|
||||
| 7. Lepton | Recent port-clash, binder and data-clearing signatures; no Android/container commands. |
|
||||
| 8. Chromium XR | Crash history; no browser profile or environment inspection. |
|
||||
| 9. KDE Connect | Process presence; absence is normal when sharing is off. |
|
||||
| 10. Streaming and capture | ffmpeg crash history; no capture or encoder is started. |
|
||||
| 11. Panels | Always `UNKNOWN`: placement requires the user's visual confirmation. |
|
||||
|
||||
Journal samples are capped at 2,000 entries; current-boot samples cover the
|
||||
last ten minutes. This can miss older or high-volume failures. Empty,
|
||||
inaccessible or permission-limited journals are `UNKNOWN`, never zero errors.
|
||||
Commands time out individually, and each SSH probe is bounded to 90 seconds.
|
||||
|
||||
Exit codes: **0** if all reported checks are OK; **1** for warnings, broken or
|
||||
unknown checks after connecting; **2** if no authenticated SSH path exists or
|
||||
local SSH setup is unavailable. Panel verification currently means a connected
|
||||
run normally exits 1. Automation should read the labelled checks rather than
|
||||
treating a warning as proof the headset is unusable.
|
||||
|
||||
## Read-only repair plan and before/after reports
|
||||
|
||||
```sh
|
||||
scripts/frame-doctor.sh --plan --save-report before.json
|
||||
# Review the plan with the headset user. Any manual action is separate.
|
||||
scripts/frame-doctor.sh --plan --compare-before before.json --save-report after.json
|
||||
```
|
||||
|
||||
`--plan` prints advice; it never executes repair commands or prompts for approval.
|
||||
There is no automatic repair mode. Advice about Wi-Fi, display timeouts, low
|
||||
storage, a missing Steam install marker or keep-awake requires the exact
|
||||
recognized section, severity and evidence. Other warnings request local review;
|
||||
unknown evidence never justifies a repair. A signature or missing marker alone
|
||||
does not establish a cause. Restart risk, rising counters or unknown counters
|
||||
block all section-specific plans and direct you to the recovery runbook.
|
||||
Nothing changes passwords, headset security, pairing, services or power settings.
|
||||
|
||||
Reports are optional **local** JSON files containing only fixed diagnostic
|
||||
summaries, a format version and an opaque SSH-target configuration fingerprint.
|
||||
The fingerprint binds comparisons to the same alias, identity/routing
|
||||
configuration and fallback addresses; it does not prove physical headset
|
||||
identity or that the headset was not replaced behind that configuration. Keep
|
||||
using trusted SSH host keys. Report creation is exclusive (existing files and
|
||||
symlinks are refused), with owner-only permissions on POSIX. Protect reports
|
||||
using your local account permissions on Windows. Choose a fresh output filename
|
||||
for each run. Doctor never writes a report on the headset.
|
||||
|
||||
`--compare-before` validates a bounded baseline before connecting and refuses
|
||||
changed target configuration. It prints each section's before/after status and
|
||||
prints both fixed evidence messages when they differ, even if the status
|
||||
stayed the same. Changes are observations, not proof a repair worked or caused them;
|
||||
recent journal windows can simply age out. There is no intervening repair by
|
||||
doctor, freshness guarantee, or authenticated report signature. If either run
|
||||
skipped deeper checks, comparison is blocked and no improvement is claimed.
|
||||
Unreachable or invalid probes produce no new baseline or comparison. Malformed
|
||||
baseline/target mismatch/report-write errors exit **2**; otherwise normal
|
||||
diagnostic exit codes apply. No saved report is needed for ordinary diagnostics.
|
||||
|
||||
## First-time setup and pairing
|
||||
|
||||
- **No SSH response:** wake the headset with its power button. Check the cable
|
||||
or Steam Settings → Internet, then enable Developer Mode. Sleep and network
|
||||
failure can look alike; a timeout cannot distinguish them.
|
||||
- **Key rejected:** open **Set Up Connection** in Frame Control. In the headset,
|
||||
open Steam Settings → Developer → **Pair new host**, then approve setup's
|
||||
request. The doctor only checks whether devkit port 32000 responds; that
|
||||
does not prove pairing mode is open or the service is healthy.
|
||||
- **Unknown or changed identity:** verify the intended headset through setup
|
||||
or Devices → **Check its key**. Do not delete `known_hosts` to bypass this.
|
||||
- **No devkit response:** check Developer Mode and the address. See
|
||||
[SSH setup](ssh.md) for the manual fallback. The doctor cannot turn SSH on.
|
||||
|
||||
No password is requested. Enter any password required by the separate setup
|
||||
flow only in its local terminal; never paste it into an issue or report.
|
||||
|
||||
## Restart safety and recovery
|
||||
|
||||
Before deeper checks, and again afterwards, the doctor samples Steam and
|
||||
SteamVR `NRestarts` and health-check tracker sizes two seconds apart. Rising
|
||||
counters stop diagnostics immediately. Nonempty health-check trackers also stop
|
||||
deeper checks even when stable, since a short sample could miss a slower loop.
|
||||
Missing counters or counters that reset stop deeper checks as `UNKNOWN`. Stable
|
||||
counters with empty trackers still cannot rule out a slower restart loop.
|
||||
|
||||
The [historical runbook from PR #33](https://github.com/saphid/frame-control/blob/cce3d940302054ab5246c92d740afaefc4e58d38/docs/frame-doctor.md)
|
||||
is the source for these section numbers and observed signatures (September
|
||||
2026 builds). Its section 4 describes freezing and verifying health-check
|
||||
trackers before prolonged recovery during a live loop. That changes headset
|
||||
state, so this read-only implementation stops and directs you there instead.
|
||||
The optional plan and baseline comparison are a guarded next step for issue
|
||||
#34; automatic fixes and full recovery automation remain unimplemented. Review
|
||||
build-specific recovery instructions with the person using the headset; a repair can remove data.
|
||||
|
||||
Never reload `ath12k`, suspend remotely, kill gamescope to diagnose a loop,
|
||||
reset or force power off during extraction/repair, or write passwords to disk.
|
||||
The doctor never restarts services, signals processes, repairs Steam, changes
|
||||
WoWLAN/standby settings, takes keep-awake ownership, installs anything or writes
|
||||
a remote report. Only `--save-report` creates a local file. Nothing needs
|
||||
restoring on the headset after its checks. Manual/device tests must
|
||||
follow the [shared-device lock and battery procedure](testing.md#headset-smoke-test).
|
||||
|
||||
## Privacy and verification
|
||||
|
||||
Only fixed diagnostic summaries leave the remote probe. Raw journals, process
|
||||
arguments, environment variables, SSH errors, account IDs, network addresses,
|
||||
SSIDs, keys and tokens are not printed. No telemetry or HTTP request is sent;
|
||||
the only pairing-service probe is a TCP connection. SSH produces normal access
|
||||
logs. Review summaries before sharing them; feature availability can still be
|
||||
personal information. Redirecting stdout or using `--save-report` saves a report
|
||||
only if you choose it.
|
||||
Treat the target fingerprint as private metadata; it is a hash, not encryption.
|
||||
|
||||
Unit tests use mocked boundaries. `scripts/e2e.sh test_doctor` covers asleep,
|
||||
disk-full, SSH/devkit-off and missing-key faults against the fake Frame. Its
|
||||
stable restart counters are explicitly simulated, not proof about SteamOS.
|
||||
On 2026-10-24 the script ran read-only against a real Frame, SteamOS build
|
||||
`20261015.6141207`. It verified the configured SSH alias and key login,
|
||||
then reported `WARN Restart safety`: nonempty health-check trackers. It
|
||||
correctly skipped all eleven deeper sections and exited 1. No raw logs,
|
||||
addresses or keys were printed, and no repair or restart was attempted.
|
||||
This verifies the safety stop on that device; the deeper checks are not
|
||||
claimed as headset-tested.
|
||||
|
||||
Follow-up verification on 2026-10-24: `--plan --save-report` and then
|
||||
`--plan --compare-before` ran read-only on the same real Frame. Both reported
|
||||
nonempty health-check trackers, skipped deeper checks and exited 1. The plan
|
||||
was blocked and the comparison reported `risk -> risk` without claiming
|
||||
improvement. No install, launch, stop or repair was performed; this validates
|
||||
the guarded report path, not the deeper checks or any manual repair.
|
||||
|
||||
The follow-up's 17 focused offline tests passed. All five doctor fake-Frame
|
||||
tests passed, including a disk-full baseline followed by fault removal and a
|
||||
fresh comparison. The final evidence-output change also passed that comparison
|
||||
test again. Because zsh is unavailable on this host, the repository's images
|
||||
were built and its Compose harness run directly under the isolated project
|
||||
`frame-doctor-plan-34`; its containers and volumes were removed afterwards.
|
||||
These storage changes were fake fault controls, not doctor repairs.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Game storage
|
||||
|
||||
Open **Tools → Game storage** for a read-only snapshot of the headset's user
|
||||
filesystem and Steam libraries. Refresh after an installation, download or move.
|
||||
The page never moves, deletes or modifies content, starts Steam, requests root,
|
||||
or recursively scans game files. It works through the existing SSH connection.
|
||||
|
||||
## What the numbers mean
|
||||
|
||||
- **Total, free and used** come from `statvfs` on the headset user's home and
|
||||
each library path. Free is space available to that user; reserved blocks mean
|
||||
used + free can be less than total. These are filesystem sizes, not advertised
|
||||
drive capacity, and do not include separate system partitions.
|
||||
- Libraries come from `~/.local/share/Steam/steamapps/libraryfolders.vdf` plus
|
||||
the default Steam library. Modern nested and older numbered-path records are
|
||||
accepted; canonical duplicate paths are shown once. No drive is mounted.
|
||||
- Two paths can share one filesystem. Their capacity must **not** be added.
|
||||
An unmounted drive whose mount directory still exists can report the parent
|
||||
filesystem's capacity: the page labels this as the filesystem containing the
|
||||
path, rather than claiming the removable drive is connected.
|
||||
- Game sizes are Steam's `SizeOnDisk` records in `appmanifest_<appid>.acf`, sorted
|
||||
largest first, with unknown sizes last. A missing, negative or invalid size
|
||||
is **Unknown**, while an explicit zero remains zero. Records may be stale,
|
||||
including during downloads or moves; they do not measure allocated blocks.
|
||||
- Games/apps and runtimes/tools have separate subtotals of **known recorded**
|
||||
sizes. Runtime classification uses names (Proton, Steam Linux Runtime,
|
||||
Steamworks Common, FEX and Lepton Development), so it is only a convenience.
|
||||
Additional apps or runtimes may fall in the game/app group.
|
||||
- Saves, Proton prefixes, shaders, workshops, downloads, Android apps,
|
||||
sideloaded games, media and OS files are **unmeasured other usage**. The page
|
||||
does not subtract manifest totals from filesystem usage to invent a size for
|
||||
them. Recorded content is not an exhaustive storage breakdown.
|
||||
|
||||
Permission denied, missing/disconnected paths, unavailable filesystem reads,
|
||||
invalid metadata and partial inventories are reported separately. A failed read
|
||||
is never proof of an empty library. If library discovery fails, other libraries
|
||||
may be missing from the view. Refresh clears the old results immediately; a
|
||||
headset switch clears the snapshot and ignores late replies from the old device.
|
||||
|
||||
## Moving a game safely
|
||||
|
||||
Use Steam's own **Settings → Storage** on the headset (or in desktop Steam):
|
||||
choose the source drive, select the games and choose **Move**, then pick a
|
||||
Steam-supported destination. A second Steam library must already be available.
|
||||
Steam may withhold Move or a destination when it cannot perform the operation.
|
||||
Let Steam finish, follow Steam's errors/recovery prompts, then Refresh here.
|
||||
Do not manually delete files to make room or interrupt an in-progress move.
|
||||
|
||||
[Valve's moving guide](https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move)
|
||||
was read in a browser on 2026-10-24 and documents this UI. This is an instructional
|
||||
handoff: the page does not send a URI or silently open a UI on the headset.
|
||||
The guide's separate Windows instructions for moving the entire Steam client
|
||||
are outside this feature's scope.
|
||||
|
||||
### Why Frame Control does not automate moving
|
||||
|
||||
The repository's Valve devkit tools register and launch developer titles; no
|
||||
supported game-move operation is provided there. The existing Steam CEF bridge
|
||||
uses internal client APIs, which are not a verified supported contract for a
|
||||
move transaction. [Valve's public ISteamApps reference](https://partner.steamgames.com/doc/api/ISteamApps) provides install-directory
|
||||
and installed-depot queries but no documented move transaction with completion,
|
||||
cancellation, rollback and error guarantees. The public support guide describes
|
||||
the user-operated Storage UI, rather than such an automation interface.
|
||||
|
||||
Consequently this feature adds no move endpoint or guessed internal Steam call,
|
||||
manifest rewrite, copy/delete fallback, or custom rollback. Automating a move
|
||||
requires a supported interface and verified behaviour for full disks, disconnects,
|
||||
running games, downloads, cancellation and Steam restart first.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
`tests/test_storage.py` runs the actual probe against temporary Steam metadata
|
||||
and filesystem/error fixtures. It covers multiple libraries, duplicate paths,
|
||||
zero/unknown/invalid sizes, corrupt manifests, unreadable directories and files,
|
||||
missing discovery/drives, reserved capacity and read-only file preservation.
|
||||
A loopback HTTP test executes the real guarded endpoint with SSH mocked.
|
||||
`tests/storage.cjs` runs the production renderer, navigation and refresh handlers
|
||||
with DOM/transport boundaries faked, including escaping, offline errors,
|
||||
out-of-order refreshes and headset switches. It runs through unittest discovery.
|
||||
|
||||
The production storage component was rendered with synthetic data at 390px and
|
||||
900px widths; both fit without horizontal overflow. This checks component
|
||||
layout, not the full connected application or headset browser.
|
||||
|
||||
On 2026-10-24 the uninstalled storage parser was piped read-only over SSH
|
||||
to a real Frame running SteamOS build `20261015.6141207`. It reported
|
||||
710.4 GiB free on the user filesystem, one available Steam library, 29
|
||||
readable app manifests, zero unknown sizes and zero parse/permission issues.
|
||||
No game names or raw metadata were printed in the test report. The
|
||||
connected UI, removable media, Steam's Move UI and an actual game move were
|
||||
not tested. UI and error-state fixtures remain synthetic.
|
||||
@@ -52,6 +52,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
|
||||
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
|
||||
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
|
||||
| **A second Windows program can join a running Proton game.** Copy `WINEPREFIX`, `WINEDLLPATH`, `WINEDLLOVERRIDES`, `PATH` (Proton's `files/bin-arm64` comes first) and `DISPLAY` from `/proc/<game pid>/environ`, keeping your own `HOME` and the rest, and run `wine prog.exe` from an ordinary SSH shell. It shares the game's wineserver (outside the pressure-vessel container). Don't copy `WINESERVERSOCKET`, which is an inherited fd. A game's windows live on gamescope's X display for that app (`:1` for Gravitas), where the full-screen game keeps the pointer and focus: XTest clicks and restacking don't reach other windows, but `XSendEvent` button events sent to a window do. `xwd` captures WPF/DXVK windows but shows Vulkan game windows as black. **Verified 2026-09-29**, BUILD_ID 20260925.6191901, Proton 11.0-2c ARM64. | [mods.md](mods.md) |
|
||||
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
|
||||
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
||||
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
|
||||
@@ -95,5 +96,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
|
||||
- Remote access from anywhere: [tailscale.md](tailscale.md)
|
||||
- Floating windows in space: [panels.md](panels.md)
|
||||
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
|
||||
- Backing up saves and settings, restoring after a reset, leaving Desktop Mode: [backup-and-recovery.md](backup-and-recovery.md)
|
||||
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
|
||||
- What's still unverified: [open-questions.md](open-questions.md)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 483 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
+5
-4
@@ -102,8 +102,9 @@ app installed opens its App Store page.
|
||||
Things iOS asks the first time: **Local Network** (tap Allow, or the app can't
|
||||
see the Frame), and **Paste** when you send the iPhone's clipboard (tap Allow
|
||||
Paste, or set Settings → Apps → Frame Control → Paste from Other Apps → Allow).
|
||||
Sending text to the Frame's clipboard needs the desktop panel open in the
|
||||
headset, as on the desktop app.
|
||||
Sending text to the Frame's clipboard, or copying the Frame's to the phone
|
||||
(**Type on the headset → Copy the headset's clipboard**), needs the desktop
|
||||
panel open in the headset, as on the desktop app.
|
||||
|
||||
Not yet exercised: Android display changes through podman (no Android app was
|
||||
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
|
||||
@@ -114,8 +115,8 @@ don't.
|
||||
|
||||
## Family and comfort
|
||||
|
||||
The shared Home card sets session limits, breaks and check-ins, and offers
|
||||
**Cast headset view**. **Enable / test notifications** requests iOS notification
|
||||
The shared Home card sets session limits, breaks and check-ins; **Cast to this
|
||||
screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification
|
||||
permission and sends a local test. These are local notifications, not APNs push;
|
||||
iOS background suspension can interrupt phone alerts. The headset timer still
|
||||
runs. See [the behavior and verification limits](family-comfort.md).
|
||||
@@ -4,7 +4,8 @@ Frame Control can show any Mac window, or a whole Mac screen, as its own panel
|
||||
in the Steam Frame. You place each panel anywhere in the room with the SteamVR
|
||||
dashboard. The laser clicks and drags, the thumbstick scrolls, and you type on
|
||||
the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
|
||||
only).
|
||||
only). On Windows there's an experimental port,
|
||||
[PC in the headset](pc-in-headset.md).
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
# My Meta library (Experimental)
|
||||
|
||||
Install Quest games your Meta account owns straight from Meta, test whether
|
||||
they run on the Steam Frame, and add the result to the shared compatibility
|
||||
reports. It is off until you connect, and it lives in **Android → My Meta
|
||||
library**.
|
||||
|
||||
**Read this first.** My Meta library uses Meta's undocumented internal API
|
||||
with your own Meta access token, as the open-source tools QAVS, DownQuest and
|
||||
BSManager do. It may stop working whenever Meta changes things, and using it
|
||||
may be against Meta's terms. Your account is your responsibility. Frame
|
||||
Control is unofficial and not made or endorsed by Meta or Valve.
|
||||
|
||||
What it will and won't do:
|
||||
|
||||
- It lists and downloads only games your account owns (its active
|
||||
entitlements). It never downloads anything else.
|
||||
- It installs games as Meta ships them. There is **no licence-check bypass**:
|
||||
no patching, no stub libraries, no OVRPort or FramePort. A game that
|
||||
enforces Meta's licence check may quit or hang on the Frame, because that
|
||||
check needs Meta's services, which the Frame doesn't have.
|
||||
- Game files go only to a cache folder on your computer and then to your
|
||||
Frame. They are deleted from the cache after each install, and never
|
||||
uploaded or shared. Reports carry results only (below), never files or your
|
||||
token.
|
||||
|
||||
## Your token
|
||||
|
||||
Frame Control never reads your browser's cookies. There are two ways to give
|
||||
it your token.
|
||||
|
||||
**Sign in with Meta** (desktop app only; a plain browser doesn't show it).
|
||||
Tick **I understand…**, then choose **Sign in with Meta…**. Meta's own login
|
||||
page (`https://secure.oculus.com`, the address BSManager's "Log in with Meta"
|
||||
opens) appears in a Frame Control window. Sign in there as usual. When Meta's
|
||||
site sets its access token cookie (`oc_ac_at`), Frame Control takes it from
|
||||
that window, connects with it exactly as if you had pasted it (below), and
|
||||
closes the window. The window:
|
||||
|
||||
- has its own storage (the Electron session `persist:meta-signin`), separate
|
||||
from Frame Control's main window and from every browser on your computer;
|
||||
the cookie is read only from there, and only for `oculus.com`;
|
||||
- runs Meta's page sandboxed, with no access to Frame Control (no preload, no
|
||||
Node, context isolation on, no developer tools) and every permission request
|
||||
or check (camera, notifications…) refused;
|
||||
- only goes to `https://` pages on `oculus.com`, `meta.com`, `facebook.com` and
|
||||
`instagram.com` (and their subdomains; Meta's login passes through Instagram).
|
||||
A link anywhere else opens in your system browser; a redirect anywhere else
|
||||
is stopped, and closing the window then says where it went. Pop-ups on those sites open in the same
|
||||
window. Frames inside the page may load any `https://` page (Meta's login
|
||||
can embed captcha and CDN frames from other sites, such as Arkose Labs,
|
||||
hCaptcha, reCAPTCHA or fbcdn) or an empty frame (`about:blank`,
|
||||
`about:srcdoc`); a frame going to any other scheme (`http:`, `data:`,
|
||||
`blob:`, `javascript:`, `file:`…) is just stopped. Redirects follow the same
|
||||
rule for the frame they happen in. Frames can't reach the
|
||||
token: the cookie is read by Frame Control's main process, and the window
|
||||
has no IPC;
|
||||
- sends a cookie value on only if it looks like a user token (`OC` or `FRL`,
|
||||
mixed case, no `%`, `|` or `:`), and each value once. If the server refuses
|
||||
it, the window stays open; close it to see why;
|
||||
- gives up after 10 minutes, as BSManager does;
|
||||
- when it is closed, times out or is signed out of, stops reading cookies
|
||||
first, then is destroyed (a page's "leave this page?" can't keep it open),
|
||||
and, if it had sent a token, tells the server to drop that sign-in: each
|
||||
sign-in has a random attempt id. The cancel is sent only once the connect
|
||||
under way has finished (saved, refused or failed), so the server always
|
||||
gets it after the connect; with nothing sent, there is nothing to cancel.
|
||||
The server lists an attempt only while its
|
||||
connect is being checked: cancelled then, it isn't saved; cancelled just
|
||||
after its token was saved (even before the connect has finished tidying
|
||||
up), that token is deleted (as a disconnect). A cancel
|
||||
never removes an account a later sign-in or a pasted token saved, and one
|
||||
for an unknown attempt changes nothing.
|
||||
|
||||
The page never sees the token: the desktop app hands it straight to the local
|
||||
server's connect path and tells the page only the answer (connected, and your
|
||||
Meta display name). It isn't logged anywhere.
|
||||
|
||||
The window keeps you signed in to Meta (in `persist:meta-signin` only) until
|
||||
you **Disconnect**, which also signs out of Meta in Frame Control: it clears
|
||||
that window's cookies, storage and cache, so the next sign-in starts fresh.
|
||||
**Sign out of Meta in Frame Control** next to the sign-in button does just
|
||||
that part, for a sign-in that didn't connect.
|
||||
|
||||
**Paste a token.** You copy your own token and paste it in. The two ways
|
||||
BSManager's guide describes
|
||||
([How to obtain your Oculus Token](https://github.com/Zagrios/bs-manager/wiki/How-to-obtain-your-Oculus-Token)):
|
||||
|
||||
1. **From the Meta website.** Sign in at `https://secure.oculus.com`. Open the
|
||||
browser's developer tools, then **Application** (Chrome, Edge) or
|
||||
**Storage** (Firefox) → **Cookies** → `https://secure.oculus.com`. Copy the
|
||||
value of `oc_ac_at`. It starts with `OC`.
|
||||
2. **From the Meta Quest PC app** (Windows). Press **Ctrl+Shift+I**, open
|
||||
**Network**, press **Ctrl+R**, type `graph` in the filter, open the first
|
||||
request, and copy the access token from its **Payload**. It starts with
|
||||
`FRL`.
|
||||
|
||||
Treat it like a password: anyone with it can act as your Meta account. Signing
|
||||
out of the Meta website or app usually ends it; then connect again with a new
|
||||
one.
|
||||
|
||||
Frame Control refuses values that can't be a user token before sending them
|
||||
anywhere: anything not starting with `OC` or `FRL`, URL-encoded copies (with
|
||||
`%`), app tokens (with `|` or `:`), cut-off values (under 20 characters), and
|
||||
anything with characters other than letters, digits and `+ / = - _` (the
|
||||
base64 and URL-safe base64 sets), or with no lower-case letter (real tokens are
|
||||
mixed case). The public tools only describe tokens made
|
||||
of letters and digits; if a real token needs another character, it has to be
|
||||
added in one place (`TOKEN_CHARS` in `ui/frame_meta.py`), which also makes
|
||||
the redaction look for it.
|
||||
|
||||
## Using it
|
||||
|
||||
1. Tick **I understand…**, then either choose **Sign in with Meta…** (desktop
|
||||
app) or paste your token and choose **Connect**. Frame Control asks Meta
|
||||
"who am I" with it (a read-only query); only a token Meta accepts is kept.
|
||||
2. Your Quest games are listed with their version, size, game data, and the
|
||||
community verdict from the compatibility database when there is one.
|
||||
3. **Install** downloads the game and installs it on the Frame, as [Import
|
||||
from my Quest](quest-import.md) does. A game with OBB game data then needs
|
||||
a second **Install** while you have it open on the Frame: Frame Control
|
||||
downloads the data, copies it in and closes the game.
|
||||
4. **Install and test** does the same, then starts the game itself (twice if
|
||||
it has game data to add: once to add it, once for the test), watches it for
|
||||
about 30 seconds, and records what happened. **Test again** repeats only
|
||||
the test.
|
||||
5. Choose **Install** on as many games as you like: they wait in the
|
||||
**Install queue** and install one after another, with Quest imports and
|
||||
Android store installs. **Remove** takes one out before it starts.
|
||||
6. **Disconnect** deletes the token from your computer and, in the desktop
|
||||
app, signs out of Meta in Frame Control (clears the sign-in window's
|
||||
storage). Games already on the Frame stay.
|
||||
|
||||
From a terminal (the token is read from standard input, never an argument):
|
||||
|
||||
```sh
|
||||
python3 ui/frame_meta.py connect --token-stdin # paste the token, press Enter
|
||||
python3 ui/frame_meta.py status
|
||||
python3 ui/frame_meta.py list [--refresh] # owned Quest games, with community verdicts
|
||||
python3 ui/frame_meta.py download APP_ID # into the local install cache (a later download clears it after 6 hours)
|
||||
python3 ui/frame_meta.py install APP_ID [--test] [--allow-downgrade]
|
||||
python3 ui/frame_meta.py test com.example.game [--wait 30] [--no-report]
|
||||
python3 ui/frame_meta.py disconnect
|
||||
```
|
||||
|
||||
The local API: `GET /api/meta` (connected or not, never the token),
|
||||
`GET /api/meta?library=1[&refresh=1]` (the games), and `POST /api/meta` with
|
||||
`{"action": "connect", "token": "...", "acknowledged": true}`,
|
||||
`{"action": "disconnect"}`, `{"action": "cancel-signin", "attempt": "..."}`, `{"action": "install", "app": "APP_ID", "test": false,
|
||||
"allowDowngrade": false}` or `{"action": "test", "package": "..."}`. Install and
|
||||
test return a background job (`/api/job`); an install waits its turn in the
|
||||
install queue (`/api/queue`, see [Import from my Quest](quest-import.md#using-it)). Sign in with Meta uses the same
|
||||
`connect` action: the desktop app's main process (`app/meta-signin.js`) posts
|
||||
it with the cookie's value and an `"attempt"` id (8 to 64 letters, digits or
|
||||
`-`) once the page's acknowledgement box is ticked. A connect whose attempt
|
||||
was cancelled while it was checked answers 409 and saves nothing;
|
||||
`cancel-signin` for an attempt whose token is the saved account deletes it.
|
||||
|
||||
## What an install does
|
||||
|
||||
1. Checks the game is in your library (by its Meta app id), and that there's
|
||||
room on this computer for the APK, a patched copy of it and the game data.
|
||||
2. Downloads the APK from Meta's download server, within the size Meta
|
||||
declared (and at most 2 GB), checking its SHA-256 if Meta gives one, and
|
||||
that it is an APK of the package and version Meta said.
|
||||
3. Runs Import from my Quest's checks. Refused: 32-bit-only builds (no
|
||||
`arm64-v8a`), and apps that need an Android newer than Lepton's (API 30).
|
||||
Installed with a warning: games that use Meta's licence check
|
||||
(`libovrplatformloader.so`), and VrApi-only games ("may not run").
|
||||
4. Installs it with `frame_android.install` (VR detection, the launcher fix,
|
||||
the OpenXR compatibility layer, artwork, a Steam library entry). An older
|
||||
copy never silently replaces a newer one on the Frame unless you tick
|
||||
**Allow replacing a newer version**.
|
||||
5. Game data: the app's OBB (saved as Meta names it when that is a standard
|
||||
`main|patch.<version>.<package>.obb` name, otherwise as
|
||||
`main.<version code>.<package>.obb`) and any *required* asset files with
|
||||
standard OBB names, at most 8 GB each. One install downloads at most 16 GB
|
||||
in all, APK included, counted as the bytes arrive (so files whose size Meta
|
||||
doesn't give count too); a file that goes past it is stopped and removed.
|
||||
The data is copied into the
|
||||
running game by the same two-step helper Import from my Quest uses. Other
|
||||
asset files (DLC packs, `.pak` files a game fetches through Meta's
|
||||
Platform SDK) aren't downloaded; the result lists them.
|
||||
6. Deletes everything it downloaded, whether the install worked or not.
|
||||
Downloads left by a crash are removed after six hours.
|
||||
|
||||
## The test, and the report
|
||||
|
||||
The test starts the game, waits for its Lepton instance, then looks every 10
|
||||
seconds for 30 seconds: is the game's process alive, and what does its Android
|
||||
log (main, system and crash buffers) say. It recognises:
|
||||
|
||||
| In the log | Cause given |
|
||||
|---|---|
|
||||
| "entitlement check failed", "not entitled", `ovr_Entitlement…` or `IsUserEntitledToApplication` reporting a failure (a call that passes doesn't count) | Meta's licence check failed |
|
||||
| Platform SDK initialisation errors | Meta's Platform SDK couldn't start |
|
||||
| `Failed to launch SystemActivities`, "device not supported" | The game checks it is on a Meta Quest |
|
||||
| `vrapi_… was called before…`, VrApi load errors | Legacy VrApi |
|
||||
| `XR_ERROR_EXTENSION_NOT_PRESENT` (with the extension's name) | An OpenXR feature the Frame lacks |
|
||||
| `XR_ERROR_API_VERSION_UNSUPPORTED`, runtime/initialisation failures | OpenXR version or session problems |
|
||||
| `VK_ERROR_DEVICE_LOST`, `zink`, `libgallium_dri.so`, `GL_INVALID_…`, shader failures | Graphics driver errors |
|
||||
| `ClipboardManager` | Lepton's missing clipboard service |
|
||||
| `NoSuchMethodError`, `UnsatisfiedLinkError`, `dlopen failed` | Android or library features Lepton lacks |
|
||||
| `FATAL EXCEPTION`, `Fatal signal`, `SIGSEGV` | A crash |
|
||||
| `ANR in …`, "Input dispatching timed out" | Not responding |
|
||||
| `XR_SESSION_STATE_SYNCHRONIZED/VISIBLE/FOCUSED`, `xrEndFrame` | The VR scene started |
|
||||
|
||||
The outcome is one of **runs** or **runs with issues** (its VR scene
|
||||
started), **quits** (closed by itself), **crashes**, **hangs** (Android
|
||||
reported it not responding; Frame Control then closes it, and says so if it
|
||||
couldn't), **didn't start** (its instance never came up), or **no clear
|
||||
result**: it stayed open but its log never showed its VR scene starting. That
|
||||
can be a game still loading, or one stuck after a failed check; Frame Control
|
||||
can't tell, so it doesn't record it. A game already open is closed first, so
|
||||
the test starts clean; if it can't be closed, nothing is tested. If the Frame
|
||||
fails while the test reads the game or its log, the test says only that it
|
||||
couldn't read the log: a failed command's output can be the game's log, so it
|
||||
is never shown. Signatures look at the first 4 KB of each log line and are
|
||||
bounded, so a huge or hostile log can't make the test slow. They
|
||||
are written for Frame Control from the research in [vr-apks.md](vr-apks.md);
|
||||
they are not copied from other tools.
|
||||
|
||||
The result goes through the same path as **Test** and **Report**
|
||||
(`frame_catalog.add_report`): `via` probe, source `meta-store`, the result
|
||||
(`runs`, `crashes` or `instance_failed`, the values the database accepts), and
|
||||
notes with the outcome, the cause, the names of the matched signals and the
|
||||
version code, all fixed texts. Nothing else: no token, account name, file or
|
||||
log text. The page may show a short name from the log next to the cause (an
|
||||
OpenXR extension, a library, an exception class or a signal), taken from the
|
||||
log line after the token is removed from it, and left out unless it looks like
|
||||
a real name (64 characters at most, no long mixed letter-and-digit runs);
|
||||
reports never carry it. It is always kept on this
|
||||
computer; it reaches the shared database only if **Share compatibility
|
||||
results** is on in Privacy settings, as for every report. The panel offers to
|
||||
turn that on.
|
||||
|
||||
## Your token and privacy
|
||||
|
||||
- Kept in `meta-account.json` in Frame Control's per-user data folder
|
||||
(`~/Library/Application Support/Frame Control` on macOS,
|
||||
`%APPDATA%\Frame Control` on Windows, `~/.local/share/frame-control` on
|
||||
Linux), written owner-only (0600) through a temporary file. Never on the
|
||||
Frame.
|
||||
- Sent only to `graph.oculus.com` (in the POST body) and
|
||||
`securecdn.oculus.com` (in the download address, which is how that server
|
||||
takes it), over HTTPS. A download that redirects elsewhere (Meta's CDN) is
|
||||
followed only to a plain HTTPS address on port 443 that doesn't carry the
|
||||
token in any form, anywhere (host, path, query or fragment; literal,
|
||||
recased, percent-encoded, or 8+ characters of it), nor an `access_token`
|
||||
parameter. That is checked before any lookup or connection, and a redirect
|
||||
that fails it is refused. API answers that redirect are refused.
|
||||
- Every connection goes to an address checked to be public (an allow-list:
|
||||
no private, local, shared, reserved, multicast, NAT64, 6to4 or Teredo
|
||||
addresses), and to exactly that address, with TLS checked for the host
|
||||
name, so a second DNS answer can't swap in another. System proxy settings
|
||||
aren't used. This is the same design as the itch.io purchases branch.
|
||||
- Never in logs, error messages, API answers, the page or reports: every
|
||||
message is cleaned first, matching the token (or any start of it of 8+
|
||||
characters) in any letter case, with each character written literally or
|
||||
percent-encoded once or twice (%XX, %25XX) in either case, and any other
|
||||
token-shaped value, before it is shortened.
|
||||
- Disconnect deletes the file and the library Frame Control holds in memory,
|
||||
and stops a download under way. A connect still being checked when you
|
||||
disconnect is not saved. In the desktop app, before the token is deleted, it
|
||||
also stops any sign-in under way, destroys the sign-in window, waits for a
|
||||
connect still under way to finish and cancels its attempt (deleting its
|
||||
token if it was just saved), and only then clears the `persist:meta-signin` session
|
||||
(cookies, storage, cache); no other session is touched. Sign in with Meta
|
||||
stays off from the moment you press Disconnect until the token is deleted,
|
||||
and the app refuses a sign-in while it is signing out.
|
||||
- With Sign in with Meta, a second copy of your Meta sign-in lives in that
|
||||
session's storage, in Electron's per-user data folder (`Partitions/meta-signin`),
|
||||
until you disconnect or sign out of Meta in Frame Control.
|
||||
|
||||
## Where the protocol comes from
|
||||
|
||||
Frame Control's code is its own (MIT). These public sources describe the
|
||||
protocol; Frame Control takes only facts from them (addresses, query ids,
|
||||
field names), not code. QAVS and BSManager are GPL-3.0; OculusGraphQLApiLib,
|
||||
DownQuest and Oculus-downgrader state no licence.
|
||||
|
||||
| Fact | Source |
|
||||
|---|---|
|
||||
| GraphQL at `https://graph.oculus.com/graphql`, POST, form fields `access_token`, `doc_id`, `variables`; `OAuthException` / code 190 for a bad token | [OculusGraphQLApiLib `GraphQLClient.cs`](https://github.com/ComputerElite/OculusGraphQLApiLib/blob/866081e2/OculusGraphQLApiLib/GraphQL/GraphQLClient.cs), [DownQuest `DownQuest.js`](https://github.com/ptrpaws/DownQuest/blob/50f5d558/DownQuest.js) |
|
||||
| "Who am I": `doc_id` 4149322231793299, `data.viewer.user {id, alias}` | OculusGraphQLApiLib `GraphQLClient.cs` and `Results/User.cs` |
|
||||
| Owned apps: `doc_id` 4850747515044496, `data.viewer.user.active_entitlements.nodes[].item` | OculusGraphQLApiLib `GraphQLClient.cs`, `Results/Entitlement.cs`, `Results/Application.cs`; used by [QAVS `GameDownloadManager.cs`](https://github.com/ComputerElite/QuestAppVersionSwitcher/blob/36bebd5a/QuestAppVersionSwitcher/GameDownloadManager.cs) |
|
||||
| One binary: `doc_id` 4734929166632773 with `binaryID`; fields `package_name`, `version`, `version_code`, `size`, `obb_binary`, `asset_files` (`file_name`, `size`, `is_required`) | OculusGraphQLApiLib `GraphQLClient.cs`, `Results/OculusBinary.cs`, `Results/AssetFile.cs` |
|
||||
| Downloads: `https://securecdn.oculus.com/binaries/download/?id=<id>&access_token=<token>`, the same for the APK, `obb_binary` and asset files; QAVS takes the OBB and required asset files | OculusGraphQLApiLib `Game/GameDownloader.cs`, [QAVS `DownloadManager.cs`](https://github.com/ComputerElite/QuestAppVersionSwitcher/blob/36bebd5a/QuestAppVersionSwitcher/DownloadManager.cs), DownQuest `DownQuest.js` |
|
||||
| Token checks (`OC`/`FRL`, no `%`, `|`, `:`, not `OC` + 15 digits) | OculusGraphQLApiLib `GraphQL/TokenTools.cs`, [BSManager `oculus.helpers.ts`](https://github.com/Zagrios/bs-manager/blob/00380c79/src/shared/helpers/oculus.helpers.ts) |
|
||||
| How users get a token (`oc_ac_at` cookie, or the PC app's `FRL` token) | [BSManager wiki](https://github.com/Zagrios/bs-manager/wiki/How-to-obtain-your-Oculus-Token) |
|
||||
| Sign in with Meta: open `https://secure.oculus.com` in an app window, and once it reaches `https://secure.oculus.com/my/profile` read the `oc_ac_at` cookie from that window's session (checked on `did-navigate`, `did-navigate-in-page`, `did-stop-loading`); 10-minute timeout | [BSManager `bs-oculus-downloader.service.ts`](https://github.com/Zagrios/bs-manager/blob/00380c79/src/main/services/bs-version-download/bs-oculus-downloader.service.ts) (`getUserTokenFromMetaAuth`) |
|
||||
|
||||
**Assumed, not seen in those sources:** that the entitlement and binary
|
||||
answers carry the fields above in the shapes the libraries' models describe
|
||||
(the stored queries are opaque); that the list isn't paginated (none of the
|
||||
tools page it); that `ANDROID_6DOF` marks Quest apps; where downloads redirect
|
||||
to (the tools let their HTTP client follow redirects; Frame Control follows
|
||||
up to five, to any public HTTPS host, and refuses one carrying the token); and
|
||||
a `sha256`
|
||||
field (none was seen, so in practice only sizes are checked). The log
|
||||
signatures for the test are inferred from the compatibility research, not
|
||||
from a real Frame.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
**Verified locally** with a fake Meta and a fake Frame (`tests/test_meta.py`):
|
||||
token checks; storage owner-only through a temporary file; redaction (the
|
||||
stored token recased and percent-encoded in either case, starts of it, other
|
||||
token-shaped values, and redaction before cutting); HTTPS-only URLs; the
|
||||
public-address allow-list, resolving once and connecting only to a checked
|
||||
address within one deadline; redirects off Meta's hosts refused when they
|
||||
carry the token anywhere, before any lookup; the token never sent to the
|
||||
CDN; GraphQL redirects refused; disconnect beating a connect in flight and
|
||||
stopping a download; library parsing (Quest apps only, nothing expired or
|
||||
inactive, details from the binary query); downloads with an OBB; size, length
|
||||
and checksum limits and the whole-install budget with nothing left behind;
|
||||
refusing apps not in the
|
||||
library; the handoff to Import from my Quest's checks, install and data step;
|
||||
the licence-check warning instead of a refusal; test outcomes from log
|
||||
fixtures (a passing licence check isn't a failure; no VR scene is no clear
|
||||
result, not recorded); closing before a test and saying when a close failed;
|
||||
reports carrying only fixed texts, no token, log text or file names. The
|
||||
real TLS connection code is the same as the itch.io branch's and is exercised
|
||||
here only with a fake connection.
|
||||
|
||||
**Sign in with Meta, verified locally** with fake Electron windows, sessions
|
||||
and cookies (`app/test/meta-signin.test.js`): the window's options (its own
|
||||
partition, sandbox, context isolation, no Node, no preload) and Meta's login
|
||||
address, no developer tools; permission requests and checks refused; the
|
||||
navigation allow-list (links elsewhere open in the system browser, redirects
|
||||
elsewhere are stopped, pop-ups never become windows); frames allowed any
|
||||
HTTPS page but no other scheme, the window itself only Meta's sites; a
|
||||
page's beforeunload not keeping the window open; the cookie read only from
|
||||
`persist:meta-signin` and only for `oculus.com`, even when the default
|
||||
session and another partition hold one; signed-out values ignored; a value
|
||||
sent once, a refused one keeping the window open; the window closing after a
|
||||
connect, with the page getting the answer and never the token; nothing
|
||||
logged, and a server error that echoed the token scrubbed; closing the window
|
||||
or signing out ending the wait; closing, timing out or signing out during a
|
||||
connect cancelling that attempt (with its own id) after cookie intake stops
|
||||
and only after that connect settles (saved, refused or failed), and no cancel
|
||||
when nothing was sent; sign-out (Disconnect's first step) waiting for a
|
||||
connect under way, even from a window closed earlier, before it finishes;
|
||||
sign-out destroying the window before clearing only that partition; no
|
||||
sign-in starting while a sign-out runs (it is refused: "try again in a
|
||||
moment"), a second sign-out joining the first, and a sign-out waiting for
|
||||
every window and connect started before it. The
|
||||
frame redirects held to the frame rule, the window's to Meta's sites. The
|
||||
server side (`tests/test_meta.py`): an attempt cancelled while Meta is
|
||||
answering not saved, even after 300 other cancels; a cancel just after the
|
||||
save deleting that token; a cancel never removing a later sign-in's or a
|
||||
pasted token's account; unknown attempts changing nothing; no attempt left
|
||||
listed after a connect, refused or not; attempt ids checked.
|
||||
|
||||
**Differences from BSManager:** BSManager uses its app's default session (and
|
||||
clears only `.oculus.com` cookies afterwards); Frame Control uses a session of
|
||||
its own and clears all of it on disconnect. BSManager waits for
|
||||
`/my/profile`; Frame Control also reacts to the cookie changing, and to any
|
||||
`oculus.com` page, and lets the server decide whether the value is a
|
||||
working token.
|
||||
|
||||
**Not verified:** Sign in with Meta against Meta's real login page (no real
|
||||
Meta account was used, and nothing was sent to Meta): whether Meta's login
|
||||
still goes only through `oculus.com`, `meta.com` and `facebook.com`, whether
|
||||
it uses pop-ups (they open in the same window here, which would break a
|
||||
pop-up that reports back to its opener), whether `oc_ac_at` is still set on
|
||||
`oculus.com` after login, and whether Meta accepts the window (Frame Control
|
||||
removes Electron's name from the user agent as a precaution). A real Meta token and Meta's real answers (none was
|
||||
available; nothing was sent to Meta), real downloads and their redirects,
|
||||
real Quest games installed or tested on a real Frame, and whether the log
|
||||
signatures match what real games print in Lepton.
|
||||
+85
-15
@@ -1,11 +1,14 @@
|
||||
# Flat-to-VR mods and Beat Saber songs
|
||||
|
||||
**Status: feasibility work, not an installer.** Frame Control does not yet
|
||||
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26)
|
||||
stays open: neither UEVR injection nor Beat Saber custom-song playback has
|
||||
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy
|
||||
has not been inspected. There is no Mods button until the underlying
|
||||
install, playback and removal have been checked.
|
||||
**Status: UEVR works from Frame Control for Unreal Engine games; Beat Saber
|
||||
songs are blocked.** Installed games have a **VR mod** button. It installs
|
||||
UEVR, starts the game with UEVR injected, and removes UEVR again. That whole
|
||||
loop was run on a real Frame on 2026-09-29 with Gravitas, from the app's own
|
||||
UI ([evidence](evidence/mods-2026-09-29.md)). The headset was unworn, so
|
||||
the VR image, head tracking and controls haven't been seen yet. Beat Saber
|
||||
custom songs need an owned Beat Saber on the Frame, which there isn't
|
||||
([below](#beat-saber-songs-first)).
|
||||
[Issue #26](https://github.com/saphid/frame-control/issues/26) stays open for those.
|
||||
|
||||
The mod manager must be Frame Control's own implementation. Mods and songs
|
||||
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
|
||||
@@ -15,27 +18,87 @@ unofficial mod mirrors are part of this work.
|
||||
|
||||
## Per-game support
|
||||
|
||||
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64,
|
||||
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
Checked 2026-09-28 and 2026-09-29 on SteamOS **0.4.1**, BUILD_ID
|
||||
**20260925.6191901**, aarch64, with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
the observation stated, not a promise that the game is playable. “Documented”
|
||||
means an upstream source describes it; “inferred” means it still needs a test.
|
||||
|
||||
| Game / build | Mod or content | Evidence and support status | Next check |
|
||||
|---|---|---|---|
|
||||
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
|
||||
| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. |
|
||||
| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Twelve cold **Play in VR** runs (33–35 s each), and the full cycle from the app's UI, all loaded `UEVRBackend.dll` into the game, with UEVR logging `Hooked DirectX 11` and `Requested runtime: openvr_api.dll`. SteamVR's compositor counted frame submits for `steam.app.1067310`. Remove restored the prefix to its pre-install file listing. **Not seen:** the stereo image, head tracking and controls (headset unworn). | Wear the headset: check the image, tracking, UEVR's in-headset menu and controller input. |
|
||||
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
|
||||
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
|
||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2.** Not reachable on 2026-09-29 (no device on `adb` from the Mac). No APK, version or installed mods inspected; no Frame playback verified. A Quest copy is a Meta store purchase; copying it to another headset would need its entitlement check to pass there, which this work won't work around. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
|
||||
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
|
||||
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
|
||||
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
|
||||
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
|
||||
|
||||
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup,
|
||||
visible output and failures. It also records a SteamVR restart during the
|
||||
shared session, which prevents attributing the failed UEVR attempt to FEX.
|
||||
The test records ([2026-09-28](evidence/mods-2026-09-28.md),
|
||||
[2026-09-29](evidence/mods-2026-09-29.md)) distinguish process startup,
|
||||
visible output, injection and failures.
|
||||
|
||||
## UEVR from Frame Control
|
||||
|
||||

|
||||
|
||||
**Get games → an installed game → VR mod.** The dialog says whether the game
|
||||
can take UEVR, then offers **Install UEVR**, **▶ Play in VR** and **Remove
|
||||
UEVR**. The server route is `POST /api/mods {"action": "status" | "install" |
|
||||
"start" | "uninstall", "appid": …}`, and the work happens on the Frame in
|
||||
[`ui/frame_mods.py`](../ui/frame_mods.py). It uses only the standard library,
|
||||
like `frame_steam.py`.
|
||||
|
||||
- **Which games.** Unreal Engine games, recognised by their
|
||||
`…/Binaries/Win64/*-Win64-Shipping.exe` (inferred as the general rule; checked
|
||||
with Gravitas, and HL2 VR is correctly refused). The game needs a Proton
|
||||
prefix, so it must have been played once.
|
||||
- **Install.** Downloads praydog's [UEVR 1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||
and python.org's [Windows x64 embeddable Python 3.14.7](https://www.python.org/downloads/release/python-3147/).
|
||||
Each is checked against a pinned SHA-256; Python's comes from python.org's
|
||||
release-file API. Archives are unpacked into a staging folder, rejecting
|
||||
entries that escape it, symlinks and oversized contents, then moved into
|
||||
`<prefix>/drive_c/frame-control/` (about 47 MB). Nothing goes into the
|
||||
game's own folder. A receipt in `~/.local/share/frame-control/mods/` lists
|
||||
what to remove, including UEVR's own settings folders only if they didn't
|
||||
exist before. Install and remove refuse while the game is running, and only
|
||||
one mod action runs at a time.
|
||||
- **Play in VR.** Launches the game through Steam if it isn't running and
|
||||
waits for its window. It then runs a short script under that Python, with
|
||||
the game's Proton, prefix and Wine settings read from the game process, so
|
||||
both share one Wine session. The script repeats what UEVR's own injector does
|
||||
when you press Inject with OpenVR chosen: load `UEVRPluginNullifier.dll` and
|
||||
call its `nullify`, load `openvr_api.dll`, set `Frontend_RequestedRuntime` in
|
||||
UEVR's per-game `config.txt`, then load `UEVRBackend.dll`. Each load is a
|
||||
`LoadLibraryW` remote thread, as in the frontend's `Injector.cs`. `start`
|
||||
then checks that `UEVRBackend.dll` is mapped into the game and reads
|
||||
SteamVR's frame count for the app (`vrcmd --stats`).
|
||||
- **Remove.** Deletes what the receipt lists, then the download cache once
|
||||
no game uses it. Verified: the prefix's file listing matched its
|
||||
pre-install state afterwards.
|
||||
|
||||
Why not UEVR's own injector (verified 2026-09-29):
|
||||
|
||||
- It's a .NET 6 WPF app, so it needs Microsoft's .NET runtimes (about 70 MB
|
||||
of downloads) in the prefix.
|
||||
- Under gamescope, the full-screen game keeps the pointer and focus. XTest
|
||||
clicks, raising or refocusing the injector window don't change that, so in
|
||||
the headset you can't click it. Sending X `ButtonPress`/`ButtonRelease`
|
||||
events straight to its window does work.
|
||||
- `--attach=<process>` didn't inject under Wine, and it didn't restore its
|
||||
saved OpenVR choice.
|
||||
- Driven that way, it injected in 6 of 8 cold starts. In a bad session every
|
||||
new injector ignored input or never showed its window, even after
|
||||
relaunching. Its process list reads each process's main window title, a
|
||||
cross-process window message; a window that isn't answering would block its
|
||||
UI thread (inferred from the frontend's source, not proven). Without a
|
||||
`HOME` it also died in .NET at startup.
|
||||
- Frame Control's own script injected in 12 of 12 cold starts through the API, in 33–35 s, and again from the UI.
|
||||
- UEVR's DirectX 12 probing logs errors before it settles on DirectX 11 for
|
||||
Gravitas, and some of its Unreal engine scans fail on this older UE4 game.
|
||||
Neither stopped the injection.
|
||||
|
||||
## Beat Saber: songs first
|
||||
|
||||
@@ -62,7 +125,10 @@ or dependency for Frame Control.
|
||||
|
||||
## Requirements for our manager
|
||||
|
||||
These are **planned**, not implemented or verified:
|
||||
For UEVR, items 1–5 are implemented in `frame_mods.py` and covered by
|
||||
fake-library tests (`tests/test_mods.py`), apart from checking ownership
|
||||
through Steam; item 6 is met for Gravitas. Beat Saber songs would need the
|
||||
same, and none of it is built for them:
|
||||
|
||||
1. Resolve the selected Steam game's real library, installed build, executable
|
||||
architecture and Proton prefix. Confirm ownership through Steam; a directory
|
||||
@@ -91,7 +157,11 @@ These are **planned**, not implemented or verified:
|
||||
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
|
||||
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
|
||||
including the SHA-512 hashes used for the test runtimes.
|
||||
including the SHA-512 hashes used for the injector tests.
|
||||
- [UEVR frontend source](https://github.com/praydog/UEVR-Frontend): `UEVR/MainWindow.xaml.cs`
|
||||
(`Inject_Clicked`) and `UEVR/Injector.cs`, the steps `frame_inject.py` follows.
|
||||
- [python.org release-file API](https://www.python.org/api/v2/downloads/release_file/) for the
|
||||
embeddable Python's `sha256_sum`.
|
||||
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
|
||||
including per-game installation and the need to preserve original DLLs.
|
||||
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
|
||||
|
||||
@@ -143,6 +143,15 @@ check in the headset:
|
||||
- Keys from SteamVR's on-screen keyboard.
|
||||
- Whether Flathub Chromium decodes H.264 (otherwise use Compatible).
|
||||
|
||||
## Battery charge limit (2026-10-23)
|
||||
|
||||
Can an 80% charge limit be set over SSH or adb? **Not safely yet.** SteamOS
|
||||
Manager's `BatteryChargeLimit1` (the interface Steam's own setting uses) has
|
||||
no backend in the Frame's device config, and the battery node is a
|
||||
`max1720x` fuel gauge with no `charge_control_end_threshold`. Research and
|
||||
read-only headset checks are in [charge-limit.md](charge-limit.md). Not tested
|
||||
on a headset.
|
||||
|
||||
## Unconfirmed claims made in these docs
|
||||
|
||||
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ a limit on the number of floating panels.
|
||||
separate from Frame Control. Public reports describe some Proton support;
|
||||
Windows-only does not by itself prove a Frame app cannot run. Local status
|
||||
and sources are in [VR utilities](vr-utilities.md).
|
||||
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
|
||||
- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in
|
||||
headset creates its own gamescope panel using built-in tools. It needs no
|
||||
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# PC in the headset (Experimental)
|
||||
|
||||
**Tools → PC in the headset**, on Windows only. It's the Windows version of
|
||||
[Mac in the headset](mac-in-headset.md): a whole screen on your PC
|
||||
becomes its own SteamVR panel in the Frame. Individual windows are available only
|
||||
with streaming libraries built with Windows Graphics Capture (WGC); the shipped
|
||||
MSYS2 libraries disable WGC, so this build explains that limitation and offers
|
||||
whole screens. Place the panel with the dashboard's Float
|
||||
in World, Move and Size, point and click with the laser, scroll with the
|
||||
thumbstick, and type on the PC's keyboard.
|
||||
|
||||
**It's experimental.** A Windows 11 VM has captured its real desktop with
|
||||
Desktop Duplication and encoded it with Media Foundation and JPEG. Physical PCs,
|
||||
hardware encoding and physical laser input remain unverified. See
|
||||
[the dated evidence](evidence/windows-vm-2026-10-24.md) for results and limits.
|
||||
Reports are welcome through the
|
||||
[feedback form](https://frame-control.pages.dev/feedback/).
|
||||
|
||||
## Turning it on
|
||||
|
||||
It's off by default. Until you tick **Turn on PC in the headset** on the card,
|
||||
Frame Control starts no helper, loads none of the streaming libraries and lists
|
||||
no windows. The choice is remembered on this computer. Turning it off ends
|
||||
every stream, closes the viewers in the headset and stops the helper.
|
||||
|
||||
The card and its Tools link only appear in the Windows app. On macOS the card
|
||||
is Mac in the headset, as before; on Linux and the iPhone app there's no card.
|
||||
|
||||
## How it works
|
||||
|
||||
The ported pieces are from [PR #38](https://github.com/saphid/frame-control/pull/38).
|
||||
Frame Control owns the host helper, input, authentication, streaming protocol,
|
||||
panel launcher and adaptation. It doesn't need Sunshine, OBS or any other
|
||||
streaming app. GStreamer is bundled as ordinary libraries.
|
||||
|
||||
| | Windows |
|
||||
|---|---|
|
||||
| Capture | GStreamer's `d3d11screencapturesrc`: Desktop Duplication for whole screens in the shipped build; Windows.Graphics.Capture by window (HWND) or screen (HMONITOR) when that implementation is present |
|
||||
| Encoding | Media Foundation's H.264 encoder (`mfh264enc`), low latency, no B-frames. Whether it uses the GPU depends on the PC; **not verified** |
|
||||
| Input | `SendInput`, mapped through the source's bounds, with per-monitor DPI awareness |
|
||||
|
||||
API references:
|
||||
[Windows capture](https://learn.microsoft.com/en-us/windows/uwp/audio-video-camera/screen-capture),
|
||||
[GStreamer WGC](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11screencapturesrc.html),
|
||||
[Media Foundation encoder](https://gstreamer.freedesktop.org/documentation/mediafoundation/mfh264enc.html).
|
||||
|
||||
It needs Windows 10 or 11 with Desktop Duplication and a Media Foundation
|
||||
H.264 encoder (Windows "N" editions need the Media Feature Pack). WGC-enabled
|
||||
builds can also capture individual windows. Clicks and
|
||||
keys can't reach administrator apps or the sign-in screen, which an ordinary
|
||||
process can't drive. If Windows won't bring the chosen window to the front,
|
||||
input is refused rather than sent to the app covering it: bring the window
|
||||
forward, then Stop and Show again. Minimised or closed windows stop sending
|
||||
pictures. Protected video isn't supported. Mac in the headset's "own display
|
||||
per window" is Mac-only.
|
||||
|
||||
The pieces it shares with Mac in the headset:
|
||||
|
||||
- `ui/frame_macview.py` owns the SSH tunnel, reconnects, quality presets and
|
||||
panel launch. `ui/frame_pcview.py` picks the Windows helper
|
||||
(`ui/frame_pc_agent.py`, `ui/frame_pc_capture.py`, `desktop/capture.c`) and
|
||||
holds the on/off switch. `/api/macview` is the endpoint for both.
|
||||
- `ui/mac-view.html` is the one viewer in the headset. Its frame header, H.264
|
||||
and JPEG payloads, reconnect handshake, clock sync, timing reports and input
|
||||
messages are unchanged.
|
||||
- `desktop/controller.c` is a C port of the Mac helper's Swift rate controller,
|
||||
for the Windows helper. The Mac keeps its Swift original;
|
||||
`tests/test_pc_controller.py` checks the port against a frozen 600-state
|
||||
trace from it. Capture is gated before encoding, so encoded frames are never
|
||||
dropped, and an idle window's last picture still goes out once the link
|
||||
clears.
|
||||
- Stream records use the Mac helper's JSON schema (`ui/frame_stream_stats.py`),
|
||||
so the benchmark's analysis is shared.
|
||||
|
||||
## Packaging
|
||||
|
||||
The Windows installer carries `resources/desktop/bundle`: `pc-host.dll` (our
|
||||
adapter and the controller), the GStreamer plugins it uses (`coreelements`,
|
||||
`videotestsrc`, `videoconvertscale`, `app`, `jpeg`, `videoparsersbad`,
|
||||
`d3d11`, `mediafoundation`) and the DLLs they depend on, all from MSYS2's
|
||||
UCRT64 packages. `libraries.json` records the GStreamer version, the plugins,
|
||||
each copied DLL and the exact package versions; licence texts are in
|
||||
`licenses/`.
|
||||
|
||||
MSYS2's `gst-plugins-bad` build disables `d3d11-wgc`. The presence of
|
||||
`d3d11screencapturesrc` alone does not prove WGC support: its `capture-api`,
|
||||
`window-handle` and `show-border` properties are conditional. The helper queries
|
||||
those properties on the instantiated element, advertises only supported
|
||||
sources, and omits all three properties for the DXGI whole-screen pipeline.
|
||||
It never substitutes a screen crop for an unavailable window capture.
|
||||
|
||||
The x264 encoder is **not** in the installer. It's GPL, and Windows encodes
|
||||
with Media Foundation, so only CI's test builds include it
|
||||
(`desktop/build.py --with-x264`). The macOS and Linux installers are unchanged.
|
||||
|
||||
From a source checkout on Windows, build the bundle from an MSYS2 UCRT64 shell
|
||||
with the packages listed in `.github/workflows/pc-host.yml`:
|
||||
|
||||
```sh
|
||||
python desktop/build.py # what the installer ships
|
||||
python desktop/build.py --with-x264 # for the native tests
|
||||
```
|
||||
|
||||
A missing bundle is reported on the card; nothing is downloaded on first use.
|
||||
|
||||
The Linux adapter from #38 (desktop portal, PipeWire, VA-API) is in the tree,
|
||||
because it shares `capture.c` and the controller, and CI still builds and tests
|
||||
it. The app never offers it and the Linux installers don't include it.
|
||||
|
||||
The [2026-10-24 lxso2 feasibility check](evidence/linux-pc-host-lxso2-2026-10-24.md)
|
||||
found no running or activatable desktop portal, no PipeWire socket and missing
|
||||
native build dependencies. Linux support remains disabled. The evidence records
|
||||
the build failure and prerequisites for a screen-only, view-only x264 slice;
|
||||
Linux capture, input and window modes have not been verified there.
|
||||
|
||||
## Measuring
|
||||
|
||||
The Mac benchmark accepts the PC host:
|
||||
|
||||
```sh
|
||||
python scripts/macview-bench.py run --pc --scenario test --label pc-test
|
||||
python scripts/macview-bench.py run --pc --scenario capture --source window:12345 --label windows-window
|
||||
```
|
||||
|
||||
`capture` needs a window or screen id from the helper's `/windows` or
|
||||
`/displays`. CPU sampling isn't available on PC hosts. See
|
||||
[Mac in the headset](mac-in-headset.md) for the targets and shaping options.
|
||||
|
||||
## What's verified
|
||||
|
||||
- **Verified in CI, this PR:** on `windows-latest`, the native adapter builds
|
||||
and a real x264 synthetic stream passes the ticket, reconnect, timing and
|
||||
input-echo tests through the helper. The Windows installer builds with the
|
||||
bundle, and its bundled Python loads the bundle, starts the helper and
|
||||
confirms x264 isn't in it. Unit tests check the Windows-only, off-by-default
|
||||
gating, the card and the Tools link on macOS, Windows and Linux.
|
||||
- **Verified, #38, 2026-09-28, real Frame on SteamOS BUILD_ID 20260925.6191901:**
|
||||
the PC helper (ARM64, on the Frame itself, x264 test pattern) streamed
|
||||
through a Mac SSH relay into the shared viewer: 400 frames at 39.3 fps,
|
||||
content latency p50/p95 15.1/28.6 ms, synthetic input p50 76.6 ms, at
|
||||
revision `cd20243`. The frame-rate and late-frame targets failed. This
|
||||
proves the helper and the viewer work together; it isn't Windows capture or
|
||||
a real PC link.
|
||||
[Raw result](../bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json),
|
||||
[earlier probe](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
|
||||
- **Verified, #38, on a Mac:** the C controller matched all 600 states of the
|
||||
Swift controller's congestion and recovery trace.
|
||||
- **Verified, 2026-10-24:** Windows 11 VM build 26200.8037 on lxso2, using
|
||||
the v0.4.10 installer with this fix, captured its actual desktop and streamed
|
||||
H.264 to Frame BUILD_ID `20261015.6141207`: 219 sent frame records, 215 decoded
|
||||
and drawn before the snapshot. The own-panel screenshot shows the Windows
|
||||
desktop. This short, mostly idle run does not establish performance targets.
|
||||
[Evidence, raw traces and cleanup](evidence/windows-vm-2026-10-24.md).
|
||||
- **Not verified:** Windows.Graphics.Capture, physical-PC capture, hardware
|
||||
Media Foundation encoding, encoder restarts when the bitrate
|
||||
changes, mixed-DPI and multi-monitor input alignment, window resize and
|
||||
minimise, non-US keyboard layouts, physical laser input, and how it performs
|
||||
in a worn headset. No numbers are claimed for these. Not tested with a
|
||||
worn headset. See the dated VM evidence for the separate automated checks.
|
||||
- **Not reviewed independently.** #38 tried an independent review that never
|
||||
completed.
|
||||
@@ -0,0 +1,224 @@
|
||||
# Read-only power and heat measurements
|
||||
|
||||
Issue [#52](https://github.com/saphid/frame-control/issues/52) starts with
|
||||
measurement. `scripts/frame-power-sample.sh` is a bounded, on-demand sampler
|
||||
over the existing `ssh frame` alias. It needs Python 3 on both computers and
|
||||
an already paired, awake, reachable Frame. It uses strict SSH host-key checking
|
||||
and batch mode; it never pairs, prompts for a password or changes SSH security.
|
||||
|
||||
```sh
|
||||
# Twelve samples, five seconds apart (about 55 seconds), JSON report:
|
||||
scripts/frame-power-sample.sh > power.json
|
||||
|
||||
# Five-minute observation: samples at 0, 5, ... 300 seconds:
|
||||
scripts/frame-power-sample.sh --count 61 --interval 5 > baseline.json
|
||||
|
||||
# Long-form CSV samples; JSON summary goes separately to stderr:
|
||||
scripts/frame-power-sample.sh --count 12 --interval 5 --csv > power.csv 2> summary.json
|
||||
```
|
||||
|
||||
Count is limited to 1–720; interval to finite values from 1–60 seconds. These
|
||||
are target start-to-start intervals, not guarantees: read latency can delay a
|
||||
sample. Wall-clock `time` and monotonic `elapsedSeconds` record actual timing.
|
||||
The first sample has no CPU utilization: two valid `/proc/stat` observations
|
||||
are required. CPU utilization then covers the interval between observations,
|
||||
averaged across the machine, rather than the utilization of one process.
|
||||
|
||||
## Start, stop and open an accessible report
|
||||
|
||||
From a source checkout with Python 3 and your already trusted `ssh frame`
|
||||
connection, start a local observation:
|
||||
|
||||
```sh
|
||||
scripts/frame-power-sample.sh --count 12 --interval 5 --html power-report.html
|
||||
```
|
||||
|
||||
Progress is printed on your computer as each sample arrives. Wait for completion,
|
||||
or press **Ctrl+C once** to stop local collection and save the samples already
|
||||
received. Open `power-report.html` in your browser using its Open File command.
|
||||
The file is self-contained and works offline: no server, scripts, external
|
||||
resources, account or upload. It includes a table with named metrics, source
|
||||
paths, units, minimum/mean/maximum/last values, and available/unknown counts.
|
||||
Keyboard users can focus the table region to scroll through its columns.
|
||||
Charging states and entirely unavailable sensor families remain explicit.
|
||||
Use **Download JSON samples and summary** or **Download CSV samples** to save
|
||||
raw measurements. The HTML also embeds those measurements; review it before
|
||||
sharing. No automatic browser launch occurs.
|
||||
|
||||
HTML sessions are limited to `count × interval <= 600` seconds, with a 30-second
|
||||
transport allowance and at most twelve seconds of local child/pipe cleanup. The default
|
||||
12 samples at five-second intervals take about 55 seconds. Existing output files
|
||||
are never overwritten; choose a new filename for each run. `--html` cannot be
|
||||
combined with `--csv`. The desktop flow below uses the same sampler and renderer.
|
||||
|
||||
Stop, timeout, SSH failure or a smoke-test lock marks the report incomplete;
|
||||
earlier received samples remain downloadable. Exit status is 75 for incomplete
|
||||
reports, 0 for completion. A connection that cannot start may instead return 1
|
||||
without an HTML file. Empty reports clearly show zero samples, not a successful
|
||||
measurement. If saving the file fails, received JSON is printed on stdout as a
|
||||
recovery path. Do not treat a partial report as a full baseline.
|
||||
|
||||
Stopping terminates only this command's local SSH child. It sends no remote
|
||||
process-control command. The bounded remote Python reader exits when it next
|
||||
writes to the closed connection (normally at the next sample, up to one interval
|
||||
later); it can still read sensors before discovering the disconnect. Stop is
|
||||
not an immediate headset-side cancellation guarantee. The reader still checks
|
||||
the smoke-test lock before every sample. No lock is acquired or removed by this
|
||||
flow, and no power, thermal, service, password or security controls are exposed.
|
||||
|
||||
## Desktop Tools → Power & heat report
|
||||
|
||||
In the packaged desktop app, connect to an already paired Frame, open **Tools →
|
||||
Power & heat report**, choose **1 minute** (12 samples) or **5 minutes** (60 samples),
|
||||
and select **Start observation**. Samples arrive every five seconds, starting at
|
||||
zero (about 55 or 295 seconds total). Progress shows received samples and the
|
||||
original headset alias. There is at most one observation per local app server;
|
||||
viewing progress never starts another SSH connection. No terminal, checkout,
|
||||
extra dependency or password is needed.
|
||||
|
||||
Use **Stop and keep samples** to end local collection. Disconnect, timeout,
|
||||
app shutdown or switching to another headset also ends collection. Switching
|
||||
network addresses for the same headset does not retarget an existing observation:
|
||||
it keeps its captured SSH route and may finish incomplete if that route fails.
|
||||
As with the CLI, only the owned local SSH child is terminated. The remote reader
|
||||
is bounded and discovers a closed connection on its next write. Desktop sessions
|
||||
have a 30-second transport allowance and bounded child cleanup. Stream lines are
|
||||
limited to 262,144 characters, sample events to the requested count, and the receive queue
|
||||
to count + 2 events; desktop SSH stderr is discarded rather than stored unbounded.
|
||||
The existing smoke lock is checked locally before start and remotely before each
|
||||
sample; this feature never creates, bypasses or removes that lock.
|
||||
|
||||
**Download HTML report**, **Download JSON** and **Download CSV** save local
|
||||
snapshots through the browser/app download mechanism. Open the downloaded HTML
|
||||
in your browser to view the offline table and its embedded JSON/CSV downloads.
|
||||
Snapshots taken before completion explicitly say incomplete. Desktop CSV includes
|
||||
`collection.state`, `collection.requestedSamples` and `collection.receivedSamples`
|
||||
metadata rows with the original headset alias in the source column, even when no
|
||||
sensor sample arrived. CSV text fields, including the alias, are escaped if they
|
||||
begin with a spreadsheet formula prefix. JSON records the alias and device ID;
|
||||
HTML names the alias.
|
||||
|
||||
Only the latest observation is retained in server memory, including partial
|
||||
samples after stop or disconnect. **Download before starting another observation,
|
||||
restarting the server or quitting the app**: those discard the previous report.
|
||||
Page reloads retain server state. Download requests include an observation ID, so
|
||||
an old page cannot silently download or stop a replacement observation. Reports
|
||||
are not uploaded or automatically opened, and may contain device identifiers,
|
||||
OS builds and sensor paths. Review before sharing. The phone-hosted UI does not
|
||||
support starting observations.
|
||||
|
||||
## What it reads
|
||||
|
||||
- Battery percentage, driver charging state, signed battery watts and battery
|
||||
temperature, using the same parser as Frame Control's battery details.
|
||||
Positive battery watts mean charging on the Frame fuel gauge; negative means
|
||||
discharge. `Not charging` alone does not prove a charger is connected.
|
||||
- CPU utilization from `/proc/stat`; GPU clock from the known Frame devfreq path.
|
||||
- Named thermal zones in °C, with zone names retained. A thermal zone is not
|
||||
automatically a CPU sensor; there is no inferred “CPU temperature” or safe
|
||||
temperature threshold.
|
||||
- hwmon power channels, labels and driver names in watts. `vph` is the measured
|
||||
system rail, not power at the wall. CPU `apc*` and GPU `gfx` rails are separate
|
||||
readings; do not sum all channels as if they were independent power consumers.
|
||||
- Fan RPM and the Frame fan driver's raw `pwm1`. No generic percentage conversion
|
||||
is assumed. The historical Frame driver reported a percent-like value of 41.
|
||||
- Named IIO board temperatures, PM8550B input/charge current in amps, and raw
|
||||
VCNL4000 proximity if the account can read it. Raw proximity has no inferred
|
||||
worn/unworn threshold.
|
||||
|
||||
Each metric includes its unit, source path and availability state. Missing,
|
||||
unreadable or invalid values are JSON `null`, not zero. A missing sensor family
|
||||
is also listed in each sample's `unavailableGroups`; CSV adds unavailable group
|
||||
rows. Numeric zero remains a valid reading. The OS build is recorded per sample
|
||||
when `/etc/os-release` is readable; hostnames, addresses, machine IDs, process
|
||||
lists, logs, screenshots and application inventories are not collected.
|
||||
|
||||
The JSON report contains raw samples and a summary per metric: available and
|
||||
unavailable counts, numeric minimum/maximum/arithmetic mean/first/last, or the
|
||||
observed charging states. Means exclude unavailable readings. They are sample
|
||||
means, not time-weighted energy or battery-runtime estimates. Different sample
|
||||
availability and intervals must be considered when comparing sessions.
|
||||
|
||||
Numeric validity does not establish sensor accuracy. Historical readings
|
||||
already showed an apparently absent/broken right-display thermistor near
|
||||
−16 °C. This firmware also reports one charger-lite die sensor at −60 °C.
|
||||
Those raw numbers remain visible for diagnosis; neither should be used to
|
||||
infer cooling performance or safety. This tool does not diagnose heat, explain
|
||||
a spike, predict battery health, or promise power savings.
|
||||
|
||||
## Safety and shared-device use
|
||||
|
||||
Only file reads under `/proc`, `/sys` and `/etc/os-release` run on the Frame.
|
||||
The Python sources are sent on SSH stdin and not installed on the headset.
|
||||
There is no sudo, OpenVR initialization, service call, settings write, process
|
||||
control, journal query, wake, suspend or reboot. No daemon, telemetry upload or
|
||||
automatic measurement is added. Redirects above write only to your computer.
|
||||
The observer itself uses a little CPU and SSH traffic, so label baseline
|
||||
conditions and avoid interpreting very small changes as savings.
|
||||
|
||||
The sampler checks `/tmp/frame-test.lock` on the computer before SSH, and on
|
||||
the Frame before any probe and before each sample. If a smoke session is active,
|
||||
it exits 75. A lock appearing mid-run stops the next sample, retains earlier
|
||||
samples, sets `stopped`, and exits 75. It never removes or acquires another
|
||||
session's lock. This is an advisory check, not an atomic lease: a smoke session
|
||||
can start during a file read. Coordinate sessions and defer measurement when
|
||||
timing overlaps. It does not block someone else from starting a smoke test.
|
||||
|
||||
SSH failures/timeouts return a nonzero exit and an explanation, without
|
||||
presenting a zero or an old report as a successful measurement. Fully missing
|
||||
sensors can still produce a successful report with explicit unavailable states.
|
||||
On a connection failure, partial samples buffered on the headset can be lost.
|
||||
|
||||
## Evidence and remaining work
|
||||
|
||||
Read-only checks on **2026-10-24**, SteamOS **0.5.6**, build
|
||||
**20261015.6141207**, confirmed battery, CPU ticks, thermal zones, rail power,
|
||||
fan, GPU clock, board temperatures and charger current through the final
|
||||
sampler. Proximity was unavailable. Both JSON and CSV paths were exercised;
|
||||
see [the recorded results](evidence/power-sampler-2026-10-24.json).
|
||||
No smoke lock was present at the preflight or sampler checks. No settings,
|
||||
services, application state or device files were changed. This short run is
|
||||
a parser/transport check, not a controlled baseline or a worn-headset test.
|
||||
|
||||
The original planning document is in commit `fe132e3` (2026-09-29). It recorded
|
||||
an off-head, charging idle baseline of about 4.71 W, CPU zones around 40–45 °C,
|
||||
and about 8,350 fan RPM on build 20260925.6191901. Those conditions and firmware
|
||||
differ from the current check; comparisons do not establish a regression.
|
||||
|
||||
This change completes only the sampler portion of “Plan: leave it on, but
|
||||
cheaply.” Controlled worn/home-space, streaming/game load and unplugged
|
||||
baselines, and the cause of the 2026-09-28 95 °C spike, remain uninvestigated.
|
||||
Quiet mode, compositor changes, CPU/fan/charging changes and remote-wake tests
|
||||
are separate work. No controls are offered here. Follow the shared-device
|
||||
procedure in [testing.md](testing.md#headset-smoke-test) for future experiments.
|
||||
|
||||
The accessible HTML export was checked on the same build on **2026-10-24**:
|
||||
three streamed samples, 91 metric summaries and 276 CSV data rows. Chromium
|
||||
preview checks at 1280 px and 390 px confirmed the table stays in its scroll
|
||||
region; both browser downloads matched the original samples. See
|
||||
[export evidence](evidence/power-export-2026-10-24.json). Ctrl+C retention and
|
||||
local process cleanup were tested with local subprocess fixtures, not on the
|
||||
real headset. Screen-reader, Windows and macOS checks were not performed.
|
||||
|
||||
Offline validation:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p 'test_power*.py' -v
|
||||
bash -n scripts/frame-power-sample.sh
|
||||
python3 -m py_compile scripts/frame_power_sample.py scripts/frame_power_report.py
|
||||
```
|
||||
|
||||
Fixtures cover units, signed and zero values, missing/malformed/permission-denied
|
||||
readings, named/discovered channels, unknown charging state, CPU warmup,
|
||||
summary counts, CSV quoting, strict SSH transport and smoke-lock interruption.
|
||||
|
||||
Export fixtures also cover HTML escaping, download round trips, zero versus unknown,
|
||||
stream completion/failure, smoke-lock stop, timeout, Ctrl+C retention, local child
|
||||
cleanup, duration bounds and refusing to overwrite reports.
|
||||
|
||||
Desktop validation on **2026-10-24** covered API start/stop, a synthetic browser
|
||||
observation with partial exports, shared-module resource staging under isolated
|
||||
Python and missing-resource build guards. A real Frame probe under this task's
|
||||
owned smoke lock correctly returned zero samples with an explicit incomplete
|
||||
reason; no successful live sensor baseline or native installer run was performed.
|
||||
See [desktop evidence and limitations](evidence/power-desktop-2026-10-24.json).
|
||||
+106
-14
@@ -33,23 +33,46 @@ computer, exactly as they were sent.
|
||||
yours.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
- `app_installed`, `app_updated` and `app_opened` carry `country`: a
|
||||
two-letter code such as `DE`, worked out on your computer from its time zone
|
||||
setting (`Europe/Berlin` becomes `DE`), so we can see roughly where Frame
|
||||
Control is used without knowing your address. The time zone itself isn't
|
||||
sent, and a setting that isn't a country's zone (such as UTC) sends nothing.
|
||||
Copies that showed the first-run notice before it mentioned this (before
|
||||
0.4.6) show it again, and don't send a country until it has been seen.
|
||||
- `frame_connected` carries `headset_id`: the first 16 hex digits of a
|
||||
SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset,
|
||||
so the machine-id itself never leaves it. It is the same for one headset on
|
||||
every computer, which lets us count headsets rather than installs; it can't
|
||||
be turned back into the machine-id or a serial number. `headsets_saved` is how
|
||||
many headsets this copy of Frame Control has set up.
|
||||
|
||||
## Usage events
|
||||
|
||||
| Event | When | Properties besides the common ones |
|
||||
|---|---|---|
|
||||
| `app_installed` | First run | |
|
||||
| `app_updated` | First run of a new version | `from_version` |
|
||||
| `app_opened` | At most once a day | |
|
||||
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
|
||||
| `app_installed` | First run | `country` |
|
||||
| `app_updated` | First run of a new version | `from_version`, `country` |
|
||||
| `app_opened` | At most once a day | `country` |
|
||||
| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` |
|
||||
| `tab_viewed` | The first click on each tab in a session | `tab` |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category`, `installer_code`, and see below |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
|
||||
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
|
||||
|
||||
`install_finished` never includes a file name, path or error message. An
|
||||
error becomes one category from a fixed list (for example `apk_wrong_abi` or
|
||||
`frame_unreachable`), plus Android's own `INSTALL_FAILED_…` code when there
|
||||
is one. It names what was installed only when that's already public:
|
||||
error becomes one category from this fixed list, plus Android's own
|
||||
`INSTALL_FAILED_…` code (`installer_code`) when there is one:
|
||||
`android_installer`, `apk_needs_newer_android`, `apk_wrong_abi`,
|
||||
`layer_missing`, `apk_repack_failed`, `tool_missing`, `apk_unreadable`,
|
||||
`cant_run_on_frame`, `steam_shortcut`, `steam_not_running`,
|
||||
`steam_not_responding`, `steam_js_error`, `artwork_failed`, `copy_timeout`,
|
||||
`frame_not_set_up`, `frame_auth`, `frame_unreachable`, `frame_disk_full`,
|
||||
`copy_failed`, `download_failed`, `flatpak`, `cancelled`, `lepton`,
|
||||
`internal_error` (a mistake in Frame Control's own code) or `other`. A VR
|
||||
APK that installed without Frame Control's OpenXR compatibility layer,
|
||||
because this copy of the app is missing it, carries `xr_layer_missing: true`;
|
||||
otherwise that field is left out. It names what was installed only when
|
||||
that's already public:
|
||||
|
||||
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
|
||||
catalogue app's package name
|
||||
@@ -67,13 +90,21 @@ SteamOS and Lepton builds. Before sending:
|
||||
|
||||
- the notes, app name and version are scrubbed like error messages (see
|
||||
below)
|
||||
- the APK's source is kept only if it's `F-Droid` or the public host name of
|
||||
a download link (`https://example.com/…`). File names, user names,
|
||||
- the APK's source is kept only if it's `F-Droid`, `meta-store` (a test
|
||||
from [My Meta library](meta-library.md)) or the public host name of a
|
||||
download link (`https://example.com/…`). File names, user names,
|
||||
passwords, ports, paths, IP addresses and local host names are dropped
|
||||
|
||||
When you turn this on, reports you made earlier on this computer are shared
|
||||
too.
|
||||
|
||||
My Meta library (Experimental) keeps your Meta access token in
|
||||
`meta-account.json` in this data folder, owner-only, and sends it only to
|
||||
Meta's API and download hosts. It is never in analytics, reports, error
|
||||
details or logs; its test reports carry the package, version, version code,
|
||||
title, result and the reason, nothing from your account. See
|
||||
[meta-library.md](meta-library.md#your-token-and-privacy).
|
||||
|
||||
The maintainer's `python3 ui/frame_compat_db.py sync` copies these events
|
||||
into the compatibility database, marked `via=community…`. It takes at most
|
||||
30 per reporter per day.
|
||||
@@ -93,12 +124,16 @@ scrubbed:
|
||||
names, passwords, ports, paths and queries are dropped
|
||||
- `token=`, `key=`, `password=` and similar values are replaced
|
||||
|
||||
The same error is sent at most once every 10 minutes.
|
||||
The same error is sent at most once every 10 minutes. When ssh reports that
|
||||
it couldn't reach the Frame (asleep, away, or not set up yet), or that ssh and
|
||||
the Frame refused each other's keys (a changed host key, or this computer's
|
||||
key not accepted), that is sent at most once per kind each time Frame Control
|
||||
runs.
|
||||
|
||||
## Report a problem
|
||||
|
||||
**Report a problem** is the warning-sign button in the header, also in the
|
||||
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
||||
**Report a problem** is the speech-bubble button in the header, also in the
|
||||
Settings page (**Privacy & updates**) and under **Help → Report a Problem…**. It sends the report
|
||||
privately to Frame Control's PostHog project as a `problem_report` event, the
|
||||
same way as the analytics above, so only the maintainer can read it and
|
||||
nothing is published. It works whatever the analytics settings are, because
|
||||
@@ -122,10 +157,58 @@ With **Include diagnostics** ticked (the default), the report adds:
|
||||
- the OS, its release and CPU, and the Python version
|
||||
- the Frame's SteamOS build, if it has connected since the app started
|
||||
- which analytics levels are on
|
||||
- a short connection summary, so "the app can't find the headset" can be
|
||||
diagnosed. It is made of fixed words and counts only; no text from an
|
||||
error message or from ssh, and no name you chose, goes in it:
|
||||
- the connector's state (idle, connecting, connected or failed), the stage
|
||||
it failed at (network, find, ssh, identity or login), the attempt number
|
||||
and why it started (such as "Starting up" or "Trying again")
|
||||
- how many headsets are saved; whether the active one's ssh alias is the
|
||||
default `frame` or a custom one (a custom alias itself is never named);
|
||||
whether it's saved or a bare ssh alias; and its number of addresses
|
||||
- for the current error and the last failure: the stage and an error
|
||||
category (the same fixed names as error details, such as
|
||||
`frame_unreachable`, `frame_not_set_up`, `frame_auth` or `other`), and
|
||||
how long ago the last failure was. The category is decided when the
|
||||
failure happens
|
||||
- for each address tried, only its kind (`.local`, `ipv4`, `ipv6`,
|
||||
`ipv6 link-local`, `tailscale`, `hostname`, `alias` for one read from
|
||||
`~/.ssh/config`, and what a name resolved to, such as
|
||||
`.local->ipv6 link-local`) and how the try went (answered, unresolved,
|
||||
timeout, refused, unreachable, sshfailed). Never the address itself
|
||||
- when connected, the kind of address used and its round trip in ms
|
||||
- whether this computer has a network gateway, and whether Tailscale is on,
|
||||
off or not installed
|
||||
- which kind of `ssh` the app runs (Windows OpenSSH in System32, OpenSSH in
|
||||
Program Files, Git for Windows, MSYS2/Cygwin, Homebrew or /usr/local,
|
||||
Nix, the system one, or "other"), never its path; its version, rebuilt
|
||||
from the numbers in the banner `ssh -V` prints (such as
|
||||
`OpenSSH for Windows 9.5p1, LibreSSL 3.8.2`; anything that isn't a
|
||||
plain OpenSSH banner is reported as `unknown`); and the kinds of any
|
||||
other `ssh` on the PATH.
|
||||
This is looked up once in the background after the app starts, so a
|
||||
report sent straight away may say "still being checked"
|
||||
- whether `~/.ssh/config` exists, whether it has Set Up Connection's
|
||||
managed block for the active alias, how many managed blocks it has, and
|
||||
whether a hand-written `Host` line also names the alias (yes or no; the
|
||||
alias isn't named)
|
||||
|
||||
**Also include recent activity and the server log** is off by default,
|
||||
because those lines can name files and apps. When ticked, it adds the newest
|
||||
Activity lines and server log lines, without the request lines.
|
||||
Activity lines and server log lines, without the request lines. The server
|
||||
log has a line for each failed connection attempt: its stage, the message
|
||||
shown on the connection pill, ssh's last line about it, and the address kinds
|
||||
above. That free text is scrubbed when the line is written: the addresses, ssh alias
|
||||
and display name of the headset being tried (whole, whatever their case), and
|
||||
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
|
||||
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
|
||||
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
|
||||
included) becomes `<user>@<host>:`; then the scrubbing below. Whole display names
|
||||
are hidden even when they contain a scrubber token such as `<host>` or overlap
|
||||
ssh's wording. Those overlaps also preserve redaction of URLs, home folders,
|
||||
and keys. A host name ssh mentions in some other wording can still get through,
|
||||
so check the log lines in **Show exactly what's included** before sending. A failure that repeats on every
|
||||
retry is written at most once every 5 minutes.
|
||||
|
||||
Everything is scrubbed like error details and limited to what fits in the
|
||||
report. Environment details are kept first, then the newest lines. **Show
|
||||
@@ -206,3 +289,12 @@ then every 6 hours: the latest release's `update.json` on GitHub, or
|
||||
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
|
||||
Those requests carry no id. To stop it, set
|
||||
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
|
||||
|
||||
## Screenshots and clips
|
||||
|
||||
Copying screenshots and Steam clips to your computer, by hand or with
|
||||
**Settings → Copy to this computer**, goes straight from the headset to your
|
||||
computer over the same SSH connection as everything else. Nothing is uploaded
|
||||
anywhere. What automatic copying has copied is listed, by file id, in
|
||||
`auto-sync.json` in Frame Control's data folder; see
|
||||
[recording.md](recording.md#copying-new-ones-automatically).
|
||||
@@ -0,0 +1,219 @@
|
||||
# Import from my Quest
|
||||
|
||||
Move games you own from your own Meta Quest onto the Steam Frame. Plug the
|
||||
Quest into your computer by USB; Frame Control copies the game off it with
|
||||
adb, checks it can run in Lepton, and installs it on the Frame like any other
|
||||
APK, with its own Lepton instance and Steam library entry
|
||||
([apks.md](apks.md), [vr-apks.md](vr-apks.md)).
|
||||
|
||||
There is no licence-check bypass, and there won't be. A game that uses Meta's
|
||||
licence check installs with a warning: on the Frame that check can't reach
|
||||
Meta, so the game may quit, hang, or run without online features. See
|
||||
[What won't run](#what-wont-run-and-why) below. To install games you own
|
||||
straight from Meta instead, without a Quest, see [My Meta library
|
||||
(Experimental)](meta-library.md).
|
||||
|
||||
## Before you start: developer mode on the Quest
|
||||
|
||||
adb only reaches a Quest with developer mode on:
|
||||
|
||||
1. Make a free Meta developer organisation at
|
||||
[developers.meta.com](https://developers.meta.com/horizon/) on a **verified**
|
||||
Meta account (Meta asks for a phone number or payment card to verify).
|
||||
2. In the Meta Horizon phone app, open the headset's settings and switch
|
||||
**Developer Mode** on. Restart the Quest.
|
||||
3. Plug the Quest into the computer that runs Frame Control with a USB cable
|
||||
that carries data (some charging cables don't).
|
||||
4. Put the headset on. Accept **Allow USB debugging**, and tick **Always allow
|
||||
from this computer**.
|
||||
|
||||
Frame Control uses the adb it already uses for the Frame (an adb you
|
||||
installed, else the one it bundles). On Linux, adb also needs permission to
|
||||
open the Quest: add a udev rule for Meta's USB vendor id `2833`, for example
|
||||
`SUBSYSTEM=="usb", ATTR{idVendor}=="2833", MODE="0660", GROUP="plugdev"`, and
|
||||
be in that group. Then unplug and replug.
|
||||
|
||||
The Frame's own adb connections (Frame Control's SSH tunnels to Lepton,
|
||||
`127.0.0.1:<port>`) are never touched. Only a device that reports itself as
|
||||
Oculus or Meta, or a Quest model, counts as a Quest.
|
||||
|
||||
## Using it
|
||||
|
||||
In the app: **Android → On your Frame → Import from my Quest**. Choose **Look
|
||||
for my Quest**, then **Import** next to a game. Tick **Also copy each game's
|
||||
files in Android/data** to bring the game's external files too (some games
|
||||
keep saves there). Progress shows in the panel; the result and any reason it
|
||||
stopped appear in the message log.
|
||||
|
||||
You can choose **Import** on several games at once: they wait in the **Install
|
||||
queue** (above the Quest panel) and import one after another, together with
|
||||
installs from the Android store and My Meta library. **Remove** takes a game
|
||||
out of the queue before it starts. Each queued import runs on the headset that
|
||||
was selected when you queued it; if you switch headsets first, it stops with a
|
||||
message instead.
|
||||
|
||||
A game with game data (an OBB folder, or Android/data when ticked) takes two
|
||||
imports, because Android can only take that data while the game's Lepton
|
||||
instance is running, and there is no way to start the instance without the
|
||||
game. Frame Control never starts the game itself:
|
||||
|
||||
1. **Import** installs the game and says it has data to add.
|
||||
2. Start the game from the Steam library and leave it on its first screen.
|
||||
3. **Import** again. Frame Control sees the game open with the same version,
|
||||
copies the data in, and closes the game. Start it again to play.
|
||||
|
||||
A game that quits straight away without its data can't take it this way.
|
||||
|
||||
From a terminal:
|
||||
|
||||
```sh
|
||||
python3 ui/frame_quest.py devices # what adb sees, and which is the Quest
|
||||
python3 ui/frame_quest.py list # apps on the Quest: split or not, OBB and data sizes
|
||||
python3 ui/frame_quest.py import com.example.game # copy one to the Frame
|
||||
python3 ui/frame_quest.py import com.example.game --data --serial 1WMHH000XXXXXX
|
||||
```
|
||||
|
||||
The API is `GET /api/quest` (the Quest, or guidance when there isn't one, and
|
||||
its apps) and `POST /api/quest` with
|
||||
`{"action": "import", "package": "...", "serial": "...", "data": false}`,
|
||||
which returns a background job (`/api/job`) with a stage and percentage. The
|
||||
job waits in the install queue first (`"queued": true`, stage `Queued: N
|
||||
installs before it`). `GET /api/queue` lists the running install and those
|
||||
waiting; `POST /api/queue` with `{"action": "remove", "job": "..."}` removes a
|
||||
waiting one (409 once it has started).
|
||||
|
||||
## What an import does
|
||||
|
||||
1. Finds the Quest (`adb devices -l`, then `ro.product.manufacturer` and
|
||||
`ro.product.model`). If none is ready it says why: nothing connected, not
|
||||
authorised yet (the **Allow USB debugging** prompt), no USB permission
|
||||
(Linux), offline, another Android device, or several Quests.
|
||||
2. Lists third-party apps (`pm list packages -3`), each with `pm path` (one
|
||||
line is a single APK, several a split APK) and the size of
|
||||
`/sdcard/Android/obb/<package>` and `/sdcard/Android/data/<package>`.
|
||||
Labels aren't shown before import: reading them means copying the APK.
|
||||
3. Checks there is room on the computer (the APKs, a merged and a patched copy
|
||||
of them counted uncompressed, the OBB folder and the Android/data folder
|
||||
once, since that is streamed to the Frame), then copies the APK and its splits
|
||||
into a per-user cache folder (`Frame Control/quest-import` under the
|
||||
system's cache folder).
|
||||
4. **Checks the APK before anything else is copied**, refusing:
|
||||
- 32-bit-only builds (native code but no `arm64-v8a`);
|
||||
- apps that need an Android newer than Lepton's (API 30);
|
||||
- split APKs Frame Control can't merge (below).
|
||||
Installed with a warning:
|
||||
- games that use Meta's licence check (Meta's Platform SDK,
|
||||
`libovrplatformloader.so`, in the base or any split): "uses Meta's
|
||||
licence check; it may quit, hang, or run without online features on the
|
||||
Frame. Frame Control won't remove the check." Linking the SDK doesn't
|
||||
mean a game enforces the check: Meta's store requirement for it
|
||||
(VRC.Quest.Security.1) has only been *Recommended* since 2021, and what
|
||||
a game does when the check fails is up to the game;
|
||||
- VrApi-only builds (`libvrapi.so`, no OpenXR loader), flagged as for any
|
||||
APK: the Frame has no VrApi, so they may not run.
|
||||
5. If the game isn't open on the Frame: installs it through the normal APK
|
||||
install (`frame_android.install`: VR detection, the launcher fix, the
|
||||
OpenXR compatibility layer, artwork), and says whether it has game data
|
||||
to add. An older Quest copy never replaces a newer version on the Frame
|
||||
(compared by version code, which installs now record; for installs made
|
||||
before that, it is read from the installed APK on the Frame) unless you
|
||||
tick **Allow replacing a newer version** (`--allow-downgrade`,
|
||||
`"allowDowngrade": true`). If the installed version can't be read at all,
|
||||
that permission is needed too.
|
||||
6. If the game is open on the Frame (the second import): refuses unless
|
||||
Frame Control's install record is for this package, in this package's own
|
||||
instance, at the Quest's exact version code (an open game is never
|
||||
replaced), otherwise copies the OBB folder (and `Android/data` if ticked)
|
||||
from the Quest, waits for Android to have finished booting, copies standard
|
||||
`main|patch.<version>.<package>.obb` files with the existing OBB helper
|
||||
(checked by SHA-256 inside Android, [sidequest.md](sidequest.md#obb-files))
|
||||
once Android's own `dumpsys package` in that instance shows the same
|
||||
package at the same version code,
|
||||
streams the `Android/data` files in as a tar written straight into the SSH
|
||||
transfer, then closes the game so its next start reads them, and checks
|
||||
its instance has stopped; if it hasn't, or the check itself fails (a
|
||||
failed `podman ps` never reads as "stopped"), the result says so. The game
|
||||
is closed whatever happens during the copy, including a local read error
|
||||
or Ctrl-C, and a failed close is reported without hiding the original
|
||||
error. Everything
|
||||
from the second check of the install record (after the pulls, which can
|
||||
be long) to closing the game holds the same lock as installs and removals.
|
||||
Other files in the OBB folder are listed as not copied.
|
||||
7. Deletes the cache folder, whether the import worked or not. A folder left
|
||||
by a crash is removed by a later import after six hours.
|
||||
|
||||
Only one import runs at a time. If copying game data fails, the game is
|
||||
closed and the message says so; start it and import again.
|
||||
|
||||
### Split APKs
|
||||
|
||||
Lepton runs one APK per instance, so the shared install path now merges a
|
||||
split APK into one (`frame_android.install(base, splits=[...])`, or
|
||||
`python3 ui/frame_android.py install base.apk split1.apk ...`). Merging copies
|
||||
each split's native libraries and assets into the base, drops the base
|
||||
manifest's `isSplitRequired` flag, and re-signs with Frame Control's key, as
|
||||
the launcher fix already does. Only splits that hold nothing but
|
||||
`lib/<abi>/*.so`, `assets/` and their own signature, manifest and an empty
|
||||
resource table are merged. Refused, with the reason, before anything reaches
|
||||
the Frame: splits with resources (screen density or language), code
|
||||
(`classes*.dex`), any other file, a manifest that declares components or
|
||||
libraries (`<service>`, `<uses-library>`...), feature splits, and splits of
|
||||
another package. The merged files are held in memory while the APK is
|
||||
repacked and signed (as for every patched APK), so a merge adds at most
|
||||
512 MB per file and 1 GB in all. Each file is read from its raw bytes and
|
||||
checked against both of its ZIP headers: the local header must name the same
|
||||
file with the same method, sizes and checksum as the central directory (or a
|
||||
data descriptor must carry them; ZIP64 sizes are understood), the data must
|
||||
end before the next entry, a stored file must be exactly its declared size, a
|
||||
compressed one must end exactly at the end of its data with nothing after it,
|
||||
and its checksum must match. So a forged header can't truncate a file or fill
|
||||
memory. A resource table counts as empty only
|
||||
if it defines nothing at all (styles and other complex entries included). A merged, re-signed APK can't update a
|
||||
copy signed by the publisher.
|
||||
|
||||
## What won't run, and why
|
||||
|
||||
- **Games that enforce their Meta store licence.** Many store games link
|
||||
Meta's Platform SDK (`libovrplatformloader.so`), for the licence check and
|
||||
for user ids, achievements, multiplayer, cloud saves or DLC. On the Frame the
|
||||
SDK can't reach Meta's services, which only exist on Meta headsets. A game
|
||||
that enforces the check then quits or hangs; one that doesn't may run
|
||||
without its online features. Frame Control can't tell which from the APK, so
|
||||
it installs these with a warning and **won't remove or work around the
|
||||
check**, and doesn't bundle, call or link to any tool that does. If yours
|
||||
quits, ask the developer for a build without the check, or get the game from
|
||||
Steam or itch.io. Most SideQuest, itch.io and open-source games don't link
|
||||
the SDK.
|
||||
- **32-bit games** (Quest 1 era): the Frame runs 64-bit ARM only. Refused.
|
||||
- **VrApi-only games**: the Frame has OpenXR, not Meta's older VrApi. They
|
||||
install with a warning, but may not run.
|
||||
- **Anything else that needs Meta**: avatars, Quest-only device checks,
|
||||
passthrough and other `XR_FB_*` features SteamVR lacks
|
||||
([vr-apks.md](vr-apks.md#what-steamvrs-android-runtime-supports-verified)).
|
||||
- **Private app data** (`/data/data/<package>`) can't be read over adb without
|
||||
root, so only saves in `Android/data` come across.
|
||||
|
||||
What usually works: OpenXR games without Meta's licence check, for example
|
||||
open-source and itch.io builds you sideloaded onto the Quest.
|
||||
|
||||
Only import games you own, for your own use.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
**Verified locally** with a fake adb (`tests/fakeadb.py`, `tests/test_quest.py`):
|
||||
device detection (Quest, the Frame's tunnel, the Frame over USB, other
|
||||
Android devices, unauthorised, no permissions, offline, none, several),
|
||||
package listing and split detection, the refusals and the licence-check warning, the order of copies
|
||||
(APK checked before the OBB is copied), the two-step install then data copy
|
||||
(the game is never started by Frame Control, never replaced while open, and
|
||||
closed after its data is copied), split merging and signing, the split
|
||||
refusals and size limits (including a forged zip bomb), the space estimate,
|
||||
and cache clean-up after success and failure.
|
||||
|
||||
**Not verified:** a real Quest (none was available), Quest system apps in
|
||||
`pm list packages -3`, real split APKs from the Meta Store, OBB and
|
||||
`Android/data` writes into a running Lepton instance on a real Frame
|
||||
(the OBB helper itself is also unverified there), whether games stay open
|
||||
without their OBB long enough for the second import, and the Linux udev rule.
|
||||
Large games are copied into memory while being merged or patched, as every
|
||||
patched APK already is.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Recording and Steam clips
|
||||
|
||||
Frame Control can record what the headset shows, with its sound, and save it as
|
||||
an `.mp4` on your computer. It can also export the clips and background
|
||||
recordings from Steam's own game recording, at any length, as one `.mp4` each.
|
||||
Both are on **Tools → Capture & Live**. Everything is saved to
|
||||
`~/Videos/SteamFrame`, next to the screenshots in `~/Pictures/SteamFrame`.
|
||||
Nothing needs installing on the computer or on the Frame: the work is done by
|
||||
the `ffmpeg` that SteamOS ships.
|
||||
|
||||
**Recording and Steam clip export have each been tested on a headset.** On a Frame
|
||||
running SteamOS 0.5.6 (build 20261015.6141207), Record and Stop saved an
|
||||
8-second 1080p H.264 recording with AAC sound to the computer, Stop took about a
|
||||
second, and nothing was left running or stored on the Frame. A later test on
|
||||
that build created a saved clip with Steam's own recorder and exported it with
|
||||
Frame Control's production export function. The parts we're least sure of are
|
||||
listed under [What hasn't been checked](#what-hasnt-been-checked).
|
||||
|
||||
## Record
|
||||
|
||||
Press **Record** next to **Live**. The button turns into **Stop**, and a line
|
||||
under the viewer counts the time. Press **Stop**: the recording is finished on
|
||||
the Frame, converted to `.mp4` there (without re-encoding) and copied to
|
||||
`~/Videos/SteamFrame/rec-<date>-<time>.mp4`. The line says when it's saved.
|
||||
|
||||
- **What's in it**: the same picture as **Live**, SteamVR's copy of the headset
|
||||
view (one undistorted 1920×1080 eye, from `/dev/video99`), at 30 fps and
|
||||
8 Mbit/s, encoded with x264 (the Frame's hardware encoder crashes `ffmpeg`).
|
||||
The sound is what the headset plays: the monitor of PipeWire's default
|
||||
output, read through its PulseAudio server (`-f pulse -i @DEFAULT_MONITOR@`).
|
||||
The microphone isn't recorded.
|
||||
- **Without sound**: before each recording Frame Control checks for a fraction
|
||||
of a second that `ffmpeg` can read that audio. If it can't, the recording goes
|
||||
ahead as video only, and the page and the saved message say so.
|
||||
- **Limits**: a recording stops by itself after 20 minutes and is saved as if
|
||||
you'd pressed Stop. It needs about 2.9 GB free on the Frame to start (room for
|
||||
a full-length recording twice, while it's converted), and checks the space on
|
||||
your computer before copying. One recording runs at a time.
|
||||
- **Where it waits on the Frame**: `~/.cache/frame-control/video/`, not `/tmp`,
|
||||
which is a RAM disk on SteamOS. It's deleted once copied.
|
||||
- **If the connection drops or the app quits** while recording, the Frame
|
||||
stops `ffmpeg` cleanly when the SSH channel closes: it types `q` into
|
||||
`ffmpeg`'s input, as at its keyboard, and kills it if it's still there 10
|
||||
seconds later. Not a signal: SteamOS's `ffmpeg` 7.0 catches SIGTERM and
|
||||
SIGINT and carries on recording. A recording that was never copied is
|
||||
deleted when the next one starts.
|
||||
|
||||
Recording and **Live** can run together; both read the same device.
|
||||
|
||||
## Steam clips
|
||||
|
||||
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
|
||||
clips and background recordings, newest first, with the game, length and size.
|
||||
**Export** joins one into a single `.mp4` in `~/Videos/SteamFrame`, named after
|
||||
its folder (for example `clip_620_20261020_202134.mp4`), however long it is.
|
||||
Steam's own export is reported to stop at about 60 seconds. Exports run in the
|
||||
background; ones already on your computer are marked.
|
||||
|
||||
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
|
||||
recording session.
|
||||
|
||||
```
|
||||
~/.local/share/Steam/userdata/<account>/gamerecordings/
|
||||
video/bg_<appid>_<YYYYMMDD>_<HHMMSS>/ background recording
|
||||
clips/clip_<appid>_<YYYYMMDD>_<HHMMSS>/video/bg_<...>/ a saved clip
|
||||
session.mpd
|
||||
init-stream0.m4s chunk-stream0-00001.m4s chunk-stream0-00002.m4s ...
|
||||
init-stream1.m4s chunk-stream1-00001.m4s ...
|
||||
```
|
||||
|
||||
Each stream's `init-stream` segment followed by its chunks, in number order, is
|
||||
a playable fragmented MP4 of that stream. On the Frame, `ui/frame_clips.py`
|
||||
builds those files and has `ffmpeg` put the video and audio into one `.mp4`
|
||||
with `-c copy`, so nothing is re-encoded and it takes seconds. The result is
|
||||
copied to the computer and deleted from the Frame. The export needs free space
|
||||
on the Frame of about twice the recording's size while it runs.
|
||||
|
||||
The listing doesn't rely on the folder names: any folder under `clips/` or
|
||||
`video/` (up to four levels down) holding `init-stream*.m4s` counts. A stream
|
||||
missing its init segment or its chunks is left out. Missing chunk numbers are
|
||||
shown as "pieces missing"; the rest still exports.
|
||||
|
||||
## Copying new ones automatically
|
||||
|
||||
**Settings → Copy to this computer** copies new screenshots and Steam clips
|
||||
here on their own. It's off until you turn it on, with a switch each for
|
||||
screenshots and Steam clips. While a headset is connected, Frame Control looks
|
||||
about once a minute and copies what's new with the same code as **Save** and
|
||||
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
|
||||
`~/Videos/SteamFrame`.
|
||||
|
||||
- **Only what's new.** It copies what you take after turning it on, so the
|
||||
shots and clips already on the headset don't all arrive at once. **Copy
|
||||
everything now** copies everything that isn't on this computer yet, once.
|
||||
- **Deleted stays deleted.** What it has copied is remembered, per headset, in
|
||||
`auto-sync.json` in Frame Control's data folder, so a file you delete here
|
||||
isn't copied again (**Copy everything now** would bring it back).
|
||||
- **Saved clips only.** Steam's background recording is a rolling buffer, not
|
||||
something you chose to keep, so it isn't copied; export it by hand. A clip
|
||||
Steam wrote to in the last 30 seconds is still being saved and waits for the
|
||||
next look.
|
||||
- **It gives way.** It waits while an install, an export, a recording or
|
||||
anything else is running on the headset, and stops between files if one
|
||||
starts. While a copy is running, switching headsets waits for it, as it does
|
||||
for an export. When the headset can't be reached it waits longer each time,
|
||||
up to 15 minutes, and starts over once it answers.
|
||||
- **When this computer is full** (under 512 MB free, or a copy runs out of
|
||||
space) it pauses and says so in Settings and above the lists. Free some space
|
||||
and press **Resume**.
|
||||
- **With several headsets** it copies from the one you're connected to.
|
||||
|
||||
Settings shows when it last checked, how many files it last copied and when,
|
||||
and the last problem it had. The screenshot and clip lists say when it's on.
|
||||
|
||||
## For developers
|
||||
|
||||
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
|
||||
copying, in `ui/frame_autosync.py`, does):
|
||||
|
||||
- `list_clips(run_frame)` and `export_clip(id, run_frame, run, fetch)` are the
|
||||
computer's side; the server passes how to reach the Frame.
|
||||
- `list_recordings(userdata)` and `export(userdata, id)` are the Frame's side,
|
||||
run as `python3 - list` / `python3 - export ID` with the file on stdin.
|
||||
- `Recorder` runs one recording from Start to the copy.
|
||||
- `parse_id` checks a clip id (`<account>/<clips|video>/<folder>...`, plain
|
||||
names only, no `.` or `..`) before it reaches a path or a shell.
|
||||
|
||||
The API, with the same guards as every other request:
|
||||
|
||||
| Request | Does |
|
||||
|---|---|
|
||||
| `GET /api/record` | The recording's state: `idle`, `starting`, `recording` (with `elapsed`, `audio`, `limit`), `stopping`, `saving`, `saved` (with `file`) or `failed` (with `error`) |
|
||||
| `POST /api/record {"action": "start"\|"stop"}` | Start or stop |
|
||||
| `GET /api/clips` | Steam's recordings on the Frame, each with `saved` |
|
||||
| `POST /api/clips/export {"id"}` | Starts an export; poll `/api/job` |
|
||||
| `POST /api/open {"what": "videos"}` | Opens `~/Videos/SteamFrame` |
|
||||
| `GET /api/autosync` | Automatic copying: `screenshots`, `clips`, `since` (the watermark), `checked`, `copied`, `copied_time`, `error`, `paused`, `waiting` (clips still being written) |
|
||||
| `POST /api/autosync {"action": "settings", "screenshots", "clips"}` | Turn either on or off; turning it on sets `since` to now |
|
||||
| `POST /api/autosync {"action": "now"\|"resume"}` | Copy everything not here yet, once; or resume after running out of space |
|
||||
|
||||
They aren't available when the server runs on the Frame itself (the iPhone
|
||||
app), since they save to a computer.
|
||||
|
||||
Tests: `tests/test_clips.py`, and `tests/test_autosync.py` for automatic
|
||||
copying (with a fake headset). They run the recording's shell under bash with a
|
||||
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
|
||||
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
|
||||
recording with Steam's file names and export it with the real `ffmpeg`.
|
||||
|
||||
## Real Steam clip verification and remaining checks
|
||||
|
||||
On 2026-10-24, v0.4.15 on build `20261015.6141207` recorded a ten-second
|
||||
reserved test session through Steam's own game recorder, saved it as a clip,
|
||||
then exported it with `frame_clips.export_clip`, the same implementation used
|
||||
by the desktop API. The 159,829-byte MP4 decoded completely: 10.29 seconds,
|
||||
1920×1080 H.264 video and stereo AAC audio (SHA-256
|
||||
`f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1`).
|
||||
All 616 decoded video frames and the decoded audio matched Steam's source
|
||||
segments. Recording mode was restored
|
||||
to off; the reserved title and its processes were removed, the shared test lock
|
||||
was released, and the battery remained at 99%. The Steam source clip was kept.
|
||||
|
||||
The inspected frame is nearly black in both Steam's source and the export, so
|
||||
this establishes file creation and faithful export, **not visible gameplay
|
||||
capture**. On v0.4.16, the literal desktop **Export** button was also clicked
|
||||
against that retained real Steam clip in the source UI on lxso2. The job
|
||||
completed and saved `~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4`;
|
||||
its 159,829 bytes and SHA-256 matched the previously decoded export exactly.
|
||||
The temporary server stopped, the shared device lock was released, and the
|
||||
headset remained in Game Mode at 99% battery. See
|
||||
[`docs/evidence/clip-gui-export-2026-10-24.json`](evidence/clip-gui-export-2026-10-24.json)
|
||||
for the narrow proof. This did not exercise the packaged desktop installer.
|
||||
|
||||
On 2026-10-24, the exact v0.4.17 tag exported a second real Steam-saved clip
|
||||
through `frame_clips.export_clip`, the same production path used by **Export**.
|
||||
Steam listed the clip at **100.801 seconds**, with 68 chunks and no gaps. The
|
||||
copied MP4 was **100.833 seconds** by `ffprobe` and decoded completely with
|
||||
`ffmpeg`; it was not cut at 60 seconds. The test restored Steam's recording
|
||||
mode, removed its reserved title and processes, and left Game Mode active at
|
||||
100% battery. See [the long-clip evidence](evidence/clip-long-2026-10-24.json).
|
||||
This did not click **Export** in the packaged desktop app or verify visible
|
||||
gameplay quality or perceptual audio/video sync.
|
||||
|
||||
Remaining checks include:
|
||||
|
||||
- Perceptual audio/video sync and audible content. Frame Control's separate
|
||||
**Record** path stamps camera and audio inputs with the wall clock because
|
||||
they otherwise count from different clocks.
|
||||
- Clips longer than the 100-second test, trims starting partway through a Steam
|
||||
recording, and the packaged desktop app's **Export** button. Two real saved
|
||||
clips' directory layouts were checked; the parser remains deliberately loose
|
||||
for other layouts.
|
||||
- How much x264 at 1080p30 costs the Frame while a game is running.
|
||||
- Automatic copying: that 30 seconds without a write is a safe sign Steam has
|
||||
finished saving a clip, and how a once-a-minute listing feels on the Frame.
|
||||
@@ -4,6 +4,9 @@ Where to get the Steam Frame's operating system, what's inside it, and how to
|
||||
run it for testing without the headset. For recovering a Frame that won't boot,
|
||||
see the boot menu and boot-loop entries in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing).
|
||||
To keep your game saves, settings and Android app data through a factory reset
|
||||
(`Erase User Data`) or a re-image, and to get out of Desktop Mode, see
|
||||
[backup-and-recovery.md](backup-and-recovery.md).
|
||||
|
||||
## Downloads
|
||||
|
||||
|
||||
+4
-1
@@ -14,7 +14,10 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
|
||||
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
|
||||
attaches everything to a **draft** release for that tag. Nobody is
|
||||
offered a draft.
|
||||
offered a draft. Editing a draft while installers are uploading can make
|
||||
GitHub temporarily show its tag as `untagged-…`. The workflow finds that
|
||||
draft by its exact version title, restores the tag association and retries
|
||||
the affected upload; it refuses ambiguous matches.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
|
||||
@@ -7,6 +7,10 @@ run on Linux. On Windows, use the app.
|
||||
|
||||
## Minimum typing on the headset
|
||||
|
||||
If setup or pairing fails, run `scripts/frame-doctor.sh` from this checkout.
|
||||
It checks existing SSH access without requesting passwords, pairing or applying
|
||||
repairs. See the [Frame doctor guide](frame-doctor.md) for results and next steps.
|
||||
|
||||
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
|
||||
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
|
||||
thing you type on the headset is a password you choose.
|
||||
@@ -89,6 +93,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
|
||||
| Script | Runs on | Purpose |
|
||||
|---|---|---|
|
||||
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
|
||||
| `scripts/store-on-frame.sh` | Mac → Frame | Open the APK Store (Frame Control's Android store) as a panel in the headset; installs run on the Frame (**inferred**; see [docs/apks.md](apks.md)) |
|
||||
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
|
||||
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
|
||||
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ it.) A dialog shows:
|
||||
|
||||
Install copies it to the Frame and registers it with Steam; progress shows in
|
||||
the bar and the activity log. **Sideloaded titles** lists what's installed,
|
||||
with Launch and Remove. **Copy to ~/Downloads instead** keeps the old
|
||||
with Launch, Remove and, for a game that has stopped responding, **Force
|
||||
quit…** in its ⋯ menu ([how](steam-games.md#force-quitting-a-running-game)). **Copy to ~/Downloads instead** keeps the old
|
||||
behaviour for a zip that isn't a game.
|
||||
|
||||
From a terminal:
|
||||
|
||||
+6
-1
@@ -24,7 +24,7 @@ No SideQuest implementation code was copied.
|
||||
| Installed-app management (launch, uninstall, backup) | List, launch, stop, remove, probe | Already mostly covered. Backup added here. |
|
||||
| Update notices / account library | Compatible-version lookup; no source-aware installed update notices | Useful later, medium; needs original version code and source identity recorded on install. |
|
||||
| Custom repositories | Built-in F-Droid catalogue and compatible-version indexes | Separate user-repos worker. Legacy SideQuest source has a fixed SideQuestRepos index; arbitrary current custom-repo support was not verified. |
|
||||
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | OBB backend added here; future UI can call it. UI drop wiring is not included. |
|
||||
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | The [Android backup importer](android-backups.md) now groups the APK and matching OBBs, detects desktop siblings and guides installation and data setup. |
|
||||
| Tags, price, headset filters, reviews | Text search and Lepton verdicts, not Quest headset metadata | Useful, medium; search worker owns filters. Keep source headset claims distinct from tested Frame compatibility. |
|
||||
| Screenshot/video capture and streaming | Frame screenshots/VR capture already present | Reuse existing tools; do not port Quest capture commands. |
|
||||
| Device settings and ADB utilities | Frame/Android display settings, SSH and own-instance tools | Borrow selectively; Quest CPU/GPU presets and wireless-ADB setup do not map directly to Lepton. |
|
||||
@@ -65,6 +65,8 @@ flow, not evidence of an anonymous download API. It is not implemented here.
|
||||
- `search()` raises a user-readable `SourceError` with the browse URL (zero
|
||||
limit returns no rows). It does not invent app results or report a false
|
||||
“no matching games”. The aggregate search UI should surface this source error.
|
||||
In the app, Discover doesn't link to it; the **Sources** dialog lists SideQuest
|
||||
with a link to its website instead of an on/off switch.
|
||||
- `details()` accepts a numeric listing id and returns its canonical page link,
|
||||
`downloadable: False`, empty versions/tags/headsets and the `images` shape
|
||||
`{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id;
|
||||
@@ -83,6 +85,9 @@ local SHA-256 alone must not be called publisher verification.
|
||||
|
||||
## OBB files
|
||||
|
||||
For the app's guided APK and OBB import, see [Import an Android game backup](android-backups.md).
|
||||
The CLI helper below remains available for a running instance.
|
||||
|
||||
```sh
|
||||
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb
|
||||
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb patch.42.org.example.game.obb
|
||||
|
||||
+59
-3
@@ -4,9 +4,9 @@ Frame Control's **Get games** section lists the games you own with each one's
|
||||
Steam Frame rating, installs them on the Frame, and searches the Steam store.
|
||||
This page covers how it works underneath, so you can do the same from a shell.
|
||||
|
||||
For flat-to-VR mods and Beat Saber custom songs, see the
|
||||
[per-game feasibility table](mods.md). Mod support is separate from Steam's
|
||||
Frame rating; there is no mod installer yet.
|
||||
Installed Unreal Engine games also have a **VR mod** button that installs,
|
||||
starts and removes UEVR; see [VR mods](mods.md) for how it works and the
|
||||
per-game table. Mod support is separate from Steam's Frame rating.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -101,6 +101,62 @@ Store search uses `store.steampowered.com/api/storesearch/?term=…&cc=…`. It
|
||||
returns nothing without `cc`, so Frame Control takes the country from
|
||||
`SteamClient.User.GetIPCountry()` on the Frame.
|
||||
|
||||
## Force quitting a running game
|
||||
|
||||
**Running now**, at the top of Games, lists what's running on the Frame: Steam
|
||||
games, sideloaded titles, non-Steam shortcuts and Android apps. A game's library
|
||||
tile, a sideloaded title's ⋯ menu and the Control Center's **Force quit a game**
|
||||
card have **Force quit…** too. Each asks first ("Unsaved progress will be
|
||||
lost"); from a game's own menu it checks that the game is running and says so if
|
||||
it isn't. SteamVR (app 250820, and its beta 1009850) is never listed or quit:
|
||||
on the Frame it shows the headset view, so quitting it isn't a way out of a game.
|
||||
|
||||
`ui/frame_running.py` does the work on the Frame, piped over SSH with
|
||||
`frame_steam.py` as its DevTools client:
|
||||
|
||||
```sh
|
||||
scp ui/frame_steam.py ui/frame_running.py frame:/tmp/
|
||||
ssh frame 'python3 /tmp/frame_running.py list' # {"running": [{appid, gameid, name, kind, ...}]}
|
||||
ssh frame 'python3 /tmp/frame_running.py quit 620' # {"name", "method", "message"}
|
||||
```
|
||||
|
||||
1. **What's running.** Steam starts every game, shortcut and devkit title under
|
||||
its `reaper`, as `reaper SteamLaunch AppId=<id> -- <command>`, and the reaper
|
||||
stays the parent of the game's processes. So a running game is one of your
|
||||
own processes whose program is named `reaper`, with `SteamLaunch` and an
|
||||
`AppId=` before `--`; the game is that reaper's process tree. Steam's own list
|
||||
(`SteamUIStore.RunningApps`) and Android apps whose Lepton container is up
|
||||
(`podman ps`) are added. Names come from `appStore`, else the app manifest,
|
||||
the Android app's `meta.json` or the devkit title's folder.
|
||||
2. **Steam first.** `SteamClient.Apps.TerminateApp(<game id>, false)`, the
|
||||
call Steam's own Stop makes for Android apps ([vr-apks.md](vr-apks.md)). The
|
||||
game id is the appid, or `(appid << 32) | 0x02000000` for a non-Steam
|
||||
shortcut. An Android app's container is also stopped, as the Apps tab's Stop
|
||||
does (`podman stop -t 5 lepton-steamlaunch-<instance>`).
|
||||
3. **Then signals.** If the game's processes are still there after 5 seconds,
|
||||
they get SIGTERM, deepest first; whatever is left 3 seconds later gets
|
||||
SIGKILL, the reaper last, so nothing it holds is orphaned unsignalled. A pid
|
||||
is only signalled while its start time still matches, so a reused pid is safe.
|
||||
|
||||
Nothing is found by name. These are never signalled, even inside a game's tree:
|
||||
init, the helper itself and every process above it (the SSH session), other
|
||||
users' processes, and anything named `steam`, `steamwebhelper`, `gamescope`,
|
||||
`gamescope-wl`, `gamescopereaper`, `gamescope-session`, `sshd`, `sshd-session`,
|
||||
`ssh-agent`, `systemd`, `dbus-daemon`, `dbus-broker`, `pipewire`,
|
||||
`pipewire-pulse`, `wireplumber`, `plasmashell`, `kwin_wayland`, `kwin_x11`,
|
||||
`Xwayland`, `sddm`, `vrserver`, `vrcompositor`, `vrmonitor`, `vrdashboard`,
|
||||
`vrwebhelper` or `vrstartup`, with everything under them.
|
||||
|
||||
**Not tested on a headset.** The reaper command line is how Steam launches games
|
||||
on Linux desktops and the Steam Deck, **inferred** for the Frame.
|
||||
`SteamUIStore.RunningApps` comes from Steam's client code as plugin loaders use
|
||||
it and hasn't been seen on the Frame's build; without it, the reapers alone
|
||||
decide. `TerminateApp` with a shortcut's game id is the call Steam's Stop was
|
||||
seen making for an Android app (docs/vr-apks.md); for Steam games it is
|
||||
**inferred**. Unit tests (`tests/test_force_quit.py`) cover the parsing, the
|
||||
process selection and the escalation; the fake Frame launches its games under a
|
||||
stand-in `reaper` so `tests/e2e/test_running.py` force quits a real process tree.
|
||||
|
||||
## Not yet checked
|
||||
|
||||
- Free-to-play games: `steam://install` should stop at state 3 (free license)
|
||||
|
||||
+35
-1
@@ -7,6 +7,9 @@ This covers three directions, plus input:
|
||||
- **C. iPhone → Frame**: mirror the phone inside the headset.
|
||||
- **PC VR from Linux**: [feasibility and options](linux-vr-streaming.md),
|
||||
including Valve's streaming and USB support. No Linux host tested yet.
|
||||
- **Linux desktop → Frame panels**: [PC-host feasibility on lxso2](evidence/linux-pc-host-lxso2-2026-10-24.md).
|
||||
Blocked on missing desktop portal/PipeWire and native build dependencies;
|
||||
Linux desktop capture remains disabled and unverified.
|
||||
- **Input**: type and point in the Frame from the Mac or iPhone.
|
||||
- **Live view and Control**: watch a panel flat and tap on it to use it.
|
||||
|
||||
@@ -21,6 +24,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
| **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. |
|
||||
| VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. |
|
||||
|
||||
**RDP from Windows, verified 2026-09-30:** Windows 11 25H2's Remote Desktop
|
||||
(`mstsc`) against BUILD_ID 20260925.6191901. xrdp picks TLS, not NLA, so
|
||||
Remote Desktop never asks for a user or password. It warns that the certificate
|
||||
(`www.xrdp.org`) can't be verified. After **Yes**, xrdp shows its own "Login to
|
||||
frame" box with the username blank. Any user but `steamos` gets "User does not
|
||||
exist, or could not be authenticated". Signing in as `steamos` with the
|
||||
Developer Mode password opens a Plasma (X11) desktop within about 6 seconds.
|
||||
It's a new session on display `:10`, separate from what the headset shows,
|
||||
and it uses about 1.3 GB of the Frame's memory. Closing Remote Desktop leaves
|
||||
it running, and the next sign-in reconnects to it. To end it, find its
|
||||
session with `loginctl list-sessions` over SSH (its leader is `xrdp-sesexec`)
|
||||
and run `loginctl terminate-session <id>`; the headset's own session keeps
|
||||
running.
|
||||
|
||||
**Recommendation for A:** start with Steam Link for macOS, because Valve
|
||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||
Mac with keyboard, mouse, and clipboard.
|
||||
@@ -130,7 +147,7 @@ capture it.
|
||||
|
||||
## Input: type and point in the Frame from the Mac or iPhone
|
||||
|
||||
**Built: Home → Keyboard and trackpad**, in every version of Frame Control
|
||||
**Built: Home → Type on the headset** (or press `T`), in every version of Frame Control
|
||||
(Mac, Windows, Linux, iPhone and iPad), with nothing to install on the device
|
||||
you're holding. On a phone the panel is a trackpad (drag to move, tap to click,
|
||||
two fingers to scroll, two-finger tap to right-click) plus a text field that
|
||||
@@ -261,6 +278,23 @@ How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
|
||||
-window_id <window> -i :<display>` works on gamescope's redirected windows,
|
||||
while grabbing the root gives black. It streams as H.264 like the headset view
|
||||
(about 30 fps at 720p).
|
||||
- **When no panel is found**, `frame_touch.py panels` says why (`"why"`), and the
|
||||
Desktop view shows that instead of a bare "no app panel": X tools missing,
|
||||
gamescope listing windows that aren't X11 windows on any display, the desktop
|
||||
running but not listed, or nothing open. It reads gamescope's properties from
|
||||
`:0` first (one `xprop` call) and probes the other displays, 2 s each, only if `:0`
|
||||
has none. A whole run stops after 12 s, inside the server's 20 s, and says the headset
|
||||
was too slow. Added for
|
||||
[#73](https://github.com/saphid/frame-control/issues/73) (SteamOS 0.4.5, desktop
|
||||
open but not found). The likely cause there was the page itself: a second
|
||||
`loadPanels` (the panel switcher's) replaced the Desktop view's, fixed in #84.
|
||||
If the `GAMESCOPE_FOCUSABLE_WINDOWS` property cannot be read, discovery now
|
||||
reports that the panel list is unreadable and whether a panel is open is
|
||||
unknown. A failed X connection or missing property is not evidence that the
|
||||
desktop is closed. Check that SteamVR is running, retry or use Headset view;
|
||||
if it persists, report the exact message and SteamOS version. The confirmed
|
||||
empty-list advice is retained when the property is present but empty.
|
||||
[2026-10-24 investigation and verification limits](evidence/panel-discovery-2026-10-24.md).
|
||||
- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
|
||||
text box in the panel and the text field typed into it; a trackpad move went
|
||||
exactly (+40, +25).
|
||||
|
||||
+253
-2
@@ -8,18 +8,65 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
| Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
|
||||
| Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame |
|
||||
| Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID |
|
||||
| [Windows test VM](#windows-test-vm) | `scripts/windows-vm.sh` | A Linux machine with KVM and Docker | The Windows build on a real Windows desktop, against a real Frame when needed |
|
||||
|
||||
## Unit tests
|
||||
|
||||
The read-only power/heat sampler has fixture and transport tests in
|
||||
`tests/test_power_sample.py`. Run
|
||||
`python3 -m unittest discover -s tests -p test_power_sample.py -v`.
|
||||
See [power-and-heat.md](power-and-heat.md) for sampling, smoke-lock behavior and
|
||||
real-device verification limits.
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
The Control Center is covered by `tests/test_control_center.py` and its Node
|
||||
behavior suite `tests/control_center.cjs` (also run by unittest discovery).
|
||||
They exercise production search, metrics, navigation and action handlers with
|
||||
mocked API/DOM boundaries: volume Apply/mute/readback, offline and failed reads,
|
||||
stale samples, selected-headset capture, duplicate actions and validation before
|
||||
SSH. Integration regressions run the production shared refresh, Home renderer,
|
||||
host callback and Control Center bootstrap: a pre-command status request must be
|
||||
drained before audio readback starts, and delayed startup retains sample receipt
|
||||
age, failed-read state and early host capabilities. The actual Flatpak install
|
||||
redraw cannot revive a failed sample; a new successful poll restores metrics and
|
||||
audio after either redraw or delayed mount. Draft regressions cover a pending headset
|
||||
switch and mute followed by Apply. Static asset HTTP tests and desktop/iPhone bundle checks catch missing CSS
|
||||
or JavaScript. A focused run is:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_control_center.py -v
|
||||
node --test tests/control_center.cjs
|
||||
node --check ui/control-center.js
|
||||
```
|
||||
|
||||
Responsive layout, actual browser rendering and keyboard/touch behavior still
|
||||
need browser verification. No Control Center test connects to a real headset.
|
||||
|
||||
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
|
||||
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
|
||||
|
||||
## The fake Frame
|
||||
|
||||
Frame doctor has focused offline tests in `tests/test_frame_doctor.py` and
|
||||
fake-Frame fault tests in `tests/e2e/test_doctor.py`:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_frame_doctor.py -v
|
||||
scripts/e2e.sh test_doctor
|
||||
```
|
||||
|
||||
They cover privacy filtering, host-key preservation, restart safety and missing
|
||||
journal evidence, plus asleep, full-title-storage, SSH/devkit-off and missing-key
|
||||
faults. The plan/report tests cover exact-evidence advice, blocked plans,
|
||||
private exclusive baselines, malformed input, target mismatches and comparisons
|
||||
without repair claims. The fake-Frame storage test records a full-disk baseline,
|
||||
clears only the fake fault and compares a fresh run. The fake's doctor-only
|
||||
`systemctl show` responses assume stable restart counters and inactive keep-awake; unsupported queries fail. These tests do not
|
||||
exercise a real SteamOS user manager or touch a headset.
|
||||
|
||||
`tests/fakeframe/` builds a container that stands in for the headset, and a
|
||||
second one for the computer Frame Control runs on. `scripts/e2e.sh` builds
|
||||
both, starts them with `docker compose`, runs `tests/e2e` in the host
|
||||
@@ -52,8 +99,8 @@ its guards want.
|
||||
| `sshd` with key and password logins, `rsync`, `python3` | Real |
|
||||
| Valve's steamos-devkit-service on port 32000 and its hooks, vendored unmodified in `tests/fakeframe/steamos-devkit-service` | Real; only its `dbus` import (for mDNS through systemd-resolved) is a stand-in that logs the registration |
|
||||
| Valve's devkit-utils, copied over by Frame Control itself | Real |
|
||||
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs | Fake |
|
||||
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF | The JS engine is real; the objects are fake |
|
||||
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs. Games it really runs start under a stand-in `reaper SteamLaunch AppId=<id> -- <command>` (bash with that command line), so force quit finds a real process tree | Fake |
|
||||
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF. `SteamUIStore.RunningApps` lists launches that haven't exited; `TerminateApp` ends only launches that never ran a process, so a running program needs Frame Control's signals | The JS engine is real; the objects are fake |
|
||||
| `steam`, `wpctl`, `flatpak`, `podman`, `nmcli`, `qdbus6`, `gamescopectl`, SteamOS's `steamos-enable-sshd` helper, and Lepton's launcher | Stubs that record their calls |
|
||||
| Battery, charger and thermal zones under `/sys/class` | Files the supervisor writes. `/sys` is read-only in a container and Docker's AppArmor profile refuses writes under it, so each folder is a volume mounted twice: over `/sys/class/...` for `frame_status.py` to read, and under `/var/lib/fakeframe/sys` for the supervisor to write |
|
||||
| `vrserver` and `plasmashell` | Renamed `sleep` processes, so the status page and the clipboard find them |
|
||||
@@ -140,6 +187,43 @@ does). A cleanup that fails counts as a failed step. Results go to
|
||||
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
|
||||
isn't reachable.
|
||||
|
||||
Smoke process cleanup runs before and after title removal and during the
|
||||
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
|
||||
`fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents.
|
||||
On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and
|
||||
an exact title directory in `/proc` cwd/exe or an individual absolute command
|
||||
argument; similarly named folders and shell command substrings do not count.
|
||||
Deleted cwd/exe paths still count, so a process left after file removal can be
|
||||
found. It sends SIGTERM, waits up to three seconds, then uses SIGKILL with
|
||||
another three-second bound, rescanning for survivors. PID handles prevent PID
|
||||
reuse from signalling another process. Linux Python 3.9+ with pidfd support
|
||||
is required; missing support, signal errors and survivors fail the
|
||||
cleanup step. Stopped PIDs are recorded in the results JSON.
|
||||
If the initial leftover cleanup fails, the run skips installs and pairing and
|
||||
records the failure instead of overwriting an active test title.
|
||||
|
||||
This deliberately does not stop shared Wine/Proton services by name or prefix,
|
||||
Steam, SteamVR, process groups or arbitrary descendants. A process that drops
|
||||
all title-path evidence cannot safely be attributed and is outside this
|
||||
check. Unreadable `/proc` fields provide no ownership evidence; other readable
|
||||
fields are still checked, and a wholly unattributable process is left alone.
|
||||
Real Frame verification, 2026-10-24, SteamOS build `20261015.6141207`: the
|
||||
fixed Python smoke entry point passed all 13 steps under the shared device lock.
|
||||
The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its
|
||||
remove step stopped five test-owned wrapper/Proton PIDs before removing the
|
||||
title; the following process sweep found none. The final cleanup found no
|
||||
test titles or processes, the test desktop was closed, and the battery
|
||||
remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run
|
||||
on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran
|
||||
the same entry point instead.
|
||||
The local regression suite runs real Linux processes in temporary fake title
|
||||
directories (including deleted folders and a TERM-resistant wrapper); it checks
|
||||
exit, escalation and survival of unrelated processes without SSH or a headset:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v
|
||||
```
|
||||
|
||||
`--pair` asks the devkit service to pair a new RSA key, which needs someone
|
||||
in the headset to open **Settings → Developer → Pair new host** and approve
|
||||
it; the key is checked and then taken out of `authorized_keys` again.
|
||||
@@ -163,6 +247,129 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
|
||||
accepted. The fake now refuses them the same way, and Frame Control makes ids
|
||||
Steam accepts.
|
||||
|
||||
## PC panel host tests
|
||||
|
||||
PC in the headset (Experimental, Windows only). `tests/test_pcview.py` checks
|
||||
that only Windows gets the PC host and that it starts nothing until turned on,
|
||||
the Tools link and card wording, source-bound tickets, reconnect/Stop
|
||||
revocation, input release, source-coordinate mapping and use of the existing
|
||||
panel launcher. `tests/test_pc_controller.py` checks the C rate controller
|
||||
against a frozen trace of the Mac's Swift one.
|
||||
|
||||
The `PC host libraries` workflow builds the native adapter on Windows (and
|
||||
the unshipped Linux adapter on Ubuntu x64 and ARM64), then runs a real x264
|
||||
synthetic stream through the HTTP/WebSocket agent. x264 is GPL, so only these
|
||||
CI builds include it (`desktop/build.py --with-x264`); the release workflow
|
||||
tests the installer's own bundle with its bundled Python and checks x264 isn't
|
||||
in it. `FRAME_PC_REQUIRE_NATIVE=1` and `FRAME_PC_REQUIRE_X264=1` make a missing
|
||||
bundle or encoder fail CI instead of skipping.
|
||||
|
||||
These automated CI checks do not capture a desktop, exercise Media Foundation
|
||||
or a GPU, or check laser alignment. A [Windows test VM](#windows-test-vm) run
|
||||
on 2026-10-24 captured its actual desktop with Desktop Duplication, encoded
|
||||
with Media Foundation and streamed to a real Frame. It found MSYS2 disables
|
||||
WGC: an element name alone does not prove its optional capture properties
|
||||
exist. Native property-probe tests and source-selection regressions now cover
|
||||
that boundary. [Dated evidence and limits](evidence/windows-vm-2026-10-24.md);
|
||||
[what's verified](pc-in-headset.md#whats-verified).
|
||||
|
||||
## Windows test VM
|
||||
|
||||
Windows CI runs the unit tests and checks installer contents. Desktop capture,
|
||||
installer launch and interactive behavior need a Windows machine. This is a Windows 11 VM in a
|
||||
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
|
||||
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
|
||||
|
||||
Setting it up, once, on the Linux machine:
|
||||
|
||||
- **Windows comes from Microsoft.** The container downloads the Windows 11
|
||||
image from Microsoft on first start. Windows runs unactivated, which is fine
|
||||
for testing. Don't use activation workarounds or third-party Windows images.
|
||||
- **Publish its ports on loopback only.** Map `127.0.0.1:2222:22` (SSH),
|
||||
`127.0.0.1:8006:8006` (the web console) and, if you need it,
|
||||
`127.0.0.1:13389:3389`. The Mac reaches them through `ssh -J`. Use
|
||||
`restart: "no"` and `stop_grace_period: 2m` so it only runs when someone is
|
||||
testing, and a `docker stop` shuts Windows down cleanly.
|
||||
- **Give it SSH on first sign-in.** The container runs `/oem/install.bat`
|
||||
once. Have it add the OpenSSH Server capability, start `sshd`, set PowerShell
|
||||
as its default shell, and put a dedicated public key (for example
|
||||
`~/.ssh/id_ed25519_winvm` on the Mac) in
|
||||
`C:\ProgramData\ssh\administrators_authorized_keys`.
|
||||
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
|
||||
and the tests. Inspect free memory/disk, KVM access and existing VMs before
|
||||
starting; leave other VMs alone. The first install can take tens of minutes
|
||||
(the 2026-10-24 run took about 16 minutes to reach the desktop), rather than
|
||||
the 1–2 minute startup of an already installed guest.
|
||||
- For PC streaming, check the actual bundled capture properties. MSYS2's
|
||||
GStreamer build disables WGC: this app falls back to Desktop Duplication for
|
||||
whole screens and explains why individual windows are unavailable. A VM
|
||||
capture test does not prove physical GPU encoding or laser alignment.
|
||||
|
||||
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
|
||||
|
||||
```sh
|
||||
scripts/windows-vm.sh up # start it and wait for SSH (1-2 minutes)
|
||||
scripts/windows-vm.sh put Frame-Control-Setup-x64.exe
|
||||
scripts/windows-vm.sh ps 'Start-Process "$env:USERPROFILE\Downloads\Frame-Control-Setup-x64.exe" /S -Wait'
|
||||
scripts/windows-vm.sh shot screen.png # what's on its screen
|
||||
scripts/windows-vm.sh click 723 359 # screen pixels, as in the screenshot
|
||||
scripts/windows-vm.sh keys s t e a m o s ret # QEMU key names
|
||||
scripts/windows-vm.sh down # shut Windows down
|
||||
```
|
||||
|
||||
- **Clicks go through a scheduled task.** Commands over SSH run in a
|
||||
session with no desktop, so `click` and `scroll` write the position to a
|
||||
file, and a scheduled task running as the signed-in user replays it. The
|
||||
VM's screen must be signed in; it is after `up`. Clicks and scrolls sent at
|
||||
the same time run one after another. QEMU's own `mouse_move` is relative
|
||||
and drifts, so the script doesn't use it.
|
||||
- **Screenshots may not show the pointer.** Check the result of a click (a
|
||||
menu that opens, a button that changes) rather than the pointer's position.
|
||||
- **Windows' `ssh` waits for stdin.** The script closes it for every command.
|
||||
Do the same if you run `ssh` in the VM by hand.
|
||||
- **Starting the app.** Run Frame Control in the signed-in session, not over
|
||||
SSH. Use its Start-menu shortcut via `click`, or a scheduled task like the
|
||||
one `click` uses.
|
||||
|
||||
**Testing against a real Frame.** The VM reaches the headset on the LAN like
|
||||
any other computer. Run **Set Up Connection** in the VM's Frame Control once;
|
||||
that makes the VM's keys. So the VM doesn't keep access between test runs,
|
||||
afterwards take the lines it added out of the headset's
|
||||
`~/.ssh/authorized_keys`: the ones matching the VM's
|
||||
`~/.ssh/id_ed25519_frame.pub` and, if pairing made one,
|
||||
`~/.ssh/id_rsa_frame_devkit.pub`. Check that `ssh -o BatchMode=yes frame true`
|
||||
in the VM now fails. Then, around each run:
|
||||
|
||||
```sh
|
||||
scripts/windows-vm.sh frame-key add # let the VM's key into the headset
|
||||
# ... test ...
|
||||
scripts/windows-vm.sh frame-key remove # and take it out again
|
||||
```
|
||||
|
||||
`add` appends the VM's key tagged `windows-vm-test`, unless the headset
|
||||
already trusts that key through another line. `remove` deletes only the exact
|
||||
line `add` wrote, so it doesn't undo what Set Up Connection did, and it leaves
|
||||
the file alone if it can't rewrite it. Follow the shared-device
|
||||
procedure in [Headset smoke test](#headset-smoke-test) before installing or
|
||||
launching anything on the headset.
|
||||
|
||||
**Verified 2026-09-30:** Windows 11 Pro 25H2 (build 26200) against a Frame on
|
||||
BUILD_ID 20260925.6191901. The script was used to install Frame Control 0.4.0,
|
||||
connect it to the Frame and follow Remote Desktop through to the Frame's
|
||||
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
|
||||
`frame-key` left `authorized_keys` byte-for-byte as it was.
|
||||
|
||||
## Force quit
|
||||
|
||||
`tests/test_force_quit.py` feeds `ui/frame_running.py` made-up process tables:
|
||||
the `reaper SteamLaunch AppId=` parsing, naming from Steam, manifests, Android
|
||||
`meta.json` and devkit folders, and a Frame's worth of Steam, gamescope, SteamVR,
|
||||
sshd and session processes that must never be signalled. Processes that ignore
|
||||
SIGTERM, survive SIGKILL or exit and have their pid reused check the escalation.
|
||||
The Control Center's confirmation, duplicate and headset-switch handling run in
|
||||
`tests/control_center.cjs`. `tests/e2e/test_running.py` force quits real process
|
||||
trees on the fake Frame. None of it has run on a headset.
|
||||
|
||||
## Owned media player
|
||||
|
||||
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
||||
@@ -210,3 +417,47 @@ Chromium rendering and the same helper through HTTP. Close the switcher after
|
||||
testing. [The recorded device checks](panels.md#frame-controls-panel-switcher)
|
||||
cover actual focus, HTTP guards and an OpenXR sample transition, and separately
|
||||
identify the unverified Steam-game, spatial layout, reboot and laser behaviors.
|
||||
|
||||
## Backup and recovery kit
|
||||
|
||||
`tests/test_backup.py` runs the headset-side script `ui/frame_backup_remote.py`
|
||||
for real against a temporary folder laid out like the Frame's home, with only
|
||||
SSH faked: path selection and the secrets it must never copy, the manifest and
|
||||
checksums, restore checks after a reset, restore writing only its own files,
|
||||
unsafe archives and backup names, and the exact Game Mode command.
|
||||
`tests/e2e/test_recovery_kit.py` backs up and restores a Proton save on the fake
|
||||
Frame through the server. See [backup-and-recovery.md](backup-and-recovery.md#verification).
|
||||
A full backup and an identical-file Proton save restore were checked on a
|
||||
real Frame running build `20261015.6141207`; an after-reset restore and
|
||||
Android data restore remain unverified. See the linked device evidence.
|
||||
|
||||
## Game storage
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_storage.py -v` exercises the
|
||||
read-only metadata probe, guarded HTTP endpoint and Node renderer/navigation
|
||||
regressions. All headset reads are temporary fixtures or mocked SSH. See
|
||||
[game storage](game-storage.md#verification-and-limits) for coverage and device
|
||||
verification limits. This suite does not move games or contact a headset.
|
||||
|
||||
## Battery monitoring
|
||||
|
||||
`tests/test_battery.py` covers real file parsing with temporary sysfs-like
|
||||
files, source preference, missing/invalid sensors and permission errors.
|
||||
`tests/control_center.cjs` covers charging states and optional health fields,
|
||||
alongside existing stale/offline handling. No headset is used. See
|
||||
[battery monitoring](battery-health.md) for sources, units and limitations.
|
||||
|
||||
## Host-key recovery presentation
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_link_ui.py -v` runs the page's
|
||||
connection/banner/selection functions in Node and checks the recovery CSS guard.
|
||||
`test_link.py` covers connection failures and short probe reasons with fake SSH
|
||||
and local sockets. Neither suite uses a real headset.
|
||||
|
||||
Browser verification on 2026-10-24 used the current page's markup, styles and
|
||||
recovery renderers with synthetic changed-key state and two headsets at
|
||||
1280×800. It caught a later `.item .s` rule overriding the earlier wrapping
|
||||
rule, which the original source-only assertion missed. Check computed wrapping,
|
||||
the complete explanation, tooltip punctuation, Refresh bounds and selection
|
||||
after **Use this headset** when changing these styles. This fixture does not
|
||||
exercise Electron integration or a real SSH identity change.
|
||||
+14
-3
@@ -94,7 +94,7 @@ A new shortcut is rolled back if artwork fails; failure is never reported as
|
||||
an installed app with a blank tile.
|
||||
|
||||
Artwork preference is **SteamGridDB → source images → generated fallback**.
|
||||
Set the optional free key in Frame Control's **Library artwork settings**, or
|
||||
Set the optional free key in Frame Control's **Settings → Library artwork**, or
|
||||
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
|
||||
settings override the saved key. Without a key there are no provider calls or
|
||||
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
|
||||
@@ -214,9 +214,16 @@ source-search endpoint remain unverified (the public install seam is tested).
|
||||
|
||||
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
||||
(`libovrplatformloader.so`) to check the Quest store licence need Meta's
|
||||
services. Frame Control won't work around that.
|
||||
services. Frame Control won't work around that: no patching, no stub
|
||||
libraries, no OVRPort or FramePort. Linking the SDK doesn't mean an app
|
||||
enforces the check (Meta's VRC.Quest.Security.1 has only been
|
||||
*Recommended* since 2021, and a failed check does nothing by itself; the app
|
||||
decides), so [Import from my Quest](quest-import.md) and [My Meta
|
||||
library](meta-library.md) install these with a warning: they may quit,
|
||||
hang, or run without online features.
|
||||
- **VrApi-era apps** (`libvrapi.so`, before OpenXR) need an API translator,
|
||||
not a patch.
|
||||
not a patch. They install with a warning that they may not run: some
|
||||
OpenXR games ship `libvrapi.so` without depending on it.
|
||||
|
||||
## Frame Control does this for you
|
||||
|
||||
@@ -249,6 +256,10 @@ the app wants 1.1 and enables the extensions that became 1.1 core; maps
|
||||
and keeps the current refresh rate when SteamVR refuses a requested one.
|
||||
`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
|
||||
`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`.
|
||||
If this copy of Frame Control is missing the layer's library (an incomplete
|
||||
install, or a file removed after installing), VR apps install without it and
|
||||
the install message says so; OpenXR 1.0 apps still run. Release builds fail
|
||||
to package without it (`app/build/check-resources.js`).
|
||||
|
||||
Verified on the headset (2026-09-28):
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ checks below. The PR stays draft.
|
||||
|
||||
## Our performance HUD
|
||||
|
||||
On **Home → VR comfort and performance**, the app shows a timestamped sample
|
||||
On **Home → VR performance** (folded until you open it), the app shows a timestamped sample
|
||||
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
|
||||
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
|
||||
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
|
||||
@@ -26,7 +26,7 @@ it twice reuses the existing process.
|
||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
|
||||
|
||||
OpenVR uses background application mode, which does not start SteamVR or keep
|
||||
it running. This mode also returned live timing in a read-only device probe.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ is required.** Chromium and immersive WebXR are not in this playback path.
|
||||
|
||||
## Use it
|
||||
|
||||
In **Tools → Media in the headset**, send a file, choose its layout and press
|
||||
In **Tools → Media player** (or **Play a video or photo** on Home), send a file, choose its layout and press
|
||||
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
|
||||
**Stop** removes both. Refresh reads the library and the player's state.
|
||||
The screen follows your head; it isn't a saved world-space panel.
|
||||
|
||||
@@ -190,8 +190,11 @@ ctest --test-dir frame/openxr-compat/build-host --output-on-failure
|
||||
|
||||
The script produces arm64-v8a / android-24, C++17, `-O2`, static libc++, hidden
|
||||
internal symbols, stripped output, and `-Wl,-z,max-page-size=16384`.
|
||||
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so`.
|
||||
Its SHA-256 is recorded below with the APK checks.
|
||||
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz`,
|
||||
gzipped (`gzip -9 -n`) so the Windows installer carries it intact: electron-builder's
|
||||
7-Zip compresses a bare arm64 ELF with its ARM64 filter, which the installer's
|
||||
extractor skips. Frame Control decompresses it when injecting the layer. The
|
||||
SHA-256 of the uncompressed library is recorded below with the APK checks.
|
||||
|
||||
[Host test output](evidence/ctest.txt): two tests passed, covering pure logic
|
||||
and the actual layer with a fake next layer and host-only log/JNI shims.
|
||||
|
||||
@@ -8,5 +8,8 @@ cmake -S "$here" -B "$here/build-android" -G Ninja \
|
||||
-DANDROID_STL=c++_static -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build "$here/build-android"
|
||||
mkdir -p "$here/prebuilt/arm64-v8a"
|
||||
cp "$here/build-android/libXrApiLayer_FRAME_compat.so" "$here/prebuilt/arm64-v8a/"
|
||||
shasum -a 256 "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so"
|
||||
# Committed and shipped gzipped: electron-builder's 7-Zip applies its ARM64
|
||||
# branch filter to a bare arm64 ELF, which the Windows installer's extractor
|
||||
# can't decode, so the installed app silently lacked the library.
|
||||
shasum -a 256 "$here/build-android/libXrApiLayer_FRAME_compat.so"
|
||||
gzip -9 -n -c "$here/build-android/libXrApiLayer_FRAME_compat.so" > "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"
|
||||
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,8 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1234567890ABCDEF0123456 /* NativeSessionTests.swift */; };
|
||||
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1234567890ABCDEF0123456 /* NativeSessionState.swift */; };
|
||||
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */; };
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; };
|
||||
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; };
|
||||
@@ -35,6 +37,8 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionTests.swift; sourceTree = "<group>"; };
|
||||
B1234567890ABCDEF0123456 /* NativeSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionState.swift; sourceTree = "<group>"; };
|
||||
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
|
||||
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
|
||||
@@ -100,6 +104,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8A11F3431826A26B247C0695 /* AppModel.swift */,
|
||||
B1234567890ABCDEF0123456 /* NativeSessionState.swift */,
|
||||
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
|
||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
|
||||
);
|
||||
@@ -111,6 +116,7 @@
|
||||
children = (
|
||||
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
||||
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */,
|
||||
);
|
||||
path = FrameControlTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -260,6 +266,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
|
||||
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */,
|
||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
|
||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
|
||||
@@ -279,6 +286,7 @@
|
||||
files = (
|
||||
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
|
||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
||||
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,20 @@ final class AppModel: ObservableObject {
|
||||
/// Install links that arrived before the page was ready for them.
|
||||
@Published var pendingInstallLinks: [InstallLink] = []
|
||||
|
||||
/// Native recovery remains reachable even when WebKit cannot draw a page.
|
||||
@Published private(set) var webRecoveryMessage: String?
|
||||
@Published private(set) var webReloadID = 0
|
||||
|
||||
func webContentFailed(at url: URL, message: String) {
|
||||
guard phase == .ready(url) else { return }
|
||||
webRecoveryMessage = message
|
||||
}
|
||||
|
||||
func retryWebContent() {
|
||||
webRecoveryMessage = nil
|
||||
webReloadID += 1
|
||||
}
|
||||
|
||||
private var link: FrameLink?
|
||||
private var server: HeadsetServer?
|
||||
private var forwarder: PortForwarder?
|
||||
@@ -99,18 +113,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
/// "host", "host:port" or "[v6]:port", plus a user name.
|
||||
nonisolated static func parse(host: String, user: String) -> FrameSettings? {
|
||||
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
|
||||
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
|
||||
let rest = target.host[target.host.index(after: close)...]
|
||||
if rest.hasPrefix(":"), let port = Int(rest.dropFirst()) { target.port = port }
|
||||
target.host = String(target.host[target.host.index(after: target.host.startIndex)..<close])
|
||||
} else if target.host.filter({ $0 == ":" }).count == 1, let colon = target.host.lastIndex(of: ":"),
|
||||
let port = Int(target.host[target.host.index(after: colon)...]) {
|
||||
target.port = port
|
||||
target.host = String(target.host[..<colon])
|
||||
}
|
||||
guard !target.host.isEmpty, !target.user.isEmpty, (1...65535).contains(target.port) else { return nil }
|
||||
return target
|
||||
FrameSettings.parse(host: host, user: user)
|
||||
}
|
||||
|
||||
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
|
||||
@@ -145,6 +148,7 @@ final class AppModel: ObservableObject {
|
||||
private func invalidate() {
|
||||
attempt += 1
|
||||
retrying = false
|
||||
webRecoveryMessage = nil
|
||||
}
|
||||
|
||||
/// quiet: a background retry, which leaves the failure screen up until it works.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
|
||||
/// The persisted SSH target and its editable, lossless address form.
|
||||
struct FrameSettings: Codable, Equatable {
|
||||
var host: String
|
||||
var port: Int = 22
|
||||
var user: String = "steamos"
|
||||
|
||||
var address: String {
|
||||
let name = host.contains(":") ? "[\(host)]" : host
|
||||
return port == 22 ? name : "\(name):\(port)"
|
||||
}
|
||||
|
||||
static func parse(host: String, user: String) -> FrameSettings? {
|
||||
var host = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let user = user.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var port = 22
|
||||
if host.hasPrefix("[") {
|
||||
guard let close = host.firstIndex(of: "]") else { return nil }
|
||||
let rest = host[host.index(after: close)...]
|
||||
if !rest.isEmpty {
|
||||
guard rest.hasPrefix(":"), let value = Int(rest.dropFirst()) else { return nil }
|
||||
port = value
|
||||
}
|
||||
host = String(host[host.index(after: host.startIndex)..<close])
|
||||
} else if host.filter({ $0 == ":" }).count == 1, let colon = host.lastIndex(of: ":") {
|
||||
guard let value = Int(host[host.index(after: colon)...]) else { return nil }
|
||||
port = value
|
||||
host = String(host[..<colon])
|
||||
}
|
||||
guard !host.isEmpty, !user.isEmpty, (1...65535).contains(port) else { return nil }
|
||||
return FrameSettings(host: host, port: port, user: user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Each search owns its callbacks. Cancelling invalidates even probes that cannot
|
||||
/// be cancelled while Network.framework finishes opening a connection.
|
||||
struct FrameSearchState {
|
||||
private(set) var generation = UUID()
|
||||
private(set) var manualAddress: String?
|
||||
|
||||
mutating func start(manualAddress: String?) -> UUID {
|
||||
generation = UUID()
|
||||
let typed = manualAddress?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
self.manualAddress = typed.isEmpty ? nil : typed
|
||||
return generation
|
||||
}
|
||||
|
||||
mutating func stop() { generation = UUID() }
|
||||
func accepts(_ generation: UUID) -> Bool { self.generation == generation }
|
||||
var discovers: Bool { manualAddress == nil }
|
||||
|
||||
static func address(manual: String, discovered: String?) -> String {
|
||||
let typed = manual.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return typed.isEmpty ? (discovered ?? "frame.local") : typed
|
||||
}
|
||||
}
|
||||
|
||||
/// One automatic recovery per session; only a deliberate retry or a new session
|
||||
/// replenishes it. A successful load alone must not create a process-crash loop.
|
||||
struct WebDocumentState {
|
||||
struct Delivery: Equatable {
|
||||
let generation: UUID
|
||||
let documentID: String
|
||||
}
|
||||
|
||||
private(set) var generation = UUID()
|
||||
private(set) var documentID: String?
|
||||
private(set) var delivering = false
|
||||
private var reloads = 0
|
||||
|
||||
mutating func beginDocument() {
|
||||
generation = UUID()
|
||||
documentID = nil
|
||||
delivering = false
|
||||
}
|
||||
|
||||
mutating func resetSession() {
|
||||
reloads = 0
|
||||
beginDocument()
|
||||
}
|
||||
|
||||
mutating func shouldReloadAfterFailure() -> Bool {
|
||||
beginDocument()
|
||||
guard reloads < 1 else { return false }
|
||||
reloads += 1
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func ready(_ id: String, generation: UUID) {
|
||||
guard self.generation == generation else { return }
|
||||
documentID = id
|
||||
}
|
||||
|
||||
mutating func beginDelivery() -> Delivery? {
|
||||
guard !delivering, let documentID else { return nil }
|
||||
delivering = true
|
||||
return Delivery(generation: generation, documentID: documentID)
|
||||
}
|
||||
|
||||
/// Only acknowledgement from the same live document may consume a link.
|
||||
mutating func finishDelivery(_ delivery: Delivery, acknowledged: Bool) -> Bool {
|
||||
guard delivery.generation == generation, delivering, delivery.documentID == documentID else { return false }
|
||||
delivering = false
|
||||
if !acknowledged { documentID = nil }
|
||||
return acknowledged
|
||||
}
|
||||
}
|
||||
|
||||
/// Small app-scoped preferences survive the tunnel's intentionally ephemeral port.
|
||||
struct FramePreferences {
|
||||
private let defaults: UserDefaults
|
||||
private static let keys = ["mvSeparate", "padUsed"]
|
||||
private static let prefix = "frame.control.preference."
|
||||
|
||||
init(defaults: UserDefaults = .standard) { self.defaults = defaults }
|
||||
|
||||
func read() -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
for key in Self.keys {
|
||||
if let value = defaults.object(forKey: Self.prefix + key) as? String, ["0", "1"].contains(value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func write(key: String, value: String) -> Bool {
|
||||
guard Self.keys.contains(key), ["0", "1"].contains(value) else { return false }
|
||||
defaults.set(value, forKey: Self.prefix + key)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the first caller only.
|
||||
final class Once: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@@ -18,20 +19,30 @@ final class FrameFinder: ObservableObject {
|
||||
|
||||
private var browser: NWBrowser?
|
||||
private var probeTask: Task<Void, Never>?
|
||||
private var fallback = "frame.local"
|
||||
private var search = FrameSearchState()
|
||||
private let probe: @Sendable (String) async -> Bool
|
||||
private let discoveryEnabled: Bool
|
||||
|
||||
func start(fallback: String?) {
|
||||
init(probe: @escaping @Sendable (String) async -> Bool = { await FrameFinder.sshAnswers(host: $0) },
|
||||
discoveryEnabled: Bool = true) {
|
||||
self.probe = probe
|
||||
self.discoveryEnabled = discoveryEnabled
|
||||
}
|
||||
|
||||
func start(fallback: String?, manual: Bool = false) {
|
||||
stop()
|
||||
self.fallback = (fallback?.isEmpty == false ? fallback : nil) ?? "frame.local"
|
||||
let mine = search.start(manualAddress: manual ? fallback : nil)
|
||||
let host = FrameSearchState.address(manual: search.manualAddress ?? "", discovered: fallback)
|
||||
found = nil
|
||||
since = Date()
|
||||
browse()
|
||||
if search.discovers && discoveryEnabled { browse(generation: mine) }
|
||||
probeTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
let host = self.fallback
|
||||
if self.found == nil, await Self.sshAnswers(host: host) {
|
||||
self.found = Found(host: host, name: host)
|
||||
guard let self, self.search.accepts(mine) else { return }
|
||||
if self.found == nil {
|
||||
let answers = await self.probe(host)
|
||||
guard !Task.isCancelled, self.search.accepts(mine) else { return }
|
||||
if answers, self.found == nil { self.found = Found(host: host, name: host) }
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
}
|
||||
@@ -39,20 +50,21 @@ final class FrameFinder: ObservableObject {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
search.stop()
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
probeTask?.cancel()
|
||||
probeTask = nil
|
||||
}
|
||||
|
||||
private func browse() {
|
||||
private func browse(generation: UUID) {
|
||||
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
for result in results {
|
||||
guard case let .service(name, _, _, _) = result.endpoint else { continue }
|
||||
Self.resolve(result.endpoint) { host in
|
||||
Task { @MainActor in
|
||||
guard let self, let host else { return }
|
||||
guard let self, self.search.accepts(generation), self.search.discovers, let host else { return }
|
||||
// A found device is used over the fallback probe.
|
||||
self.found = Found(host: host, name: name)
|
||||
}
|
||||
@@ -91,12 +103,8 @@ final class FrameFinder: ObservableObject {
|
||||
|
||||
/// Whether something answers on the SSH port of "host" or "host:port" within a few seconds.
|
||||
nonisolated static func sshAnswers(host address: String) async -> Bool {
|
||||
var host = address, port: UInt16 = 22
|
||||
if let target = AppModel.parse(host: address, user: "steamos") {
|
||||
host = target.host
|
||||
port = UInt16(target.port)
|
||||
}
|
||||
return await sshAnswers(host: host, port: port)
|
||||
guard let target = FrameSettings.parse(host: address, user: "steamos") else { return false }
|
||||
return await sshAnswers(host: target.host, port: UInt16(target.port))
|
||||
}
|
||||
|
||||
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
|
||||
|
||||
@@ -4,13 +4,6 @@ import Foundation
|
||||
import NIOCore
|
||||
import NIOSSH
|
||||
|
||||
/// Where the Frame is and who to log in as.
|
||||
struct FrameSettings: Codable, Equatable {
|
||||
var host: String
|
||||
var port: Int = 22
|
||||
var user: String = "steamos"
|
||||
}
|
||||
|
||||
struct FrameFailure: LocalizedError {
|
||||
let message: String
|
||||
/// Retrying can't help: the Frame's identity changed, or it refused this phone's login.
|
||||
@@ -140,12 +133,6 @@ final class FrameLink: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the first caller only.
|
||||
final class Once: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
|
||||
}
|
||||
|
||||
func shellQuote(_ s: String) -> String {
|
||||
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
|
||||
@@ -10,15 +10,31 @@ struct RootView: View {
|
||||
case .setup:
|
||||
SetupView(model: model)
|
||||
case .connecting(let step):
|
||||
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
|
||||
ConnectingView(step: step, host: model.settings?.address) { model.showSetup() }
|
||||
case .failed(let message) where model.retrying && !model.needsPairing:
|
||||
WaitingView(host: model.settings.map { $0.port == 22 ? $0.host : "\($0.host):\($0.port)" } ?? "", detail: message, deviceName: model.deviceName,
|
||||
WaitingView(host: model.settings?.address ?? "", detail: message, deviceName: model.deviceName,
|
||||
reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() })
|
||||
case .failed(let message):
|
||||
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
|
||||
retry: { Task { await model.connect() } }, change: { model.showSetup() })
|
||||
case .ready(let url):
|
||||
WebShell(url: url, model: model).ignoresSafeArea()
|
||||
.accessibilityHidden(model.webRecoveryMessage != nil)
|
||||
.allowsHitTesting(model.webRecoveryMessage == nil)
|
||||
if let message = model.webRecoveryMessage {
|
||||
RecoveryLayout {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "arrow.clockwise.circle").font(.system(size: 44)).foregroundStyle(.orange)
|
||||
Text("Reload Frame Control").font(.title3.bold())
|
||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||
Button("Try again") { model.retryWebContent() }
|
||||
.buttonStyle(.borderedProminent).controlSize(.large)
|
||||
Button("Change headset") { model.showSetup() }
|
||||
}
|
||||
.padding(32).frame(maxWidth: 480)
|
||||
}
|
||||
.background(Color.frameBackground.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
@@ -33,20 +49,37 @@ extension Color {
|
||||
static let frameMuted = Color(red: 0.561, green: 0.596, blue: 0.627)
|
||||
}
|
||||
|
||||
/// Recovery controls remain reachable on short screens and with large text.
|
||||
private struct RecoveryLayout<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
content
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: geometry.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectingView: View {
|
||||
let step: String
|
||||
let host: String?
|
||||
let cancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
ProgressView().controlSize(.large)
|
||||
Text(step).font(.headline).multilineTextAlignment(.center)
|
||||
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
|
||||
Button("Change headset", action: cancel).padding(.top, 8)
|
||||
RecoveryLayout {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
ProgressView().controlSize(.large)
|
||||
Text(step).font(.headline).multilineTextAlignment(.center)
|
||||
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
|
||||
Button("Change headset", action: cancel).padding(.top, 8)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,21 +92,23 @@ struct FailedView: View {
|
||||
let change: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
|
||||
.font(.system(size: 44)).foregroundStyle(.orange)
|
||||
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
|
||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
|
||||
if needsPairing {
|
||||
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
} else if canRetry {
|
||||
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
RecoveryLayout {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
|
||||
.font(.system(size: 44)).foregroundStyle(.orange)
|
||||
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
|
||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
|
||||
if needsPairing {
|
||||
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
} else if canRetry {
|
||||
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
}
|
||||
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
|
||||
}
|
||||
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
|
||||
.padding(32)
|
||||
.frame(maxWidth: 480)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: 480)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,34 +121,37 @@ struct WaitingView: View {
|
||||
let reachable: () -> Void
|
||||
let change: () -> Void
|
||||
@State private var pulse = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.opacity(pulse ? 1 : 0.55)
|
||||
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
|
||||
Text("Waiting for your Frame").font(.title3.bold())
|
||||
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
|
||||
.multilineTextAlignment(.center)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
|
||||
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
|
||||
RecoveryLayout {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.opacity(reduceMotion || pulse ? 1 : 0.55)
|
||||
.animation(reduceMotion ? nil : .easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
|
||||
Text("Waiting for your Frame").font(.title3.bold())
|
||||
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
|
||||
.multilineTextAlignment(.center)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
|
||||
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
|
||||
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
|
||||
Button("Connect to a different Frame", action: change).font(.footnote)
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
|
||||
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
|
||||
Button("Connect to a different Frame", action: change).font(.footnote)
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 480)
|
||||
.onAppear { pulse = true }
|
||||
.task(id: host) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
if !host.isEmpty, await FrameFinder.sshAnswers(host: host) {
|
||||
reachable()
|
||||
return
|
||||
.padding(28)
|
||||
.frame(maxWidth: 480)
|
||||
.onAppear { pulse = true }
|
||||
.task(id: host) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
if !Task.isCancelled, !host.isEmpty, await FrameFinder.sshAnswers(host: host), !Task.isCancelled {
|
||||
reachable()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,9 @@ struct SetupView: View {
|
||||
@State private var showHelp = false
|
||||
@FocusState private var passwordFocused: Bool
|
||||
|
||||
/// Where Connect goes: what the finder saw, else what was typed, else frame.local.
|
||||
/// An explicit target always wins over discovery.
|
||||
private var host: String {
|
||||
let typed = manualHost.trimmingCharacters(in: .whitespaces)
|
||||
return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed)
|
||||
FrameSearchState.address(manual: manualHost, discovered: finder.found?.host)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -34,19 +33,19 @@ struct SetupView: View {
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background(Color.frameBackground)
|
||||
.onAppear {
|
||||
manualHost = model.settings?.host ?? ""
|
||||
manualHost = model.settings?.address ?? ""
|
||||
user = model.settings?.user ?? "steamos"
|
||||
var fallback = model.settings?.host
|
||||
var fallback = model.settings?.address
|
||||
#if DEBUG
|
||||
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
|
||||
#endif
|
||||
finder.start(fallback: fallback)
|
||||
finder.start(fallback: fallback, manual: !manualHost.isEmpty)
|
||||
}
|
||||
.onDisappear { finder.stop() }
|
||||
// After a few seconds of not finding it, say exactly what to check.
|
||||
.task(id: finder.since) {
|
||||
try? await Task.sleep(nanoseconds: 8_000_000_000)
|
||||
showHelp = true
|
||||
if !Task.isCancelled { showHelp = true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +119,7 @@ struct SetupView: View {
|
||||
Text("Address").font(.footnote).foregroundStyle(Color.frameMuted)
|
||||
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
|
||||
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.onSubmit { finder.start(fallback: manualHost) }
|
||||
.onSubmit { finder.start(fallback: manualHost, manual: true) }
|
||||
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
|
||||
TextField("User", text: $user)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
@@ -149,7 +148,9 @@ struct SetupView: View {
|
||||
}
|
||||
.onChange(of: manualHost) { _, value in
|
||||
// A typed address replaces the search.
|
||||
if !value.trimmingCharacters(in: .whitespaces).isEmpty, finder.found?.host != value { finder.start(fallback: value) }
|
||||
showHelp = false
|
||||
let typed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
finder.start(fallback: typed, manual: !typed.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,14 +31,13 @@ struct WebShell: UIViewRepresentable {
|
||||
web.isInspectable = true
|
||||
#endif
|
||||
context.coordinator.web = web
|
||||
web.load(URLRequest(url: url))
|
||||
context.coordinator.load(url, reloadID: model.webReloadID)
|
||||
return web
|
||||
}
|
||||
|
||||
func updateUIView(_ web: WKWebView, context: Context) {
|
||||
if context.coordinator.loaded != url {
|
||||
context.coordinator.loaded = url
|
||||
web.load(URLRequest(url: url))
|
||||
if context.coordinator.loaded != url || context.coordinator.reloadID != model.webReloadID {
|
||||
context.coordinator.load(url, reloadID: model.webReloadID)
|
||||
}
|
||||
context.coordinator.deliverInstallLinks()
|
||||
}
|
||||
@@ -47,16 +46,22 @@ struct WebShell: UIViewRepresentable {
|
||||
(() => {
|
||||
const call = (name, arg) => window.webkit.messageHandlers.frameApp.postMessage({ name, arg: arg ?? null });
|
||||
let installCb = null;
|
||||
window.__frameDocumentID = crypto.randomUUID();
|
||||
window.frameApp = {
|
||||
platform: "ios",
|
||||
preferences: {
|
||||
read: () => call("preferencesRead"),
|
||||
write: (key, value) => call("preferencesWrite", { key, value }),
|
||||
},
|
||||
notify: (message, request) => call("notify", { message, request }),
|
||||
readClipboard: () => call("readClipboard"),
|
||||
writeClipboard: (text) => call("writeClipboard", text),
|
||||
setUpConnection: () => call("setUpConnection"),
|
||||
open: (what) => call("open", what),
|
||||
saveImages: (images) => call("saveImages", images),
|
||||
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady"); },
|
||||
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady", window.__frameDocumentID); },
|
||||
};
|
||||
window.__frameInstallLink = (req) => { if (installCb) installCb(req); };
|
||||
window.__frameInstallLink = (req) => { if (!installCb) return false; installCb(req); return true; };
|
||||
})();
|
||||
"""
|
||||
|
||||
@@ -64,7 +69,18 @@ struct WebShell: UIViewRepresentable {
|
||||
let model: AppModel
|
||||
weak var web: WKWebView?
|
||||
var loaded: URL?
|
||||
private var installReady = false
|
||||
private(set) var document = WebDocumentState()
|
||||
private(set) var reloadID = 0
|
||||
private var activeNavigation: WKNavigation?
|
||||
private let preferences = FramePreferences()
|
||||
|
||||
@MainActor
|
||||
func load(_ url: URL, reloadID: Int) {
|
||||
loaded = url
|
||||
self.reloadID = reloadID
|
||||
document.resetSession()
|
||||
activeNavigation = web?.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
init(model: AppModel) {
|
||||
self.model = model
|
||||
@@ -118,8 +134,20 @@ struct WebShell: UIViewRepresentable {
|
||||
send(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional, nil)
|
||||
}
|
||||
}
|
||||
case "preferencesRead":
|
||||
replyHandler(preferences.read(), nil)
|
||||
case "preferencesWrite":
|
||||
guard let args = arg as? [String: Any], let key = args["key"] as? String,
|
||||
let value = args["value"] as? String, preferences.write(key: key, value: value) else {
|
||||
return replyHandler(nil, "Invalid preference")
|
||||
}
|
||||
replyHandler(true, nil)
|
||||
case "readClipboard":
|
||||
replyHandler(UIPasteboard.general.string ?? "", nil)
|
||||
case "writeClipboard":
|
||||
guard let text = arg as? String else { return replyHandler(nil, "Expected text") }
|
||||
UIPasteboard.general.string = text
|
||||
replyHandler(true, nil)
|
||||
case "setUpConnection":
|
||||
model.showSetup()
|
||||
replyHandler(nil, nil)
|
||||
@@ -135,9 +163,20 @@ struct WebShell: UIViewRepresentable {
|
||||
share(images)
|
||||
replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil)
|
||||
case "installLinkReady":
|
||||
installReady = true
|
||||
deliverInstallLinks()
|
||||
replyHandler(nil, nil)
|
||||
guard let id = arg as? String, let web,
|
||||
let data = try? JSONSerialization.data(withJSONObject: id, options: .fragmentsAllowed),
|
||||
let json = String(data: data, encoding: .utf8) else {
|
||||
return replyHandler(nil, "Invalid document")
|
||||
}
|
||||
let generation = document.generation
|
||||
web.evaluateJavaScript("window.__frameDocumentID === \(json)") { [weak self] result, error in
|
||||
guard let self else { return replyHandler(nil, "Page closed") }
|
||||
if error == nil, result as? Bool == true {
|
||||
self.document.ready(id, generation: generation)
|
||||
self.deliverInstallLinks()
|
||||
}
|
||||
replyHandler(nil, nil)
|
||||
}
|
||||
default:
|
||||
replyHandler(nil, "unknown request \(name)")
|
||||
}
|
||||
@@ -145,13 +184,23 @@ struct WebShell: UIViewRepresentable {
|
||||
|
||||
@MainActor
|
||||
func deliverInstallLinks() {
|
||||
guard installReady, let web, !model.pendingInstallLinks.isEmpty else { return }
|
||||
let links = model.pendingInstallLinks
|
||||
model.pendingInstallLinks = []
|
||||
for link in links {
|
||||
let req = ["kind": link.kind.rawValue, "target": link.target]
|
||||
guard let json = try? JSONSerialization.data(withJSONObject: req), let text = String(data: json, encoding: .utf8) else { continue }
|
||||
web.evaluateJavaScript("window.__frameInstallLink(\(text))")
|
||||
guard let web, let loaded, model.phase == .ready(loaded), reloadID == model.webReloadID,
|
||||
let link = model.pendingInstallLinks.first, let delivery = document.beginDelivery() else { return }
|
||||
let req = ["kind": link.kind.rawValue, "target": link.target]
|
||||
guard let json = try? JSONSerialization.data(withJSONObject: req),
|
||||
let text = String(data: json, encoding: .utf8),
|
||||
let idData = try? JSONSerialization.data(withJSONObject: delivery.documentID, options: .fragmentsAllowed),
|
||||
let id = String(data: idData, encoding: .utf8) else {
|
||||
_ = document.finishDelivery(delivery, acknowledged: false)
|
||||
return
|
||||
}
|
||||
// Keep the head queued until the live document confirms its callback ran.
|
||||
web.evaluateJavaScript("window.__frameDocumentID === \(id) && window.__frameInstallLink(\(text)) === true") { [weak self] result, error in
|
||||
guard let self, let loaded = self.loaded, self.model.phase == .ready(loaded),
|
||||
self.reloadID == self.model.webReloadID else { return }
|
||||
guard self.document.finishDelivery(delivery, acknowledged: error == nil && result as? Bool == true) else { return }
|
||||
if self.model.pendingInstallLinks.first == link { self.model.pendingInstallLinks.removeFirst() }
|
||||
self.deliverInstallLinks()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,13 +235,55 @@ struct WebShell: UIViewRepresentable {
|
||||
(root.presentedViewController ?? root).present(sheet, animated: true)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// A new document cannot receive links using the previous page's callback.
|
||||
@MainActor
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
activeNavigation = navigation
|
||||
document.beginDocument()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
recover(webView, message: "The app page stopped responding. Try loading it again, or change headset.")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
navigationFailed(webView, navigation: navigation, error: error)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
navigationFailed(webView, navigation: navigation, error: error)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func navigationFailed(_ webView: WKWebView, navigation: WKNavigation?, error: Error) {
|
||||
guard navigation === activeNavigation, (error as NSError).code != NSURLErrorCancelled else { return }
|
||||
recover(webView, message: "Couldn't load the app page. \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func recover(_ webView: WKWebView, message: String) {
|
||||
guard let loaded, model.phase == .ready(loaded), reloadID == model.webReloadID,
|
||||
model.webRecoveryMessage == nil else { return }
|
||||
activeNavigation = nil
|
||||
if document.shouldReloadAfterFailure() {
|
||||
// Reload the original authenticated URL, including its session key.
|
||||
activeNavigation = webView.load(URLRequest(url: loaded))
|
||||
} else {
|
||||
model.webContentFailed(at: loaded, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulator test hook: FRAME_TEST_JS runs in the page once it has loaded.
|
||||
@MainActor
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
#if DEBUG
|
||||
guard let js = ProcessInfo.processInfo.environment["FRAME_TEST_JS"] else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { webView.evaluateJavaScript(js) }
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: navigation: the app's page stays here; other sites open in Safari
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
#if !NATIVE_CONTRACT_STANDALONE
|
||||
@testable import Frame_Control
|
||||
#endif
|
||||
|
||||
final class NativeSessionTests: XCTestCase {
|
||||
func testSavedAddressesRoundTripWithoutLosingPortOrIPv6() throws {
|
||||
for host in ["frame.local", "100.100.100.2", "2001:db8::42", "fe80::1%en0"] {
|
||||
for port in [22, 2222, 65535] {
|
||||
let target = FrameSettings(host: host, port: port, user: "alex")
|
||||
let saved = try JSONDecoder().decode(FrameSettings.self, from: JSONEncoder().encode(target))
|
||||
XCTAssertEqual(FrameSettings.parse(host: saved.address, user: saved.user), target)
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(FrameSettings.parse(host: " [2001:db8::42]:2222\n", user: " steamos "),
|
||||
FrameSettings(host: "2001:db8::42", port: 2222))
|
||||
for host in ["", "frame.local:0", "frame.local:65536", "frame.local:no", "[::1]:no", "[::1]junk", "[::1"] {
|
||||
XCTAssertNil(FrameSettings.parse(host: host, user: "steamos"), host)
|
||||
}
|
||||
}
|
||||
|
||||
func testManualTargetWinsAndSearchCallbacksExpire() {
|
||||
XCTAssertEqual(FrameSearchState.address(manual: " 100.100.100.2:2222 ", discovered: "192.168.1.237"), "100.100.100.2:2222")
|
||||
XCTAssertEqual(FrameSearchState.address(manual: " \n", discovered: "192.168.1.237"), "192.168.1.237")
|
||||
XCTAssertEqual(FrameSearchState.address(manual: "", discovered: nil), "frame.local")
|
||||
var search = FrameSearchState()
|
||||
let bonjour = search.start(manualAddress: nil)
|
||||
XCTAssertTrue(search.discovers)
|
||||
let manual = search.start(manualAddress: "100.100.100.2:2222")
|
||||
XCTAssertFalse(search.discovers)
|
||||
XCTAssertFalse(search.accepts(bonjour))
|
||||
XCTAssertTrue(search.accepts(manual))
|
||||
search.stop()
|
||||
XCTAssertFalse(search.accepts(manual))
|
||||
_ = search.start(manualAddress: "")
|
||||
XCTAssertTrue(search.discovers)
|
||||
}
|
||||
|
||||
func testRecoveryIsBoundedUntilAnExplicitRetry() {
|
||||
var page = WebDocumentState()
|
||||
XCTAssertTrue(page.shouldReloadAfterFailure())
|
||||
page.beginDocument()
|
||||
page.ready("reloaded", generation: page.generation)
|
||||
XCTAssertFalse(page.shouldReloadAfterFailure())
|
||||
XCTAssertFalse(page.shouldReloadAfterFailure())
|
||||
page.resetSession()
|
||||
XCTAssertTrue(page.shouldReloadAfterFailure())
|
||||
}
|
||||
|
||||
func testReplacementDocumentsCannotConsumeQueuedLinks() throws {
|
||||
var page = WebDocumentState()
|
||||
var queue = ["first", "second"]
|
||||
XCTAssertNil(page.beginDelivery())
|
||||
let oldGeneration = page.generation
|
||||
page.ready("first document", generation: oldGeneration)
|
||||
let oldDelivery = try XCTUnwrap(page.beginDelivery())
|
||||
XCTAssertNil(page.beginDelivery(), "Serialize delivery while an acknowledgement is pending")
|
||||
XCTAssertTrue(page.shouldReloadAfterFailure())
|
||||
page.ready("stale readiness", generation: oldGeneration)
|
||||
XCTAssertNil(page.beginDelivery())
|
||||
if page.finishDelivery(oldDelivery, acknowledged: true) { queue.removeFirst() }
|
||||
XCTAssertEqual(queue, ["first", "second"])
|
||||
page.ready("replacement", generation: page.generation)
|
||||
let failedDelivery = try XCTUnwrap(page.beginDelivery())
|
||||
if page.finishDelivery(failedDelivery, acknowledged: false) { queue.removeFirst() }
|
||||
XCTAssertEqual(queue, ["first", "second"])
|
||||
XCTAssertNil(page.beginDelivery(), "An evaluation error needs fresh readiness")
|
||||
page.ready("replacement", generation: page.generation)
|
||||
let liveDelivery = try XCTUnwrap(page.beginDelivery())
|
||||
if page.finishDelivery(liveDelivery, acknowledged: true) { queue.removeFirst() }
|
||||
XCTAssertEqual(queue, ["second"])
|
||||
XCTAssertNotNil(page.beginDelivery())
|
||||
page.beginDocument()
|
||||
XCTAssertNil(page.beginDelivery(), "Even healthy main-document navigation resets readiness")
|
||||
}
|
||||
|
||||
func testPreferencesPersistAcrossSessionsAndRejectOtherKeysAndValues() throws {
|
||||
let suite = "frame-control-contract-\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let first = FramePreferences(defaults: defaults)
|
||||
XCTAssertEqual(first.read(), [:])
|
||||
XCTAssertTrue(first.write(key: "mvSeparate", value: "0"))
|
||||
XCTAssertTrue(first.write(key: "padUsed", value: "1"))
|
||||
let second = FramePreferences(defaults: try XCTUnwrap(UserDefaults(suiteName: suite)))
|
||||
XCTAssertEqual(second.read(), ["mvSeparate": "0", "padUsed": "1"])
|
||||
for (key, value) in [("other", "1"), ("mvSeparate", "false"), ("padUsed", ""), ("padUsed", "2")] {
|
||||
XCTAssertFalse(second.write(key: key, value: value))
|
||||
}
|
||||
XCTAssertEqual(first.read(), ["mvSeparate": "0", "padUsed": "1"])
|
||||
XCTAssertNil(defaults.object(forKey: "frame.control.preference.other"))
|
||||
defaults.set("bad", forKey: "frame.control.preference.mvSeparate")
|
||||
defaults.set("1", forKey: "frame.control.preference.unknown")
|
||||
XCTAssertEqual(second.read(), ["padUsed": "1"])
|
||||
defaults.set(true, forKey: "frame.control.preference.padUsed")
|
||||
XCTAssertEqual(second.read(), [:], "Only stored strings are supported")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "ui/apk_sources/*.py", "ui/apk_sources/*.json", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py", "frame/android/*.js",
|
||||
"frame/openxr-compat/XrApiLayer_FRAME_compat.json", "frame/openxr-compat/prebuilt/arm64-v8a/*.so.gz",
|
||||
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
|
||||
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
|
||||
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@ started=$SECONDS
|
||||
|
||||
print "==> Building fakeframe-frame (from $base) and fakeframe-host"
|
||||
# Quiet when it works; if a build fails, build again with the full log so CI shows why.
|
||||
build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } }
|
||||
# That second build also rides out a flaky package mirror: only its failure is fatal.
|
||||
build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 }
|
||||
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
|
||||
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
|
||||
|
||||
|
||||
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user