Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddd2d1c295 | ||
|
|
8f2e34a40e | ||
|
|
351230fcad | ||
|
|
5b35e9cc5d | ||
|
|
671c4c06e7 | ||
|
|
443813a088 | ||
|
|
92a68285b5 | ||
|
|
5878c45ddb | ||
|
|
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 | ||
|
|
7faf80fc2b | ||
|
|
6ea20a2806 | ||
|
|
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 | ||
|
|
f5f3a1f9ca | ||
|
|
9d062b7ba0 | ||
|
|
a46c28d6c8 | ||
|
|
a41f635a7c | ||
|
|
67bb71a708 | ||
|
|
0b9a165d83 | ||
|
|
88a2fe6728 | ||
|
|
ba9706b69e | ||
|
|
551cc54bbc | ||
|
|
c3e651cd25 | ||
|
|
f0ba42bfba | ||
|
|
99fc15bd79 | ||
|
|
e63dc43c2f | ||
|
|
80f433a2d3 | ||
|
|
07f44f9082 | ||
|
|
c305daae15 | ||
|
|
a4031db052 | ||
|
|
049d50f43a | ||
|
|
3a95d3b638 | ||
|
|
989962aecc | ||
|
|
08d75e3ffb | ||
|
|
01d5c612c0 | ||
|
|
28073e212a | ||
|
|
6abc765e22 | ||
|
|
f73efe414c | ||
|
|
704e5d7780 | ||
|
|
edbe8d4109 | ||
|
|
f2c8466ba9 | ||
|
|
6cf01729e2 | ||
|
|
63c1a9ff55 | ||
|
|
47a29afb4c | ||
|
|
a0f810c018 | ||
|
|
e8db571a2c | ||
|
|
d9cd40c035 | ||
|
|
865e8dc17f | ||
|
|
34a334fa27 | ||
|
|
3f273ca37a | ||
|
|
72ffec45c2 | ||
|
|
2ca0e6924a | ||
|
|
cf2db32721 | ||
|
|
3f0f09b138 | ||
|
|
b8ed53f2ff | ||
|
|
19a0d0af18 | ||
|
|
7308ac09b1 | ||
|
|
1a5f089e57 | ||
|
|
83d74548cd | ||
|
|
fa6d4fd81b | ||
|
|
7cc4abaffa | ||
|
|
02b9413f78 | ||
|
|
1a08258e3d | ||
|
|
a84d6b912b | ||
|
|
448720d8d6 | ||
|
|
1cd56740a6 | ||
|
|
b685a601f7 | ||
|
|
31de17aab8 | ||
|
|
ffef4d897a | ||
|
|
5aea46afd0 | ||
|
|
8fca0fe0a2 | ||
|
|
6d3cde64fe | ||
|
|
3f280ba59a | ||
|
|
83fa5c6c5b | ||
|
|
094c12c6d8 | ||
|
|
c46bf68dd0 | ||
|
|
dc4a64a9c3 | ||
|
|
08fbe730c6 | ||
|
|
1cf353f419 | ||
|
|
c0183ca8b2 | ||
|
|
bf8a478063 | ||
|
|
215bc357ef | ||
|
|
7d6ff7f919 | ||
|
|
1343900aa1 | ||
|
|
d2a5db7caf | ||
|
|
a08ba6f65b | ||
|
|
53c51e1888 | ||
|
|
72e4ccf080 | ||
|
|
a9740ad6f2 | ||
|
|
cb6f294299 | ||
|
|
2c8e2fb2a8 | ||
|
|
b87be6866b | ||
|
|
747dbcf72f | ||
|
|
409e328880 | ||
|
|
c5a614d989 | ||
|
|
0f33b4f094 | ||
|
|
49378b2c80 | ||
|
|
22863f2f87 | ||
|
|
b779376f5b | ||
|
|
8bd8d63546 | ||
|
|
42b51afc5a | ||
|
|
6597a90059 | ||
|
|
c3e3f2629c | ||
|
|
d18f1655be | ||
|
|
90fd2684ba | ||
|
|
fa05a4b310 | ||
|
|
f81c98a87b | ||
|
|
93ebd34f2c | ||
|
|
cb05395280 | ||
|
|
34e91988b1 | ||
|
|
3b36feff52 | ||
|
|
1b5f540a66 | ||
|
|
47266afe85 | ||
|
|
b9714fda45 | ||
|
|
a045f5479f | ||
|
|
5874fe33f6 | ||
|
|
2f9ddeea76 | ||
|
|
4589221661 | ||
|
|
7af5916378 | ||
|
|
1b39fc1718 | ||
|
|
2df0f0e32a | ||
|
|
d7cb967786 | ||
|
|
f7bc2a8f68 | ||
|
|
98ef6944c9 | ||
|
|
2e9bc8624b | ||
|
|
b5caee5b86 | ||
|
|
7990553620 | ||
|
|
6c1ece1b62 | ||
|
|
fde2f9620a | ||
|
|
f0db805d4b | ||
|
|
baefa105a9 | ||
|
|
70897cfd1d | ||
|
|
060801c674 | ||
|
|
c69ea6266f | ||
|
|
829897087a | ||
|
|
a97e8a6183 | ||
|
|
43e19d17f6 | ||
|
|
08c306c3a6 | ||
|
|
175c39a2b0 | ||
|
|
8315c7c3aa | ||
|
|
6a63a5a597 | ||
|
|
3fb541dce7 | ||
|
|
51ef5d8283 | ||
|
|
6c41a341e5 | ||
|
|
a7261b1601 | ||
|
|
4fd8bb79af | ||
|
|
5ac109c381 | ||
|
|
ba33d2ff40 | ||
|
|
f695f398de | ||
|
|
5000fa4147 | ||
|
|
41ac28248a | ||
|
|
9b0fedf602 | ||
|
|
a9d78679ec | ||
|
|
ddcf3b2ad2 | ||
|
|
029c92bccb | ||
|
|
7c439fdf28 | ||
|
|
c68afa6c5d | ||
|
|
328b7ed211 | ||
|
|
3149a6e269 | ||
|
|
ea73145fbc | ||
|
|
69f790b83a | ||
|
|
9dd57cde4e | ||
|
|
d383a26746 | ||
|
|
f10b5fe159 | ||
|
|
7e2228b15e | ||
|
|
a24d27013c | ||
|
|
c5b5930582 | ||
|
|
70a0bd0d38 | ||
|
|
318bc3b84f | ||
|
|
cb182dbce5 | ||
|
|
7844577be8 | ||
|
|
18334b5989 | ||
|
|
13eb65603b | ||
|
|
08037ab3f5 | ||
|
|
41684e2d90 | ||
|
|
c06b3285f2 | ||
|
|
a954fc83c9 | ||
|
|
784a48f218 | ||
|
|
f4dbb1180c | ||
|
|
f1b12a6eb6 | ||
|
|
113216cc0e |
No files matched your search
@@ -6,3 +6,8 @@
|
|||||||
*.json text eol=lf
|
*.json text eol=lf
|
||||||
*.md text eol=lf
|
*.md text eol=lf
|
||||||
*.bat text eol=crlf
|
*.bat text eol=crlf
|
||||||
|
# Test fixtures are byte-exact (hashes, signatures): never convert line endings.
|
||||||
|
tests/fixtures/** -text
|
||||||
|
*.apk binary
|
||||||
|
*.jar binary
|
||||||
|
*.obb binary
|
||||||
@@ -14,7 +14,40 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
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 (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. 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
|
||||||
|
steps:
|
||||||
|
- name: Create the draft release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
# A rerun finds the draft an earlier attempt made.
|
||||||
|
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
|
||||||
|
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:
|
build:
|
||||||
|
needs: draft
|
||||||
|
# Still runs for pull requests and manual runs, where the draft job is skipped.
|
||||||
|
if: ${{ !failure() && !cancelled() }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -40,15 +73,27 @@ jobs:
|
|||||||
run: npm ci && npm run ${{ matrix.script }}
|
run: npm ci && npm run ${{ matrix.script }}
|
||||||
env:
|
env:
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
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 }
|
||||||
- name: Upload to the release
|
- name: Upload to the release
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
tag="${GITHUB_REF_NAME}"
|
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
||||||
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
|
|
||||||
gh release upload "$tag" ${{ matrix.files }} --clobber
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frame-control-${{ matrix.os }}
|
name: frame-control-${{ matrix.os }}
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ Frame Control worker into your headset user account.
|
|||||||
|
|
||||||
For the other features, nothing is installed on the Frame: the app uses what SteamOS
|
For the other features, nothing is installed on the Frame: the app uses what SteamOS
|
||||||
already ships (sideloading a game copies Valve's own devkit scripts to
|
already ships (sideloading a game copies Valve's own devkit scripts to
|
||||||
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
|
`~/devkit-utils`, as Valve's Devkit Client does). The optional
|
||||||
|
[performance HUD](docs/vr-utilities.md) copies our own Python helpers into
|
||||||
|
`~/.local/share/frame-control/vr/`. [How each feature works](docs/frame-control.md).
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -211,9 +213,11 @@ 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||||
| [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 |
|
| [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 |
|
||||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
| [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) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
||||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||||
|
|||||||
@@ -6,11 +6,25 @@ A report: package, version, result (runs | crashes | install_failed |
|
|||||||
instance_failed, from an automated test), rating (works | issues | broken, from
|
instance_failed, from an automated test), rating (works | issues | broken, from
|
||||||
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
|
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
|
||||||
Newest wins, and a person's rating beats an automated result.
|
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):
|
def verdict(reports):
|
||||||
"""(verdict, summary lines) for one package's reports, or None."""
|
"""(verdict, summary lines) for one package's reports, or None."""
|
||||||
|
reports = [r for r in reports or () if about_app(r)]
|
||||||
if not reports:
|
if not reports:
|
||||||
return None
|
return None
|
||||||
rs = sorted(reports, key=lambda r: r.get('date') or '')
|
rs = sorted(reports, key=lambda r: r.get('date') or '')
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// 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_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 : ""}`);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
// 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
|
// 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.
|
// work over the `frame` SSH alias; this file only hosts it.
|
||||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
|
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||||
const { execFile, spawn } = require("child_process");
|
const { execFile, spawn } = require("child_process");
|
||||||
const { promisify } = require("util");
|
const { promisify } = require("util");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -11,6 +11,7 @@ const os = require("os");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||||
const updater = require("./updater");
|
const updater = require("./updater");
|
||||||
|
const { createMetaSignIn } = require("./meta-signin");
|
||||||
|
|
||||||
const run = promisify(execFile);
|
const run = promisify(execFile);
|
||||||
|
|
||||||
@@ -115,10 +116,11 @@ function ping(target) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
|
||||||
// sends nothing from a source checkout.
|
// which sends nothing from a source checkout.
|
||||||
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||||
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
||||||
|
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||||
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
||||||
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||||
python = await findPython(env);
|
python = await findPython(env);
|
||||||
@@ -149,7 +151,7 @@ async function startServer() {
|
|||||||
const target = `http://127.0.0.1:${port}/`;
|
const target = `http://127.0.0.1:${port}/`;
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
||||||
if (await ping(target)) { url = target; return; }
|
if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
}
|
}
|
||||||
if (server === child) server = null;
|
if (server === child) server = null;
|
||||||
@@ -159,51 +161,76 @@ async function startServer() {
|
|||||||
|
|
||||||
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
||||||
// way on Windows); SIGTERM does the same elsewhere.
|
// way on Windows); SIGTERM does the same elsewhere.
|
||||||
|
// Resolves once it has exited (or after 20 s), so a replacement can take the server
|
||||||
|
// lock: server.py allows one per user.
|
||||||
function endServer(child) {
|
function endServer(child) {
|
||||||
|
const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve()
|
||||||
|
: new Promise((resolve) => child.once("exit", resolve));
|
||||||
try { child.stdin.end(); } catch {}
|
try { child.stdin.end(); } catch {}
|
||||||
if (!IS_WIN) child.kill("SIGTERM");
|
if (!IS_WIN) child.kill("SIGTERM");
|
||||||
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
|
// server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill.
|
||||||
|
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref();
|
||||||
|
return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopServer() {
|
function stopServer() {
|
||||||
if (server) endServer(server);
|
if (server) endServer(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorPage(message) {
|
function errorPage(message, title = "Frame Control couldn't start") {
|
||||||
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||||
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
|
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
|
||||||
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
|
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
|
||||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
|
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
|
||||||
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
|
<p>${esc(message)}</p>
|
||||||
|
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
|
||||||
|
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
|
||||||
|
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
|
||||||
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
|
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A server that had been running starts again by itself (something stopped it: a
|
||||||
|
// signal, a crash). One that stops again within a minute shows the error instead,
|
||||||
|
// so a server that can't stay up doesn't restart forever.
|
||||||
|
let serverStarted = 0;
|
||||||
function serverDied(why) {
|
function serverDied(why) {
|
||||||
url = null;
|
url = null;
|
||||||
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
|
if (!win) return;
|
||||||
|
if (Date.now() - serverStarted > 60000) restartServer();
|
||||||
|
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restartServer() {
|
// Restarts that overlap share one: two could each start a server, and the one
|
||||||
const old = server;
|
// that lost the lock would leave the app pointing at nothing.
|
||||||
server = null;
|
let restarting = null;
|
||||||
url = null;
|
function restartServer() {
|
||||||
if (old) endServer(old);
|
if (!restarting) {
|
||||||
await load();
|
restarting = (async () => {
|
||||||
|
const old = server;
|
||||||
|
server = null;
|
||||||
|
url = null;
|
||||||
|
if (old) await endServer(old);
|
||||||
|
if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh
|
||||||
|
await load();
|
||||||
|
})().finally(() => { restarting = null; });
|
||||||
|
}
|
||||||
|
return restarting;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
||||||
const CHROME_CSS = IS_MAC && `
|
const CHROME_CSS = IS_MAC && `
|
||||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
||||||
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
header a, header button, header input, header select, header .chip { -webkit-app-region: no-drag; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Restart Server can start a new load while an older one is still waiting for
|
// Restart Server can start a new load while an older one is still waiting for
|
||||||
// its server; only the newest load may touch the window.
|
// its server; only the newest load may touch the window.
|
||||||
let loadGen = 0;
|
let loadGen = 0;
|
||||||
|
let starting = null; // loads that overlap share one server start
|
||||||
async function load() {
|
async function load() {
|
||||||
const gen = ++loadGen;
|
const gen = ++loadGen;
|
||||||
try {
|
try {
|
||||||
if (!url) await startServer();
|
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
|
||||||
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
||||||
@@ -247,13 +274,66 @@ function fromUi(e) {
|
|||||||
} catch { return false; }
|
} catch { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The error page's Try Again button. The error page is the only data: page the window
|
||||||
|
// shows (`url` can still be set then: the server answered but the page failed to load).
|
||||||
|
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() : "");
|
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||||
|
// 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;
|
||||||
|
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
|
||||||
|
if (img.isEmpty()) throw new Error("not an image");
|
||||||
|
clipboard.writeImage(img);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||||
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
||||||
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||||
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
||||||
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
||||||
|
|
||||||
|
// The page reports the headsets it knows (the server's Devices tab), so the Frame
|
||||||
|
// menu can switch between them. Only plain names and ids go into the menu.
|
||||||
|
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||||
|
let devices = [];
|
||||||
|
ipcMain.on("devices:changed", (e, list) => {
|
||||||
|
if (!fromUi(e) || !Array.isArray(list)) return;
|
||||||
|
const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || ""))
|
||||||
|
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
|
||||||
|
if (JSON.stringify(next) === JSON.stringify(devices)) return;
|
||||||
|
devices = next;
|
||||||
|
buildMenu();
|
||||||
|
});
|
||||||
|
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
|
||||||
|
|
||||||
|
// SSH through the server, so it goes to the headset and address the app is using
|
||||||
|
// (with its own pinned identity), and refuses when there's none.
|
||||||
|
function openSsh() {
|
||||||
|
if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running.");
|
||||||
|
const body = JSON.stringify({ what: "terminal" });
|
||||||
|
const req = http.request(new URL("/api/open", url), {
|
||||||
|
method: "POST", timeout: 15000,
|
||||||
|
headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) },
|
||||||
|
}, (res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (c) => { data += c; });
|
||||||
|
res.on("end", () => {
|
||||||
|
if (res.statusCode === 200) return;
|
||||||
|
let why = `HTTP ${res.statusCode}`;
|
||||||
|
try { why = JSON.parse(data).error || why; } catch {}
|
||||||
|
dialog.showErrorBox("Couldn't open SSH", why);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message));
|
||||||
|
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
|
||||||
|
req.end(body);
|
||||||
|
}
|
||||||
|
function showDevices() {
|
||||||
|
if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
ipcMain.handle("comfort:notify", (e, message) => {
|
ipcMain.handle("comfort:notify", (e, message) => {
|
||||||
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
|
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
|
||||||
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
|
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
|
||||||
@@ -269,6 +349,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
|
// 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),
|
// 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
|
// so they wait here until the page asks for them. The page checks the link with
|
||||||
@@ -425,13 +546,14 @@ async function runInTerminal(argv) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setUpConnection() {
|
// Set Up Connection for the headset in use (or another alias, from the Devices tab).
|
||||||
const alias = `FRAME_ALIAS=${FRAME}`;
|
async function setUpConnection(name = activeAlias()) {
|
||||||
|
if (!ALIAS_RE.test(name)) return;
|
||||||
|
const alias = `FRAME_ALIAS=${name}`;
|
||||||
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
||||||
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
||||||
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
|
// --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
|
||||||
// A new console inherits our environment on Windows; Linux terminals may not.
|
runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
|
||||||
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMenu() {
|
function buildMenu() {
|
||||||
@@ -446,8 +568,15 @@ function buildMenu() {
|
|||||||
{
|
{
|
||||||
label: "Frame",
|
label: "Frame",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: "Set Up Connection…", click: setUpConnection },
|
{ label: "Set Up Connection…", click: () => setUpConnection() },
|
||||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
|
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
|
||||||
|
{ type: "separator" },
|
||||||
|
...(devices.length > 1 ? [{
|
||||||
|
label: "Headset",
|
||||||
|
submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active,
|
||||||
|
click: () => { if (win) win.webContents.send("use-device", d.id); } })),
|
||||||
|
}] : []),
|
||||||
|
{ label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.4.0",
|
"version": "0.4.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.4.0",
|
"version": "0.4.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"productName": "Frame Control",
|
"productName": "Frame Control",
|
||||||
"version": "0.4.0",
|
"version": "0.4.7",
|
||||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"afterPack": "build/check-resources.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist",
|
"output": "dist",
|
||||||
"buildResources": "build"
|
"buildResources": "build"
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"preload.js",
|
"preload.js",
|
||||||
"install-link.js",
|
"install-link.js",
|
||||||
"updater.js",
|
"updater.js",
|
||||||
|
"meta-signin.js",
|
||||||
"package.json",
|
"package.json",
|
||||||
"build/icon.png"
|
"build/icon.png"
|
||||||
],
|
],
|
||||||
@@ -48,9 +50,19 @@
|
|||||||
"filter": [
|
"filter": [
|
||||||
"*.py",
|
"*.py",
|
||||||
"*.html",
|
"*.html",
|
||||||
|
"*.js",
|
||||||
|
"*.css",
|
||||||
"telemetry.json"
|
"telemetry.json"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "../ui/apk_sources",
|
||||||
|
"to": "ui/apk_sources",
|
||||||
|
"filter": [
|
||||||
|
"*.py",
|
||||||
|
"*.json"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "../scripts",
|
"from": "../scripts",
|
||||||
"to": "scripts",
|
"to": "scripts",
|
||||||
@@ -63,7 +75,8 @@
|
|||||||
"to": "frame/android",
|
"to": "frame/android",
|
||||||
"filter": [
|
"filter": [
|
||||||
"*.sh",
|
"*.sh",
|
||||||
"*.py"
|
"*.py",
|
||||||
|
"*.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -71,7 +84,7 @@
|
|||||||
"to": "frame/openxr-compat",
|
"to": "frame/openxr-compat",
|
||||||
"filter": [
|
"filter": [
|
||||||
"XrApiLayer_FRAME_compat.json",
|
"XrApiLayer_FRAME_compat.json",
|
||||||
"prebuilt/**/*.so"
|
"prebuilt/**/*.so.gz"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,16 +2,26 @@
|
|||||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
// to the Frame 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
|
// 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).
|
// as a title without zipping it (the local server reads it from there).
|
||||||
// It can open Set Up Connection when the headset can't be reached.
|
// 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
|
// 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.
|
// 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");
|
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("frameApp", {
|
contextBridge.exposeInMainWorld("frameApp", {
|
||||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||||
|
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||||
|
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||||
|
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
|
||||||
|
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
||||||
|
onUseDevice: (cb) => {
|
||||||
|
ipcRenderer.removeAllListeners("use-device");
|
||||||
|
ipcRenderer.on("use-device", (_e, id) => cb(String(id)));
|
||||||
|
},
|
||||||
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
|
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
|
||||||
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
|
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
|
||||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||||
@@ -30,6 +40,12 @@ contextBridge.exposeInMainWorld("frameApp", {
|
|||||||
ipcRenderer.removeAllListeners("report:open");
|
ipcRenderer.removeAllListeners("report:open");
|
||||||
ipcRenderer.on("report:open", () => cb());
|
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) => {
|
onInstallLink: (cb) => {
|
||||||
ipcRenderer.removeAllListeners("install-link");
|
ipcRenderer.removeAllListeners("install-link");
|
||||||
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
|
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");
|
||||||
|
});
|
||||||
@@ -51,6 +51,27 @@ test("update.json assets always download from this repository's release", () =>
|
|||||||
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
|
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the release page is this repository's tag page, never a draft's untagged-... link", () => {
|
||||||
|
const { fromManifest, fromApi } = require("../updater");
|
||||||
|
const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0";
|
||||||
|
const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page;
|
||||||
|
assert.strictEqual(page(tagPage), tagPage);
|
||||||
|
// 0.4.0's real update.json: the draft's address, a 404 once published.
|
||||||
|
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage);
|
||||||
|
assert.strictEqual(page(undefined), tagPage);
|
||||||
|
assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage);
|
||||||
|
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage);
|
||||||
|
// Paths that normalize to another repository.
|
||||||
|
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"),
|
||||||
|
tagPage);
|
||||||
|
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"),
|
||||||
|
tagPage);
|
||||||
|
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag
|
||||||
|
assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases");
|
||||||
|
assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [],
|
||||||
|
html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage);
|
||||||
|
});
|
||||||
|
|
||||||
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
|
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
|
||||||
const { prepare } = require("../updater");
|
const { prepare } = require("../updater");
|
||||||
const release = { version: "0.3.1", assets: [] };
|
const release = { version: "0.3.1", assets: [] };
|
||||||
|
|||||||
@@ -106,12 +106,24 @@ async function getJson(url, headers) {
|
|||||||
return JSON.parse(body);
|
return JSON.parse(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The release page the banner links to. update.json for 0.4.0 carried the draft's
|
||||||
|
// address (releases/tag/untagged-...), which is dead once the release is published,
|
||||||
|
// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo
|
||||||
|
// normalizes to another repository) before shell.openExternal. So the given page is
|
||||||
|
// ignored: the link is always this repository's tag page, built from a valid version.
|
||||||
|
function releasePage(version) {
|
||||||
|
const v = parseVersion(version);
|
||||||
|
if (!v) return RELEASES;
|
||||||
|
return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
// update.json and the API's release both become { version, notes, page, assets }.
|
// update.json and the API's release both become { version, notes, page, assets }.
|
||||||
function fromManifest(m) {
|
function fromManifest(m) {
|
||||||
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
|
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
|
||||||
const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`;
|
const version = String(m.version).replace(/^v/i, "");
|
||||||
return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
|
const base = `https://github.com/${REPO}/releases/download/v${version}/`;
|
||||||
page: m.page || RELEASES,
|
return { version, notes: String(m.notes || "").slice(0, 4000),
|
||||||
|
page: releasePage(version),
|
||||||
// Assets always come from this repository's release, whatever the manifest says.
|
// Assets always come from this repository's release, whatever the manifest says.
|
||||||
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
|
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
|
||||||
size: a.size, digest: a.digest || null })) };
|
size: a.size, digest: a.digest || null })) };
|
||||||
@@ -119,8 +131,9 @@ function fromManifest(m) {
|
|||||||
|
|
||||||
function fromApi(r) {
|
function fromApi(r) {
|
||||||
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
|
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
|
||||||
return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
|
const version = String(r.tag_name || "").replace(/^v/i, "");
|
||||||
page: r.html_url || RELEASES,
|
return { version, notes: String(r.body || "").slice(0, 4000),
|
||||||
|
page: releasePage(version),
|
||||||
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
|
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
|
||||||
digest: a.digest || null })) };
|
digest: a.digest || null })) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,43 @@ on this branch (run 36421345682).
|
|||||||
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
|
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
|
||||||
browser profile and SSH tunnel and remove the profile and panel log.
|
browser profile and SSH tunnel and remove the profile and panel log.
|
||||||
|
|
||||||
|
**Verified end to end on the same Frame/build (2026-09-29), with mutations:**
|
||||||
|
a stdio MCP client started `ui/frame_mcp.py` in its default mode (private
|
||||||
|
backend, no API key, no prestarted server) and a human approved or rejected
|
||||||
|
each change in the approval page in a real Chrome window:
|
||||||
|
|
||||||
|
| Tool | Result on the Frame |
|
||||||
|
|---|---|
|
||||||
|
| `send_file` | Approved; the file arrived in `~/Downloads` with identical contents |
|
||||||
|
| `install` | Approved; `io.github.fizzyizzy05.binary` job finished in about 35 s |
|
||||||
|
| `panel` | Approved; gamescope listed a new panel window, and `computer_state` reported the same window ID and PID. The app rendered in that window (below) |
|
||||||
|
| `launch` | Approved; Keep Talking and Nobody Explodes (341800) started under Proton and `computer_state` reported it as the focused app |
|
||||||
|
| `uninstall` | Approved; app and locale removed |
|
||||||
|
| `power` | Rejected in the page. Unapproved retries, the same token used for `uninstall`, and a retry after rejection were all refused. Nothing was powered off |
|
||||||
|
| `send_text` | Approved, then refused because the Plasma desktop was not open (documented requirement) |
|
||||||
|
| `keep_awake` | `status` reports the script unavailable until PR #16 lands |
|
||||||
|
|
||||||
|
A separate Claude Code CLI session, with only this server configured, read
|
||||||
|
status, `computer_state` and a headset capture, and requested an install. It
|
||||||
|
received an approval URL and did not execute anything.
|
||||||
|
|
||||||
|
The assistant opened as a Frame panel through `scripts/assistant-on-frame.py`.
|
||||||
|
Against a loopback stub model, a send without consent made zero requests. With
|
||||||
|
consent it made exactly one, carrying the text and a fresh Frame screenshot.
|
||||||
|
Consent unticked itself after sending. SIGTERM removed the panel, profile, log
|
||||||
|
and tunnel.
|
||||||
|
|
||||||
|
**Not verified while unworn:** every headset capture was a uniform dark frame,
|
||||||
|
so SteamVR's rendered view of panels and the game could not be checked; window
|
||||||
|
captures (`xwd`) were used instead. MCP can launch a game or panel but has no
|
||||||
|
tool to stop one: the tester stopped them over SSH. Removing an app leaves any
|
||||||
|
runtime it pulled in; Flatpak may also remove related extensions when that
|
||||||
|
runtime is removed by hand.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Computer-use coverage
|
## Computer-use coverage
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# APK repositories
|
||||||
|
|
||||||
|
Frame Control supports **F-Droid-format repositories**, including F-Droid,
|
||||||
|
F-Droid archive, IzzyOnDroid and user-provided HTTPS repositories. Repository
|
||||||
|
indexes are authenticated before their apps appear. Search lists builds with
|
||||||
|
Android API ≤30 and arm64-v8a or no native libraries, using the same streaming
|
||||||
|
reducer as the existing catalogue. This does not guarantee an app works in Lepton.
|
||||||
|
|
||||||
|
## Formats considered
|
||||||
|
|
||||||
|
| Format | Users and purpose | Support in this source |
|
||||||
|
|---|---|---|
|
||||||
|
| F-Droid v2 | F-Droid, IzzyOnDroid, self-hosted fdroidserver repositories; consumed by F-Droid clients including Droid-ify and Neo Store | Preferred: signed `entry.jar` authenticates `entry.json`; its SHA-256 authenticates `index-v2.json`, which supplies APK SHA-256 hashes |
|
||||||
|
| F-Droid v1 | Older F-Droid servers and clients | Fallback: verify `index-v1.jar`, then read its signed `index-v1.json` |
|
||||||
|
| Obtainium configurations / exports | Obtainium users share app URLs plus source-specific filters and update settings; exports can contain a list of app configuration objects | Not imported here: configurations describe how to find releases, not one signed repository index |
|
||||||
|
| SideQuest listings / custom feeds | SideQuest's own app discovery and installation service | No interoperable signed custom-repository specification was established from the public project documentation examined; SideQuest needs its own adapter |
|
||||||
|
| GitHub release lists | Developers publish APK assets on release pages; community lists link to projects | Not a repository standard: asset naming, build selection and publisher verification vary; handled separately from this F-Droid source |
|
||||||
|
| Minimal JSON list | A private list could contain package, title, APK URL and SHA-256 | Deliberately not introduced: unsigned hashes downloaded alongside files do not authenticate their publisher; another bespoke signing/update protocol would duplicate F-Droid |
|
||||||
|
|
||||||
|
Research references (checked 2026-09-28):
|
||||||
|
|
||||||
|
- [F-Droid APIs](https://f-droid.org/docs/All_our_APIs/) and
|
||||||
|
[repository setup](https://f-droid.org/docs/Setup_an_F-Droid_App_Repo/).
|
||||||
|
- [F-Droid signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/)
|
||||||
|
and [IzzyOnDroid's repository page and fingerprint](https://apt.izzysoft.de/fdroid/).
|
||||||
|
- [Droid-ify](https://github.com/Droid-ify/client) and
|
||||||
|
[Neo Store](https://github.com/NeoApplications/Neo-Store).
|
||||||
|
- [Obtainium](https://github.com/ImranR98/Obtainium), its
|
||||||
|
[configuration/deep-link format](https://wiki.obtainium.imranr.dev/deep_links/),
|
||||||
|
and [community app configurations](https://apps.obtainium.imranr.dev/).
|
||||||
|
- [SideQuest's public client](https://github.com/SideQuestVR/SideQuest).
|
||||||
|
The absence of a specification in these materials is not proof that no
|
||||||
|
historical or private custom-feed format exists.
|
||||||
|
|
||||||
|
## Add a repository in Frame Control
|
||||||
|
|
||||||
|
From the Frame Control checkout, use its source-management CLI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo?fingerprint=YOUR_64_HEX_CERTIFICATE_FINGERPRINT' --name 'My apps'
|
||||||
|
python3 ui/apk_sources/fdroid.py list
|
||||||
|
python3 ui/apk_sources/fdroid.py search SOURCE_ID 'music'
|
||||||
|
python3 ui/apk_sources/fdroid.py download SOURCE_ID org.example.app
|
||||||
|
python3 ui/apk_sources/fdroid.py remove SOURCE_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `SOURCE_ID` with the `id` printed by `add` or `list`. `--fingerprint`
|
||||||
|
can also supply the pin. `fdroidrepos://example.org/fdroid/repo?fingerprint=…`
|
||||||
|
links are accepted and converted to HTTPS. Conflicting fingerprints are refused.
|
||||||
|
A URL must identify the repository directory, not its website or an index file.
|
||||||
|
|
||||||
|
Adding fetches and validates the complete index **before saving** the source.
|
||||||
|
Without a fingerprint, Frame Control verifies the JAR signature and remembers
|
||||||
|
its signer: trust on first use (TOFU). This establishes continuity with the
|
||||||
|
first server response, not independent publisher identity. Obtain the published
|
||||||
|
fingerprint through a trusted channel when possible; the store's Add a source
|
||||||
|
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.
|
||||||
|
|
||||||
|
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)`,
|
||||||
|
`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
|
||||||
|
separate work. Built-in sources can be disabled but cannot be removed.
|
||||||
|
|
||||||
|
Settings and pins live in `frame_host.data_dir('apk-repos.json')`
|
||||||
|
(`~/Library/Application Support/Frame Control/apk-repos.json` on macOS).
|
||||||
|
Authenticated reduced indexes and APKs live under
|
||||||
|
`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours. An
|
||||||
|
expired index is still served (marked stale in the store) while it refreshes in
|
||||||
|
the background; a failed refresh is retried after 10 minutes.
|
||||||
|
Only the running Frame Control app prunes cached APKs (at start and after store
|
||||||
|
downloads); the command-line tools never do.
|
||||||
|
The existing catalogue's unverified index cache is never treated as authenticated.
|
||||||
|
|
||||||
|
Rollback protection: each repository's newest accepted signed index timestamp
|
||||||
|
is kept in `apk-repo-state.json` next to the settings, and an older index is
|
||||||
|
refused. Once a repository has served a v2 `entry.jar`, a missing `entry.jar`
|
||||||
|
is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
|
||||||
|
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
|
||||||
|
Removing a repository clears its state.
|
||||||
|
|
||||||
|
## Publish your own repository
|
||||||
|
|
||||||
|
Only publish free APKs you own or have the developer's permission to distribute.
|
||||||
|
Do not publish paid app mirrors or bypass store licences. Check distribution
|
||||||
|
terms before adding someone else's repository; this module does not infer legal
|
||||||
|
permission from a signature or automatically audit a repository's terms.
|
||||||
|
|
||||||
|
Install a current [fdroidserver](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/)
|
||||||
|
and its documented Android/Java dependencies on the publishing machine, then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir my-fdroid
|
||||||
|
cd my-fdroid
|
||||||
|
fdroid init
|
||||||
|
# Set repo_url in config.yml to https://example.org/fdroid/repo
|
||||||
|
# Also set repo_name and repo_description; keep the generated signing key safe.
|
||||||
|
cp /path/to/your-free-app.apk repo/
|
||||||
|
fdroid update --create-metadata
|
||||||
|
# Review the generated metadata (name, summary, licence, source and website).
|
||||||
|
fdroid update
|
||||||
|
```
|
||||||
|
|
||||||
|
Serve the generated **repo directory** at that HTTPS URL, including APKs,
|
||||||
|
icons, `entry.jar`, `index-v2.json` and `index-v1.jar`. Do not publish the
|
||||||
|
private signing keystore or configuration passwords. Configure fdroidserver's
|
||||||
|
`serverwebroot` and run `fdroid deploy` for managed publication, or copy the
|
||||||
|
public directory with your existing deployment tool. Publish the SHA-256
|
||||||
|
repository certificate fingerprint displayed by fdroidserver in a link such as
|
||||||
|
`https://example.org/fdroid/repo?fingerprint=…`.
|
||||||
|
|
||||||
|
Keep the repository signing key backed up: changing it breaks existing pins.
|
||||||
|
For updates, add the new APK, edit metadata as needed, run `fdroid update` and
|
||||||
|
publish again. Test the published URL with Frame Control's `add`, `search` and
|
||||||
|
`download` commands. The above publisher setup is documented from fdroidserver;
|
||||||
|
it was not executed as part of this implementation.
|
||||||
|
|
||||||
|
## Verification and limits
|
||||||
|
|
||||||
|
The stdlib verifier supports one RSA PKCS#1 v1.5 JAR/CMS signer with a key of
|
||||||
|
2048–8192 bits; SHA-256/384/512 and legacy SHA-1 digest encodings are
|
||||||
|
recognized. It checks the signer certificate pin, the signature over `.SF`,
|
||||||
|
the whole-manifest digest, and the manifest's digest of the JSON member.
|
||||||
|
ECDSA, DSA, RSA-PSS, multiple signers and section-only `.SF` manifests are
|
||||||
|
rejected. Certificates are pinned identities, not validated as Web PKI chains.
|
||||||
|
HTTPS certificates are separately checked by Python's normal TLS validation.
|
||||||
|
|
||||||
|
v1 fallback occurs only when `entry.jar` returns HTTP 404 or 410. Signature,
|
||||||
|
fingerprint, index hash, TLS and server errors never trigger an unsigned
|
||||||
|
fallback. APKs are cached by SHA-256 and checked again before reuse. Here,
|
||||||
|
`verified: true` means the bytes match the signed repository's APK hash; it
|
||||||
|
is not an independent APK publisher-signature or runtime compatibility verdict.
|
||||||
|
There is no repository timestamp rollback/expiry policy or automated signing-key
|
||||||
|
rotation yet. An old correctly signed index can still validate.
|
||||||
|
|
||||||
|
Offline fixtures exercise v2, v1, TOFU, pin changes, disabled sources, cache
|
||||||
|
reuse, URL rejection and corruption of every signature/hash layer. On the Mac,
|
||||||
|
the real IzzyOnDroid repository was added with its published pin, searched for
|
||||||
|
Tiny Music Player, and its 16,520-byte APK downloaded with SHA-256
|
||||||
|
`d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a`.
|
||||||
|
No headset connection or installation was performed.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Developer-consented APK sources
|
||||||
|
|
||||||
|
Surveyed 2026-09-28. Free access is not proof of redistribution permission or
|
||||||
|
Frame compatibility. These adapters fetch only public publisher releases or
|
||||||
|
link to publisher pages. They do not acquire store entitlements, defeat access
|
||||||
|
checks, install anything, or rehost APKs. See [VR compatibility](vr-apks.md).
|
||||||
|
|
||||||
|
| Source | Developer consent and automated-access position | API/feed; VR coverage | Decision |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [itch.io](https://itch.io/docs/legal/terms) | Publishers warrant distribution rights (§4). Users may access content through the service; this is not blanket scraping permission. Main robots excludes `/game/download/`; author subdomains exclude `/*/download/`. No challenge bypass. | Public free Android RSS for `openxr` and `oculus-quest`; substantial indie VR. Server API is mostly authenticated publisher/account functionality, not a general anonymous store-download API. | Implement RSS search, artwork and page links; `downloadable: False`. The supplied free-download script follows keyed download pages excluded by robots, so it is not shipped. |
|
||||||
|
| [GitHub releases](https://docs.github.com/en/rest/releases/releases) | Maintainers publish assets; curated repositories below establish provenance. Public hosting or an open-source topic alone does not establish rights to every uploaded binary. Use supported REST API under [API terms](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service#h-api-terms), not HTML crawling. | Releases API includes APK assets and sometimes SHA-256. Topic search finds OpenXR/Quest projects. 60 unauthenticated requests/hour; authenticated user limits are generally 5,000/hour, with separate search/secondary limits. | Implement curated downloads and explicit topic discovery. Unreviewed topic results are page-only. |
|
||||||
|
| [Uptodown](https://www.uptodown.com/aboutus) | Developer distribution program exists, but that does not prove publisher authorization for every catalog item. [Privacy policy](https://www.uptodown.com/aboutus/privacy) explicitly describes protection against automated access. General automation permission was not established. | Broad Android catalog, limited VR focus; no supported public consumer-download API established in this survey. | Page links only; no downloader. Do not infer consent from an unchanged APK signature. |
|
||||||
|
| [APKPure](https://apkpure.com/terms) | Third-party APK catalog; individual publisher consent and automation rights were not established. Terms request returned HTTP 403; no bypass attempted. | Broad Android coverage, incidental VR; internal endpoints are not permission to automate. | Exclude automatic indexing/downloading; user may open site. |
|
||||||
|
| [APKMirror](https://www.apkmirror.com/faq/) | Publisher-signed files and a free-app policy are not a blanket developer-consent or automation grant. FAQ request returned HTTP 403, so current terms could not be confirmed. | General Android/version archive; APK bundles often need another installer; little VR focus. No supported consumer-download API established. | Page links only, no scraping or bundle conversion. |
|
||||||
|
| [Aptoide](https://en.aptoide.com/company/legal) | Terms define an app supplier as developer, owner or authorized distributor; user stores still require per-item provenance. API availability alone does not settle third-party access rights. | API ecosystem and general Android catalog; weak VR focus. | Defer until a publisher-owned store and its API terms can be approved. No blanket community-store downloader. |
|
||||||
|
| [Amazon Appstore](https://developer.amazon.com/docs/app-submission/understanding-submission.html) | Official developer submissions; store account, device and license rules apply. Publisher submission APIs do not authorize public binary extraction. | Fire-device distribution; Android-device Appstore support ended in 2025; little Quest relevance. | Official product links only; no account or entitlement extraction. |
|
||||||
|
| [PICO / ByteDance store](https://developer.picoxr.com/document/distribute) | Official publisher channel with store/device entitlements. No public unauthenticated binary-download grant established; documentation request encountered a redirect error. | Strong standalone VR; PICO builds may depend on PICO services/extensions. | Store links only. A developer's independently published GitHub/itch build can qualify separately. |
|
||||||
|
| [Meta Horizon Store / former App Lab](https://www.meta.com/experiences/) | Official developer submissions. A free store entitlement is still an entitlement; no license bypass or authenticated store extraction. App Lab was folded into the main store in 2024. | Strongest Quest coverage; no supported anonymous APK-download API established. | Store links only; independently distributed free builds use their publisher source. |
|
||||||
|
| [Khronos samples](https://github.com/KhronosGroup/OpenXR-SDK-Source) | Official upstream, Apache-2.0 sample; developer-published release APKs. GitHub API terms apply. | `hello_xr` Vulkan/OpenGL ES APKs; excellent OpenXR diagnostics. | Included in GitHub curated list, Vulkan variant selected. |
|
||||||
|
| [Meta OpenXR samples](https://github.com/meta-quest/Meta-OpenXR-SDK) | Official upstream; check each sample's license. Source availability does not imply a published APK, and some samples require Meta extensions/services. | Source/build examples, inconsistent ready-made APK releases. | Link to upstream; add specific free APKs only after release/provenance review. |
|
||||||
|
| [Godot XR demos](https://github.com/GodotVR/godot-xr-tools) | Official project source and publisher demo pages; licenses and dependencies vary by demo. | OpenXR examples on GitHub/itch. Older Godot builds can fail on Lepton's missing clipboard service. | Covered by source discovery; no compatibility promise from an OpenXR tag. |
|
||||||
|
|
||||||
|
The table distinguishes observed restrictions from unknown permission. An
|
||||||
|
unverified policy is a reason to defer automation, not a claim that a site is
|
||||||
|
unlawful. Only the two implemented source kinds are registered by their own
|
||||||
|
`sources()` functions; the other rows are recommendations, not new UI entries.
|
||||||
|
|
||||||
|
## Adapters
|
||||||
|
|
||||||
|
`ui/apk_sources/github.py` uses `github_curated.json`: Khronos `hello_xr`,
|
||||||
|
[Open Brush](https://github.com/icosa-foundation/open-brush), and
|
||||||
|
[SuperTux 3D](https://github.com/SgtBilko76/SuperTux-3D). These have official
|
||||||
|
OpenXR project/release evidence, not a blanket claim of headset compatibility.
|
||||||
|
Open Brush's compatibility evidence is recorded in [vr-apks.md](vr-apks.md).
|
||||||
|
Open Brush and SuperTux publish the selected builds as prereleases; curated
|
||||||
|
opt-ins preserve that label in version records. Exact APK filename patterns
|
||||||
|
avoid downloading desktop archives or alternate non-Quest builds.
|
||||||
|
[OpenSaberPlus](https://github.com/arpruss/OpenSaberPlus) was examined but not
|
||||||
|
curated: GitHub reports its license as `NOASSERTION`, and current OpenXR APK
|
||||||
|
provenance was not established in this pass.
|
||||||
|
|
||||||
|
Default GitHub search is offline against this small list. Queries
|
||||||
|
`topic:openxr`, `topic:oculus-quest`, and `topic:quest` explicitly call repository
|
||||||
|
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
|
||||||
|
`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
|
||||||
|
leaves the latter unknown and rejects a requested `version_code` rather than
|
||||||
|
silently fetching a different build.
|
||||||
|
|
||||||
|
`itch.py` exposes separate OpenXR and Quest feed sources, so one feed's failure
|
||||||
|
does not suppress the other at the aggregator level. Queries filter the current
|
||||||
|
feed window locally: this is not an exhaustive historical itch search. Only
|
||||||
|
explicit zero-price Android entries are returned. Covers are exposed in
|
||||||
|
`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown.
|
||||||
|
Curated GitHub entries include publisher artwork and plain-language summaries.
|
||||||
|
Repository image URLs are pinned to inspected commits. Open Brush screenshots
|
||||||
|
come from its README-linked Steam listing; SuperTux uses the upstream gameplay
|
||||||
|
preview embedded in the port's README (not a headset capture). The hello_xr
|
||||||
|
sample has a launcher icon and GitHub social banner; no published screenshot
|
||||||
|
was found in the inspected repository/README, so its screenshot list is empty.
|
||||||
|
Uncurated topic results use the owner's avatar and GitHub's repository social
|
||||||
|
preview. These are repository placeholders, not app screenshots. Itch's recorded
|
||||||
|
RSS includes only covers, so screenshot lists remain empty without page scraping. VR is
|
||||||
|
based on curated evidence or a VR-specific feed/topic, not a compatibility claim.
|
||||||
|
|
||||||
|
Downloads stream to unique temporary files, require an APK manifest entry,
|
||||||
|
restrict HTTPS origins and redirects, and enforce a 2 GiB ceiling. `verified`
|
||||||
|
means the downloaded SHA-256 matches GitHub's published digest. Without such a
|
||||||
|
digest, the computed SHA-256 is returned with `verified: False`; neither value
|
||||||
|
claims publisher-signature validation. Installation must inspect the APK as
|
||||||
|
usual. OBBs, split APKs, paid assets and external release-body download links
|
||||||
|
are unsupported.
|
||||||
|
|
||||||
|
## Evidence and limits
|
||||||
|
|
||||||
|
On this Mac, Python 3.9 downloaded the real Khronos Vulkan 1.1.63 APK through
|
||||||
|
the GitHub adapter, matched its published SHA-256
|
||||||
|
`f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34`, and
|
||||||
|
`python3 ui/frame_android.py info <apk>` exited 0: package
|
||||||
|
`org.khronos.openxr.hello_xr.vulkan`, version code 1063, minimum API 24,
|
||||||
|
arm64-v8a present, OpenXR detected. No Frame connection or installation occurred.
|
||||||
|
|
||||||
|
The itch OpenXR RSS was fetched successfully and recorded as a fixture.
|
||||||
|
Subsequent live adapter search encountered HTTP 429; it is not claimed as a
|
||||||
|
successful live end-to-end search. Fixture search finds Off Nominal and parses
|
||||||
|
nine Android entries from the ten-item feed (one has only an HTML platform).
|
||||||
|
A real itch download and APK inspection were deliberately not performed:
|
||||||
|
robots restrictions take precedence over that requested proof. No current
|
||||||
|
policy text is claimed verified where the table records failed access.
|
||||||
|
|
||||||
|
Tests use recorded, reduced API/RSS fixtures with network access blocked in
|
||||||
|
the new test class. They cover selection, prereleases, unknown topic results,
|
||||||
|
paid/non-Android exclusion, URL restrictions, redirect credential removal,
|
||||||
|
caching, rate limits, checksum mismatch, non-APK rejection and partial-file
|
||||||
|
cleanup. See `.claude/NOTES-more-sources.md` for commands and local evidence.
|
||||||
@@ -39,6 +39,12 @@ section below), instead of going into Lepton Development:
|
|||||||
run at once alongside Lepton Development, each in its own container
|
run at once alongside Lepton Development, each in its own container
|
||||||
(`lepton-steamlaunch-<id>`, ADB on 5556, 5557, …).
|
(`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
|
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
|
||||||
from the shortcut, stopped, and relaunched with their data intact. ADB and
|
from the shortcut, stopped, and relaunched with their data intact. ADB and
|
||||||
Lepton Development aren't involved.
|
Lepton Development aren't involved.
|
||||||
@@ -331,3 +337,11 @@ because gamescope scales Lepton's surface to fit the same panel. Also unverified
|
|||||||
whether the settings survive the app or its Lepton instance relaunching.
|
whether the settings survive the app or its Lepton instance relaunching.
|
||||||
Lepton Development rebuilds its Android data on exit, so there they probably
|
Lepton Development rebuilds its Android data on exit, so there they probably
|
||||||
don't.
|
don't.
|
||||||
|
|
||||||
|
## Expansion files and save backups
|
||||||
|
|
||||||
|
SideQuest-inspired CLI helpers install local OBB files into an already-running
|
||||||
|
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.
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# Headsets, addresses and the connection
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The code is in three modules, all stdlib-only Python on your computer:
|
||||||
|
|
||||||
|
| Module | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `ui/frame_devices.py` | The registry: headsets, their addresses, networks; importing and updating `~/.ssh/config`; pinned host keys |
|
||||||
|
| `ui/frame_network.py` | Which network this computer is on, and Tailscale's state |
|
||||||
|
| `ui/frame_link.py` | The connector: finds the headset, keeps the SSH connection, publishes each stage; the Devices API |
|
||||||
|
|
||||||
|
## Headsets
|
||||||
|
|
||||||
|
Each headset keeps its own SSH alias, as Set Up Connection has always written
|
||||||
|
it: the first is `frame`, the next `frame-2`, and so on. Terminal's
|
||||||
|
`ssh frame-2` and the helper scripts (`FRAME_ALIAS=frame-2 scripts/push.sh …`)
|
||||||
|
work for each one.
|
||||||
|
|
||||||
|
- **Nothing to migrate by hand.** On first start, the app imports every
|
||||||
|
`# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with
|
||||||
|
the block's HostName as its first address. It also copies the host key your
|
||||||
|
`known_hosts` already trusts for that address into the headset's own
|
||||||
|
known_hosts file, `~/.ssh/frame-control-hosts/<id>`, so nobody is asked to trust it again.
|
||||||
|
- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS,
|
||||||
|
`ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias.
|
||||||
|
When it writes its block, the app picks the headset up by itself. If Set Up
|
||||||
|
Connection runs again and finds a headset somewhere new, that address is added
|
||||||
|
at the top of its list.
|
||||||
|
- **Use this headset** (or the switcher in the header, or the app's
|
||||||
|
**Frame → Headset** menu) moves the whole app to another headset; every panel
|
||||||
|
reloads from it. From that moment no command goes to the previous headset, even
|
||||||
|
if the new one never answers. It waits while an install is running, since an
|
||||||
|
install reads the SSH settings step by step.
|
||||||
|
- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick
|
||||||
|
the box; either way it isn't imported again unless Set Up Connection changes it.
|
||||||
|
- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the
|
||||||
|
app shows it as not set up and lets ssh's own config decide where it goes.
|
||||||
|
|
||||||
|
## Addresses
|
||||||
|
|
||||||
|
Each address has a kind (LAN, mDNS, Tailscale or Other, guessed from the address
|
||||||
|
and changeable), an optional label, the networks it has worked on, and when it
|
||||||
|
last worked with its round-trip time.
|
||||||
|
|
||||||
|
When connecting, the app **tries all addresses at once** (TCP to the SSH port)
|
||||||
|
and ranks them:
|
||||||
|
|
||||||
|
1. addresses that worked on the network this computer is on now;
|
||||||
|
2. mDNS names;
|
||||||
|
3. Tailscale addresses, if Tailscale is running here;
|
||||||
|
4. addresses not tried on this network yet;
|
||||||
|
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
|
||||||
|
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
|
||||||
|
answered is tried. Every success records the network on that address, so next
|
||||||
|
time on that network it's tried first.
|
||||||
|
|
||||||
|
**Test now** probes every address and tries SSH on each one that answers, without
|
||||||
|
disturbing the connection in use: "SSH works", "answered as a different
|
||||||
|
headset", "refused this computer's key", or why it didn't answer. **Find on
|
||||||
|
Tailscale** lists your tailnet's devices (likely headsets first, from `tailscale
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## Networks
|
||||||
|
|
||||||
|
A network is told apart by its default gateway: the router's IP address plus its
|
||||||
|
hardware (MAC) address, read with `route`/`arp` (macOS), `ip route`/`ip neigh`
|
||||||
|
(Linux) or `route print`/`arp -a` (Windows). That works on wired networks, and
|
||||||
|
on macOS 14 and later, which hides the Wi-Fi name from apps without Location
|
||||||
|
permission. Where the system does share the Wi-Fi name, it's shown, and you can
|
||||||
|
name any network yourself ("Home Wi-Fi") on the Devices tab.
|
||||||
|
|
||||||
|
The app rereads the gateway every 5 seconds and Tailscale's state every
|
||||||
|
30 seconds. Changing networks reconnects.
|
||||||
|
|
||||||
|
## The connection, stage by stage
|
||||||
|
|
||||||
|
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.
|
||||||
|
2. **Finding the headset**: each address resolving, trying, answered in N ms,
|
||||||
|
no answer, refused, or can't be found.
|
||||||
|
3. **Opening SSH** to the address that answered.
|
||||||
|
4. **Checking the headset's identity**: the host key must match the one pinned
|
||||||
|
for this headset.
|
||||||
|
5. **Logging in** as the headset's user.
|
||||||
|
6. **Connected** via network N, address A, round trip T; or **failed** at a stage
|
||||||
|
with the reason in plain words and a countdown to the next try (5, 10, 20,
|
||||||
|
then every 30 seconds). Retry now skips the wait.
|
||||||
|
|
||||||
|
Stages 3 to 5 come from following `ssh -v` as it runs. On macOS and Linux the
|
||||||
|
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. 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>
|
||||||
|
-o UserKnownHostsFile=~/.ssh/frame-control-hosts/<id> -o HashKnownHosts=no -o User=… -o Port=…`. The
|
||||||
|
alias's block in `~/.ssh/config` is also updated to the last address that
|
||||||
|
worked (and to the user and port you set), so Terminal's `ssh frame` and the
|
||||||
|
scripts follow. Edits to `~/.ssh/config` take a lock file
|
||||||
|
(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never
|
||||||
|
write over a change someone else made since the app last read the file.
|
||||||
|
|
||||||
|
**Host keys are pinned per headset, not per address.** Your own `known_hosts`
|
||||||
|
is keyed by address, so a different device answering at a remembered IP (a DHCP
|
||||||
|
lease that moved) would look like a new host there. The app keeps one known_hosts
|
||||||
|
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: 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
|
||||||
|
|
||||||
|
Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's
|
||||||
|
data folder). Two would each connect, reconnect and edit the headsets on their own, and
|
||||||
|
one could move the other's install to a different headset. A second one, say
|
||||||
|
`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already
|
||||||
|
running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
|
||||||
|
|
||||||
|
| Request | Returns |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/connection` | The connection state: `phase` (connecting, connected, failed), `device`, `network`, `stages`, `probes`, `via`, `error`, `retry_at`, `tests`, `version` |
|
||||||
|
| `GET /api/connection/events` | The same as server-sent events, one each time it changes (the page reads it with `fetch`, since `EventSource` can't send the 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`, `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
|
||||||
|
through a shell.
|
||||||
|
|
||||||
|
## The registry file
|
||||||
|
|
||||||
|
`devices.json` in the app's data folder (`~/Library/Application Support/Frame
|
||||||
|
Control` on macOS, `%APPDATA%\Frame Control` on Windows,
|
||||||
|
`~/.local/share/frame-control` on Linux). It's plain JSON so the iPhone app can
|
||||||
|
share the format later (it still connects to one host; see
|
||||||
|
[iphone.md](iphone.md)):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"version": 1, "active": "f67f8b7e",
|
||||||
|
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
|
||||||
|
"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",
|
||||||
|
"gateway_mac": "b4:fb:e4:b5:67:55", "wifi": true, "last_seen": 1790593550.0}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
A network id is `n-` and the first 10 hex digits of SHA-1 of `gateway|mac`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`tests/test_devices.py`, `tests/test_network.py` and `tests/test_link.py` run
|
||||||
|
with the other unit tests. They use a stand-in `ssh` (`tests/fakessh/ssh`) that
|
||||||
|
prints what `ssh -v` prints and plays a ControlMaster, real sockets on this
|
||||||
|
computer for the addresses, and temporary folders for `~/.ssh`
|
||||||
|
(`FRAME_CONTROL_SSH_DIR`) and the app data (`FRAME_CONTROL_DATA_DIR`), so they
|
||||||
|
never touch yours.
|
||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
Locked real-Frame repeat, 2026-09-29
|
||||||
|
SteamOS 0.4.1, BUILD_ID 20260925.6191901; aarch64.
|
||||||
|
mkdir /tmp/frame-test.lock succeeded before installs/launches; rmdir issued after cleanup.
|
||||||
|
Preflight battery 44%, charging; before WiVRn 46%, before ALVR 47%, cleanup 47%.
|
||||||
|
Original Steam PID 49823 and vrserver PID 49571 present after cleanup.
|
||||||
|
Same unmodified upstream release APKs and SHA-256s as 2026-09-28.txt.
|
||||||
|
Installer functions loaded from a613735 before checkout was fast-forwarded to current main.
|
||||||
|
No compatibility layer injected. Per-app immersive Lepton instances, Steam shortcut launches.
|
||||||
|
Headset unworn. No Linux gaming host. No pairing or streaming session reached.
|
||||||
|
|
||||||
|
WIVRN: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.202 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.210 1153 1181 I WiVRn : [2026-09-29 01:03:15.210] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.248 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.256 1153 1181 I WiVRn : [2026-09-29 01:03:15.256] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.257 1153 1181 E WiVRn : [2026-09-29 01:03:15.257] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||||
|
|
||||||
|
ALVR: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1167 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1165 I RustStdoutStderr: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: panicked at alvr/client_openxr/src/lib.rs:220:10:
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||||
|
|
||||||
|
Cleanup: both test app directories, compatdata, shadercache, containers and shortcuts absent.
|
||||||
|
Capture output directory removed. No global settings changed; Steam/SteamVR not stopped.
|
||||||
|
The shared lock was subsequently acquired by another thread (new directory timestamp 11:03:49 +1000).
|
||||||
|
Native clients and Valve host streaming not exercised: no native build or Linux gaming host available.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-09-28",
|
||||||
|
"os": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"build": "20260925.6191901",
|
||||||
|
"variant": "vr"
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"compositorFps": 72.0,
|
||||||
|
"frameMs": 13.89,
|
||||||
|
"appFps": null,
|
||||||
|
"gpuMs": 3.07,
|
||||||
|
"compositorCpuMs": 0.61,
|
||||||
|
"cpuPercent": 40.6,
|
||||||
|
"gpuMHz": 903.0
|
||||||
|
},
|
||||||
|
"batteryPercent": 16,
|
||||||
|
"maxTempC": 73.5,
|
||||||
|
"ownership": [
|
||||||
|
{
|
||||||
|
"id": 1009850,
|
||||||
|
"owned": false,
|
||||||
|
"installed": false,
|
||||||
|
"frame": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1173510,
|
||||||
|
"owned": false,
|
||||||
|
"installed": false,
|
||||||
|
"frame": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1068820,
|
||||||
|
"owned": false,
|
||||||
|
"installed": false,
|
||||||
|
"frame": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 908520,
|
||||||
|
"owned": false,
|
||||||
|
"installed": false,
|
||||||
|
"frame": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1494460,
|
||||||
|
"owned": false,
|
||||||
|
"installed": false,
|
||||||
|
"frame": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hudLifecycle": "open, duplicate-open, close passed before control-test pause; overlay probe removal verified",
|
||||||
|
"comfort": "Initial 1cm seated probe restored exactly. Later commits/readback disagreed while Frame in use; Alex paused control tests. No controls shipped."
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 269 KiB |
@@ -0,0 +1,58 @@
|
|||||||
|
# Independent review attempts — 2026-09-28
|
||||||
|
|
||||||
|
Target: the implementation and evidence in
|
||||||
|
[3fb541d](https://github.com/saphid/frame-control/commit/3fb541d) and
|
||||||
|
[6a63a5a](https://github.com/saphid/frame-control/commit/6a63a5a), supplied as a
|
||||||
|
frozen diff before those commits were made. No executable code changed after
|
||||||
|
the final review snapshot.
|
||||||
|
|
||||||
|
Requested model: **SWE-2 Max**, explicitly selected with `--model swe-2-max`.
|
||||||
|
No completed verdict or model self-identification was returned. This is not a
|
||||||
|
passed review, and there is no "no actionable findings" claim.
|
||||||
|
|
||||||
|
## First attempt
|
||||||
|
|
||||||
|
Launcher:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-prompt.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The prompt required read-only review, no delegation, no edits and no Frame
|
||||||
|
access. The reviewer inspected surrounding code, then stopped while checking
|
||||||
|
the public OpenVR header. The tool runner reported:
|
||||||
|
|
||||||
|
> warning: rejected a tool call that requires confirmation. Running in non-interactive mode.
|
||||||
|
|
||||||
|
The real launcher exit status was **0**, but no verdict was returned. A zero
|
||||||
|
process status here is not evidence that the review completed.
|
||||||
|
|
||||||
|
## Tool-free retry
|
||||||
|
|
||||||
|
Launcher:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-final-prompt.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The self-contained prompt supplied the complete frozen changes, surrounding
|
||||||
|
code, standards and locally fetched authoritative OpenVR header excerpts. It
|
||||||
|
explicitly prohibited tools, edits, delegation and device access. This avoided
|
||||||
|
the first attempt's permission boundary without escalating permissions.
|
||||||
|
|
||||||
|
No output or verdict arrived within the fifteen-minute review window. The
|
||||||
|
process was sent SIGTERM at 918 seconds; the shell recorded real exit status
|
||||||
|
**143**. Findings are unavailable. Review must be completed before considering
|
||||||
|
this partial draft ready; playspace feasibility is also still paused.
|
||||||
|
|
||||||
|
## Other validation
|
||||||
|
|
||||||
|
- 166 Python unit tests passed locally.
|
||||||
|
- 8 website tests and UI JavaScript syntax passed locally.
|
||||||
|
- Desktop and phone-width attached-preview checks passed using live telemetry;
|
||||||
|
paid optional install buttons were disabled, and unavailable metrics cleared.
|
||||||
|
- [Fake-Frame CI](https://github.com/saphid/frame-control/actions/runs/36423548487/job/108931878908)
|
||||||
|
passed, as did Windows/Linux server tests and the main checks job. Docker was
|
||||||
|
unavailable locally. macOS/iOS jobs were still queued at this handoff.
|
||||||
|
- Real-device evidence and the paused-control limitation are in
|
||||||
|
[VR utilities](../../vr-utilities.md).
|
||||||
@@ -17,13 +17,17 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
The window has four tabs: **Home** (headset view, status, screenshots),
|
The window has six tabs: **Home** (headset view, status, screenshots),
|
||||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||||
the catalogue, display settings, reports) and **Tools** (sending files and text,
|
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||||
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
|
Flatpaks, remote and power), **Devices** (your headsets and their addresses),
|
||||||
anywhere in the window. When the Frame can't be reached, one banner says why in
|
and **Control** (the searchable Control Center, metrics, supported settings and
|
||||||
plain words and the app retries every few seconds, filling everything in once it
|
headset guidance). Keys 1–6 switch between them. Files can be dropped anywhere in the window. A
|
||||||
answers. Flatpak and Android installs run in the background; the bottom bar
|
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
|
||||||
|
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.
|
counts them while they run.
|
||||||
|
|
||||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||||
@@ -78,10 +82,75 @@ counts them while they run.
|
|||||||
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
|
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
|
||||||
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
||||||
Remmina).
|
Remmina).
|
||||||
|
- **Devices**: several headsets, each with several addresses (LAN IPs per
|
||||||
|
network, its `.local` mDNS name, its Tailscale IP or MagicDNS name). The app
|
||||||
|
tries them all at once and learns which worked on which network. Add, edit,
|
||||||
|
reorder and test addresses, find a headset on Tailscale or on this network,
|
||||||
|
name your networks, and switch headsets. See [devices.md](devices.md).
|
||||||
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
||||||
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
||||||
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
Linux). Remote desktop first checks that the Frame's xrdp answers on port
|
||||||
asks for the sudo password over SSH.
|
3389 (Developer Mode turns it on). On Windows it opens a connection file for
|
||||||
|
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
|
||||||
|
xrdp turns away. Accept the warning about the Frame's own certificate, then
|
||||||
|
sign in with the Developer Mode password. Sleep, restart and
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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 **On your Frame** 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
|
## How it works
|
||||||
|
|
||||||
@@ -101,7 +170,9 @@ Frame for the keyboard and trackpad.
|
|||||||
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
||||||
with a non-local `Host` header, and any `/api/` request without a custom
|
with a non-local `Host` header, and any `/api/` request without a custom
|
||||||
header, so other websites can't drive it or read captures. Everything reaches
|
header, so other websites can't drive it or read captures. Everything reaches
|
||||||
the headset through the `frame` SSH alias. On macOS and Linux it keeps one
|
the headset through its SSH alias (`frame` for the first one), pointed at the
|
||||||
|
address that answered with `-o HostName=` (`ui/frame_link.py`, described in
|
||||||
|
[devices.md](devices.md)). On macOS and Linux it keeps one
|
||||||
multiplexed SSH connection open, so status and each capture take about 0.3 s.
|
multiplexed SSH connection open, so status and each capture take about 0.3 s.
|
||||||
Windows' OpenSSH can't share a connection, so there each request connects on
|
Windows' OpenSSH can't share a connection, so there each request connects on
|
||||||
its own and the app is a little slower. What differs between the three
|
its own and the app is a little slower. What differs between the three
|
||||||
|
|||||||
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -38,6 +38,33 @@ after its container exited. No headset was worn and no host was connected.
|
|||||||
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
||||||
SteamVR's original process remained running. No global settings changed.
|
SteamVR's original process remained running. No global settings changed.
|
||||||
|
|
||||||
|
### Locked repeat, 2026-09-29 (verified)
|
||||||
|
|
||||||
|
Acquired `/tmp/frame-test.lock` before installing or launching anything and
|
||||||
|
released it after cleanup. Battery was 44% and charging at preflight, 46–47%
|
||||||
|
during the launches, and 47% at cleanup. The SteamOS version/build was unchanged.
|
||||||
|
|
||||||
|
Reinstalled and launched both original APKs in immersive Lepton instances.
|
||||||
|
WiVRn again failed at OpenXR 1.1 and 1.0 with the missing timespec extension;
|
||||||
|
ALVR again panicked on `ERROR_EXTENSION_NOT_PRESENT`. This repeats the
|
||||||
|
unmodified-client test, not the newer installer's automatic compatibility-layer
|
||||||
|
path. Neither reached a session that could be paired or exercised further.
|
||||||
|
Fresh [journal excerpts and cleanup evidence](evidence/linux-vr/2026-09-29.txt)
|
||||||
|
record the failures.
|
||||||
|
|
||||||
|
SteamVR's screenshot API returned a 1920×1080 headset capture after each
|
||||||
|
launch. Both are uniformly dark: [WiVRn](evidence/linux-vr/2026-09-29-wivrn.png)
|
||||||
|
and [ALVR](evidence/linux-vr/2026-09-29-alvr.png). These images do **not** prove
|
||||||
|
rendering or a working client. The headset was unworn; visibility, controllers,
|
||||||
|
frame rate and motion-to-photon latency could not be judged. The explicit
|
||||||
|
OpenXR errors, rather than the dark captures, establish the client blocker.
|
||||||
|
|
||||||
|
Both test installs, app data, shader caches, shortcuts, containers and temporary
|
||||||
|
capture files were removed. The original Steam and SteamVR process IDs were
|
||||||
|
unchanged. No reboot, power action or global setting change was used. Native
|
||||||
|
clients remain untested, and no Linux gaming host was available for Valve's
|
||||||
|
streaming path. The recommendation below is unchanged.
|
||||||
|
|
||||||
### Relation to the VR APK branch
|
### Relation to the VR APK branch
|
||||||
|
|
||||||
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -121,8 +121,13 @@ a limit on the number of floating panels.
|
|||||||
script needed.
|
script needed.
|
||||||
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
||||||
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
||||||
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
- **Optional overlay tools:** Desktop+, OVR Toolkit and similar software are
|
||||||
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
|
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
|
||||||
|
headset creates its own gamescope panel using built-in tools. It needs no
|
||||||
|
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
||||||
|
|
||||||
## Frame Control's panel switcher
|
## Frame Control's panel switcher
|
||||||
|
|
||||||
|
|||||||
@@ -33,23 +33,46 @@ computer, exactly as they were sent.
|
|||||||
yours.
|
yours.
|
||||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||||
CPU architecture and Python version.
|
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
|
## Usage events
|
||||||
|
|
||||||
| Event | When | Properties besides the common ones |
|
| Event | When | Properties besides the common ones |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `app_installed` | First run | |
|
| `app_installed` | First run | `country` |
|
||||||
| `app_updated` | First run of a new version | `from_version` |
|
| `app_updated` | First run of a new version | `from_version`, `country` |
|
||||||
| `app_opened` | At most once a day | |
|
| `app_opened` | At most once a day | `country` |
|
||||||
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
|
| `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` |
|
| `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` |
|
| `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
|
`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
|
error becomes one category from this fixed list, plus Android's own
|
||||||
`frame_unreachable`), plus Android's own `INSTALL_FAILED_…` code when there
|
`INSTALL_FAILED_…` code (`installer_code`) when there is one:
|
||||||
is one. It names what was installed only when that's already public:
|
`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
|
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
|
||||||
catalogue app's package name
|
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
|
- the notes, app name and version are scrubbed like error messages (see
|
||||||
below)
|
below)
|
||||||
- the APK's source is kept only if it's `F-Droid` or the public host name of
|
- the APK's source is kept only if it's `F-Droid`, `meta-store` (a test
|
||||||
a download link (`https://example.com/…`). File names, user names,
|
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
|
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
|
When you turn this on, reports you made earlier on this computer are shared
|
||||||
too.
|
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
|
The maintainer's `python3 ui/frame_compat_db.py sync` copies these events
|
||||||
into the compatibility database, marked `via=community…`. It takes at most
|
into the compatibility database, marked `via=community…`. It takes at most
|
||||||
30 per reporter per day.
|
30 per reporter per day.
|
||||||
@@ -93,7 +124,11 @@ scrubbed:
|
|||||||
names, passwords, ports, paths and queries are dropped
|
names, passwords, ports, paths and queries are dropped
|
||||||
- `token=`, `key=`, `password=` and similar values are replaced
|
- `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
|
||||||
|
|
||||||
@@ -103,9 +138,18 @@ 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
|
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
|
nothing is published. It works whatever the analytics settings are, because
|
||||||
the person sends it deliberately. The report has the kind, title and text you
|
the person sends it deliberately. The report has the kind, title and text you
|
||||||
wrote, how to reach you if you gave it, a short reference shown after sending,
|
wrote, a short reference shown after sending, and the diagnostics below. Your
|
||||||
and the diagnostics below. It has its own random id, so it isn't linked to
|
email address goes with it only if you tick **The maintainer may contact me
|
||||||
your analytics events.
|
with follow-up questions** (the report then carries `contact_followup: true`);
|
||||||
|
it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to
|
||||||
|
your analytics events. With that box ticked, the address also becomes your
|
||||||
|
**Contact email** below with follow-up questions ticked, so you remove it there
|
||||||
|
like any other. If it's a different address from the one saved there, it
|
||||||
|
replaces it, and update notices stop until you turn them on again (they were
|
||||||
|
agreed for the old address); the form says so before you send. The report then also
|
||||||
|
carries this copy's contact id and change number (`contact_id`, `contact_rev`,
|
||||||
|
see below), so removing or changing the address later takes back the
|
||||||
|
follow-up permission given with the report too.
|
||||||
|
|
||||||
With **Include diagnostics** ticked (the default), the report adds:
|
With **Include diagnostics** ticked (the default), the report adds:
|
||||||
|
|
||||||
@@ -113,10 +157,56 @@ With **Include diagnostics** ticked (the default), the report adds:
|
|||||||
- the OS, its release and CPU, and the Python version
|
- the OS, its release and CPU, and the Python version
|
||||||
- the Frame's SteamOS build, if it has connected since the app started
|
- the Frame's SteamOS build, if it has connected since the app started
|
||||||
- which analytics levels are on
|
- 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,
|
**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
|
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. 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
|
Everything is scrubbed like error details and limited to what fits in the
|
||||||
report. Environment details are kept first, then the newest lines. **Show
|
report. Environment details are kept first, then the newest lines. **Show
|
||||||
@@ -128,11 +218,67 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or
|
|||||||
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
|
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
|
||||||
API key as `frame_compat_db.py sync`.
|
API key as `frame_compat_db.py sync`.
|
||||||
|
|
||||||
|
## Contact email (optional)
|
||||||
|
|
||||||
|
Frame Control never needs an email address. If you'd like to leave one, there
|
||||||
|
are two separate choices, both off until you tick them:
|
||||||
|
|
||||||
|
| Choice | What it's for |
|
||||||
|
|---|---|
|
||||||
|
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
|
||||||
|
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
|
||||||
|
|
||||||
|
You're asked once, in a bar at the top of the page, after the Frame has
|
||||||
|
connected for the first time, and never in the same visit as the first-run
|
||||||
|
privacy notice. **No thanks** hides it for good, and it isn't
|
||||||
|
shown again even if you ignore it. **Contact email** in **Privacy & updates**
|
||||||
|
is where you add, change or remove the address and either choice at any time.
|
||||||
|
|
||||||
|
**What's sent, and where.** The address and the two choices go privately to
|
||||||
|
Frame Control's PostHog project, the same place as problem reports, as a
|
||||||
|
`contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
|
||||||
|
or `withdraw`) and the common properties above. Only the maintainer can read
|
||||||
|
that project, and nothing in it is published or shared. It's sent only when
|
||||||
|
you save, or when you send a problem report with follow-up questions ticked,
|
||||||
|
whatever the analytics settings are, because you chose to. With a report, the
|
||||||
|
address and choices are saved before the report is sent and stay saved if it
|
||||||
|
fails; like any change, they're sent as soon as PostHog can be reached. It
|
||||||
|
carries its own random contact id, not the analytics id, so it isn't linked
|
||||||
|
to your usage events, and a `rev` number that goes up with each change, so
|
||||||
|
the newest choice always wins. Like everything else sent, it's listed under
|
||||||
|
**Show what's been sent**. On this computer the address and choices are kept in
|
||||||
|
`contact/contact.json` in Frame Control's data folder. An address is only
|
||||||
|
kept with at least one choice ticked.
|
||||||
|
|
||||||
|
**Removing it.** **Remove my email** (or clearing the address and saving)
|
||||||
|
deletes it from this computer, including from the **Show what's been sent**
|
||||||
|
log (in earlier contact events and problem reports), and sends a `withdraw`
|
||||||
|
event with no address in it. The maintainer's list only uses the newest event from each copy, so from
|
||||||
|
then on the address isn't listed for either choice. Unticking one choice
|
||||||
|
works the same way for that choice. This also covers problem reports you sent
|
||||||
|
from this copy with follow-up questions ticked: if your newest choice since the
|
||||||
|
report (by change number, not the clock) no longer agrees to follow-up
|
||||||
|
questions at that address, the maintainer's inbox shows the permission as
|
||||||
|
withdrawn and leaves the address out. If you're offline, the change waits on
|
||||||
|
this computer and is sent when PostHog can be reached. The earlier event
|
||||||
|
stays in PostHog until its data retention removes it; to have it deleted
|
||||||
|
sooner, ask the maintainer (for example in a problem report).
|
||||||
|
|
||||||
|
Nothing sends email yet: this only records who agreed to what. The
|
||||||
|
maintainer lists the addresses with
|
||||||
|
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
|
||||||
|
personal API key as `inbox`.
|
||||||
|
|
||||||
## Turning it all off
|
## Turning it all off
|
||||||
|
|
||||||
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
|
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
|
||||||
the environment that starts Frame Control. A copy run from a source checkout
|
the environment that starts Frame Control. A copy run from a source checkout
|
||||||
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
||||||
|
|
||||||
|
These switches cover the analytics above. A problem report or a contact email
|
||||||
|
is sent only because you pressed its Send or Save button, so those still go
|
||||||
|
when you choose to send them (a contact change saved while offline is sent
|
||||||
|
by itself once PostHog can be reached); if you don't, nothing is sent.
|
||||||
|
|
||||||
## Update checks
|
## Update checks
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -24,13 +24,20 @@ count. So a build reaches people only when you publish it, after testing it.
|
|||||||
4. Publish:
|
4. Publish:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
|
||||||
scripts/publish-release.sh v0.4.0
|
scripts/publish-release.sh v0.4.0
|
||||||
```
|
```
|
||||||
|
|
||||||
The script checks that all eight installers are attached, each with the
|
The script checks that the tag is on GitHub and that all eight installers
|
||||||
SHA-256 digest GitHub records. It attaches `update.json` (the version, the
|
are attached, each with the SHA-256 digest GitHub records. It attaches
|
||||||
notes and each installer's digest), then publishes the release and marks it
|
`update.json` (the version, the notes, the release page and each
|
||||||
latest. From then on, running copies see the update. They check about 8
|
installer's digest), then publishes the release and marks it latest. It
|
||||||
|
uses only the REST API: `gh release view` can't find a draft whose
|
||||||
|
`tag_name` still reads `untagged-…`, and GraphQL is often rate-limited.
|
||||||
|
Such a draft is found by its exact title (`Frame Control 0.4.0`, or that
|
||||||
|
followed by `: subtitle`) and tied to the tag when it's published. The release page in `update.json` is always
|
||||||
|
`releases/tag/<tag>`, because a draft's own address (`releases/tag/untagged-…`)
|
||||||
|
stops working once it's published. From then on, running copies see the update. They check about 8
|
||||||
seconds after starting, then every 6 hours, and anyone can use **Check for
|
seconds after starting, then every 6 hours, and anyone can use **Check for
|
||||||
Updates…** (the app menu on macOS, the Help menu elsewhere).
|
Updates…** (the app menu on macOS, the Help menu elsewhere).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# SideQuest and Frame Control
|
||||||
|
|
||||||
|
Researched 2026-09-28. SideQuest is both a Quest discovery website and a desktop
|
||||||
|
sideloading/device-management app. Its Quest labels are **not** evidence that a
|
||||||
|
game works on Lepton: inspect the APK for arm64/OpenXR, Android API requirements,
|
||||||
|
VrApi and Meta services (see [VR APKs](vr-apks.md)).
|
||||||
|
|
||||||
|
## Features worth borrowing
|
||||||
|
|
||||||
|
Desktop evidence is the public [SideQuest source at af2ac70](https://github.com/SideQuestVR/SideQuest/tree/af2ac7043db122bca3c8db18f2b58f1660e9befb),
|
||||||
|
especially [ADB operations](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/adb-client.service.ts),
|
||||||
|
[drag and drop](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/drag-and-drop.service.ts),
|
||||||
|
and the [legacy repository index](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/packages/package.service.ts).
|
||||||
|
Website evidence: [SideQuest](https://sidequestvr.com/) and its public Angular
|
||||||
|
bundle `main-4MMXZRXL.js`, inspected locally without browser automation.
|
||||||
|
No SideQuest implementation code was copied.
|
||||||
|
|
||||||
|
| SideQuest feature | Frame Control before this change | Borrow? / effort |
|
||||||
|
|---|---|---|
|
||||||
|
| Store descriptions, screenshots, banners, trailers, ratings | F-Droid names, icons, compatibility verdicts and reports; no equivalent rich VR store | Yes, from authorised sources; medium. Search and library workers own presentation/artwork. |
|
||||||
|
| OBB expansion-file install | APK-only install | **Implemented helper and CLI**, medium. Essential for games whose assets are separate from the APK. |
|
||||||
|
| App-data backup/restore | Persistent instances and optional keep-data uninstall, no portable save archive | **Implemented private-data helper and CLI**, medium. Back up before updates or experiments. |
|
||||||
|
| File manager (list, upload, download, remove) | General Send to Frame, no Android file browser | Useful later, medium; requires clear instance selection and scoped paths. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
Priority: expansion files, then save backup/restore. Rich discovery and update
|
||||||
|
notices follow once a permitted metadata source and source/version persistence
|
||||||
|
are available. This patch deliberately exposes CLI/backend operations, leaving
|
||||||
|
shared UI, install(), Steam artwork and launch behavior to sibling work.
|
||||||
|
|
||||||
|
## SideQuest as a source: page-only
|
||||||
|
|
||||||
|
[Terms](https://sidequestvr.com/terms), “Prohibited Activities”, (i) prohibits
|
||||||
|
copying/distributing/disclosing the Service including automated or non-automated
|
||||||
|
“scraping”; (xi) prohibits content access through means other than those provided
|
||||||
|
or authorised by the Service; (xii) prohibits bypassing access restrictions.
|
||||||
|
The terms describe downloading developer-posted games through the Service, but
|
||||||
|
do not establish permission for this third-party API integration.
|
||||||
|
|
||||||
|
[robots.txt](https://sidequestvr.com/robots.txt) requests a three-second crawl
|
||||||
|
delay and disallows `/search/`, `/user/*` and `/sideload/*`. Robots permission
|
||||||
|
would not override the terms. The API host's robots request returned HTTP 403;
|
||||||
|
a request for the first shared website JS chunk also returned 403. No bypass,
|
||||||
|
account token, cookies, browser session or private endpoint was used.
|
||||||
|
|
||||||
|
The homepage publishes `https://api.sidequestvr.com` and
|
||||||
|
`https://cdn.sidequestvr.com`. The website bundle calls `searchApps(...)` and
|
||||||
|
`getApp(id, null)`; their actual HTTP search/detail routes could not be established
|
||||||
|
from the retrieved bundle. Do not invent endpoints. The open-source desktop
|
||||||
|
[install flow](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/electron/app.ts)
|
||||||
|
POSTs `{token: ...}` to `/install-from-key`. It consumes
|
||||||
|
`data.apps[].urls[]`, with `provider` values including `APK`, `OBB`,
|
||||||
|
`Github Release` and `Mod`, and `link_url`. This is a website-issued install-key
|
||||||
|
flow, not evidence of an anonymous download API. It is not implemented here.
|
||||||
|
|
||||||
|
`ui/apk_sources/sidequest.py` implements the shared interface conservatively:
|
||||||
|
|
||||||
|
- `sources()` marks SideQuest `page_only` and explains why.
|
||||||
|
- `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.
|
||||||
|
- `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;
|
||||||
|
unknown facts, including free/VR status, stay `None`.
|
||||||
|
- `download()` refuses with that page link. Paid/external listings cannot be
|
||||||
|
downloaded by this adapter either. No downloads means no verification claim.
|
||||||
|
|
||||||
|
The JSON fixture records policy evidence, **not a purported live app response**.
|
||||||
|
No listing metadata, artwork URLs, or OBB download URLs were scraped.
|
||||||
|
The requested real SideQuest → OpenXR APK → `frame_android.py info` test is
|
||||||
|
**blocked by the terms**, and was not performed. No alternate source is silently
|
||||||
|
substituted. A future integration needs SideQuest's permission or an expressly
|
||||||
|
supported third-party API, plus recorded search/detail/download fixtures,
|
||||||
|
free/direct-download classification, and size/hash verification. A calculated
|
||||||
|
local SHA-256 alone must not be called publisher verification.
|
||||||
|
|
||||||
|
## OBB files
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the APK first. The named instance must already be running; the helper
|
||||||
|
never launches an app or uses Lepton Development. It requires standard
|
||||||
|
`main|patch.<versionCode>.<package>.obb` filenames and nonempty files, validates
|
||||||
|
the entire batch before transfer, streams each file through SSH into that
|
||||||
|
instance, checks its SHA-256 **inside Android**, then renames it into
|
||||||
|
`/sdcard/Android/obb/<package>/`. `verified: True` here means transfer integrity
|
||||||
|
against the local input, not publisher authentication. Publication is atomic per
|
||||||
|
file, not for the whole batch; retry after a partial batch failure. Existing OBBs
|
||||||
|
with different version codes remain. The filename version must match the game;
|
||||||
|
the current install metadata does not expose its version code for comparison.
|
||||||
|
Restart the game yourself after the transfer if it cached missing expansion data.
|
||||||
|
|
||||||
|
Both read-only SSH attempts to the Frame timed out. Therefore the exact
|
||||||
|
host-side `/sdcard` mapping and persistence of expansion data were **not verified**.
|
||||||
|
`compatdata/<instance>/internal/<package>` is documented as `/data/data/<package>`;
|
||||||
|
it must not be mistaken for `/sdcard`. Using Android's path avoids guessing a
|
||||||
|
host layout, but device verification across restart/update is still required.
|
||||||
|
No OBB file was installed on the Frame during this work.
|
||||||
|
|
||||||
|
## Private app-data backups
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 ui/frame_android.py stop org.example.game
|
||||||
|
python3 ui/frame_android.py backup-data org.example.game ./game-save.tar.gz
|
||||||
|
python3 ui/frame_android.py restore-data org.example.game ./game-save.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the instance stopped throughout either operation; do not launch it from
|
||||||
|
Steam concurrently. The remote guard fails if Podman cannot enumerate containers
|
||||||
|
or reports that instance running. The helpers use `podman unshare` to read/write
|
||||||
|
Android's mapped ownership without changing the live data's permissions.
|
||||||
|
|
||||||
|
The archive covers **only** `compatdata/<instance>/internal/<package>`, not the
|
||||||
|
APK, external `/sdcard/Android/data`, OBBs, keystore, or the full Android snapshot.
|
||||||
|
It contains a package/instance manifest and regular files/directories. Backups
|
||||||
|
are private (0600), validated before publication, and never overwrite an existing
|
||||||
|
backup. Keep them safe: app data can contain credentials and is not encrypted.
|
||||||
|
|
||||||
|
Restore checks the package and instance, rejects absolute/traversing/duplicate
|
||||||
|
paths, links and devices, caps files at 100,000 and content at 20 GiB, and validates
|
||||||
|
again on the Frame. It extracts into a separate directory, preserves numeric
|
||||||
|
ownership, ordinary modes and timestamps, then swaps the private-data directory.
|
||||||
|
Setuid/setgid bits are not restored. The previous directory remains beside it as
|
||||||
|
`.<package>.before-restore-<timestamp>`; the returned `previous` path identifies
|
||||||
|
it. This is an additional recovery copy, not an automatic deletion policy.
|
||||||
|
|
||||||
|
Locally verified: archive round trip including recovery copy, malformed archive
|
||||||
|
rejection, transfer command construction and failure handling. Not verified:
|
||||||
|
real Frame UID mappings/permissions, Android app-level recovery, live FUSE OBB
|
||||||
|
writes or persistence. Backups reject symlinks/special files; an app requiring
|
||||||
|
those needs a separately designed backup format. These CLI features still need
|
||||||
|
a real-device acceptance pass before being exposed as a polished UI workflow.
|
||||||
@@ -110,3 +110,11 @@ returns nothing without `cc`, so Frame Control takes the country from
|
|||||||
- Installing when there's more than one library folder, such as a microSD card.
|
- Installing when there's more than one library folder, such as a microSD card.
|
||||||
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
|
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
|
||||||
headset.
|
headset.
|
||||||
|
|
||||||
|
## Optional VR software
|
||||||
|
|
||||||
|
The [VR utilities list](vr-utilities.md#optional-software) is separate from our
|
||||||
|
controls and HUD. It checks software ownership as well as games; paid utilities
|
||||||
|
are installable only when already in the loaded Frame account library. No
|
||||||
|
purchase flow is added. Public reports, local results and ownership are shown
|
||||||
|
separately, and untested tools remain untested.
|
||||||
@@ -25,6 +25,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
|
|||||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||||
Mac with keyboard, mouse, and clipboard.
|
Mac with keyboard, mouse, and clipboard.
|
||||||
|
|
||||||
|
**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2,
|
||||||
|
Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer
|
||||||
|
Mode password opens a Plasma (X11) desktop within about 6 seconds.
|
||||||
|
|
||||||
|
- xrdp has no NLA, so the client shows a certificate warning (xrdp's own
|
||||||
|
`www.xrdp.org` certificate) and then xrdp's own login box. Frame Control
|
||||||
|
fills in `steamos` there on Windows, Remmina and FreeRDP.
|
||||||
|
- The desktop is a separate login session (Xorg on display `:10`), not the
|
||||||
|
headset's view. It uses about 1.3 GB of the Frame's memory.
|
||||||
|
- Closing the client leaves the session running, and the next login
|
||||||
|
reconnects to it. To end it over SSH, find it with `loginctl list-sessions`
|
||||||
|
and run `loginctl terminate-session <id>`. That doesn't touch the headset's
|
||||||
|
gamescope or SteamVR session.
|
||||||
|
|
||||||
## B. Show the Mac's desktop inside the Frame
|
## B. Show the Mac's desktop inside the Frame
|
||||||
|
|
||||||
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
|
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
|
||||||
@@ -247,6 +261,16 @@ How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
|
|||||||
-window_id <window> -i :<display>` works on gamescope's redirected windows,
|
-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
|
while grabbing the root gives black. It streams as H.264 like the headset view
|
||||||
(about 30 fps at 720p).
|
(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.
|
||||||
- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
|
- 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
|
text box in the panel and the text field typed into it; a trackpad move went
|
||||||
exactly (+40, +25).
|
exactly (+40, +25).
|
||||||
|
|||||||
@@ -15,6 +15,29 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
|||||||
python3 -m unittest discover -s tests
|
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
|
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`.
|
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
|
||||||
|
|
||||||
|
|||||||
@@ -84,13 +84,146 @@ not being worn, so it did not reach `FOCUSED`).
|
|||||||
The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a
|
The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a
|
||||||
Khronos-style loader and found SteamVR through `/vendor`.
|
Khronos-style loader and found SteamVR through `/vendor`.
|
||||||
|
|
||||||
|
## In the Steam library
|
||||||
|
|
||||||
|
Every successful APK install goes through the same mandatory artwork writer:
|
||||||
|
CLI (including `scripts/install-apk.sh`), upload, catalogue, version finder,
|
||||||
|
web download and source modules calling `frame_android.install`. Native
|
||||||
|
Linux/Windows sideloads also use it, preserving their devkit runtime wiring.
|
||||||
|
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
|
||||||
|
`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
|
||||||
|
returned by the settings API or copied to the headset. Exact title matches
|
||||||
|
(including a trailing “VR” variant) use the highest-scored returned static,
|
||||||
|
non-NSFW image in each slot. Provider failures use the next source.
|
||||||
|
|
||||||
|
Sources pass `install(apk_path, artwork={...})`: keys are `grid`, `wide`,
|
||||||
|
`hero`, `logo`, `icon`, `banner`, `feature_graphic`, `screenshot`, or a list
|
||||||
|
`screenshots`. Values are PNG/JPEG bytes or HTTP(S) URLs (12 MiB and
|
||||||
|
4096×4096 pixels maximum; any PNG depth or interlace, since the Frame's
|
||||||
|
Chromium decodes them). URLs must resolve to public addresses, follow at most
|
||||||
|
three redirects and share one deadline per install. Any source that fails,
|
||||||
|
for any reason, becomes a warning and generated art. Banners and feature graphics supply hero/wide art;
|
||||||
|
screenshots are the next fallback. Source images are cached for refresh.
|
||||||
|
All images are fitted to 600×900 portrait, 920×430 wide, 3840×1240 hero,
|
||||||
|
1280×480 logo and 256×256 icon. Explicit logos retain transparency.
|
||||||
|
Photo-based portrait, wide and hero slots are JPEG: Steam takes at most
|
||||||
|
12 MiB per slot, and on the Frame (2026-09-28) a noise-heavy 3840×1240 hero
|
||||||
|
came to more than 12 MiB as PNG, 3.7 MB as JPEG (2.7 s to render); a
|
||||||
|
landscape photo hero 5.6 MB as PNG, 0.76 MB as JPEG (0.75 s). A render that
|
||||||
|
still fails is retried once with generated art. Steam keeps a slot's `.png`
|
||||||
|
and `.jpg` side by side, so each slot is cleared before it is set.
|
||||||
|
|
||||||
|
Generated art uses the APK icon, a dominant-colour gradient, a blurred
|
||||||
|
backdrop and large foreground icon with shadow. Steam's Chromium canvas and
|
||||||
|
Motiva Sans render real text consistently regardless of the host OS; no
|
||||||
|
Pillow, host font installation or bitmap font is needed. The hero has no
|
||||||
|
title; the generated logo is a transparent title. APKs with no usable icon
|
||||||
|
get a typographic monogram. The desktop package includes the renderer.
|
||||||
|
|
||||||
|
Backfill installed Android apps without reinstalling or stopping them:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 ui/frame_android.py refresh-art org.godotengine.open_saber_plus
|
||||||
|
python3 ui/frame_android.py refresh-art --all
|
||||||
|
```
|
||||||
|
|
||||||
|
Devkit titles installed by Frame Control have the same command,
|
||||||
|
`python3 ui/frame_titles.py refresh-art ID|--all`. The settings panel's
|
||||||
|
refresh covers both. The API is `POST /api/android` with
|
||||||
|
`{"action":"refresh-art","all":true}` (apps and titles) or a `package`, and
|
||||||
|
`POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a
|
||||||
|
background job. Batch results retain per-item errors, and the CLIs exit
|
||||||
|
nonzero if any failed. Apps and titles without complete artwork show **Add
|
||||||
|
artwork** and `list` prints the command. Only entries marked `art_pending` at
|
||||||
|
install (a title Steam registered after an install made while it wasn't
|
||||||
|
running) are backfilled automatically, when Frame Control lists them with
|
||||||
|
Steam running (at most every five minutes), and that backfill only fills
|
||||||
|
slots Steam has no art for: names, icons, flags and any art the user set are
|
||||||
|
kept. Older installs without the flag are refreshed only on request.
|
||||||
|
|
||||||
|
Steam's app overviews carry no `devkit_gameid` (checked 2026-09-28, build
|
||||||
|
20260925.6191901, on every non-Steam shortcut). A title's shortcut is found by
|
||||||
|
its saved id, or by an executable or start folder inside
|
||||||
|
`~/devkit-game/<id>/`, read from `appDetailsStore`; never by display name.
|
||||||
|
That the devkit shortcut's exe/start folder sit inside the title folder is
|
||||||
|
inferred from `docs/sideloading.md` (`proton waitforexitandrun
|
||||||
|
"/home/steamos/devkit-game/<id>/<exe>"`), not yet seen in app details.
|
||||||
|
Devkit titles keep the VR flag Steam gave them.
|
||||||
|
|
||||||
|
**Verified on build 20260925.6191901, SteamVR 2.18.1 (2026-09-28):** both
|
||||||
|
Open Saber Plus and SuperTux were backfilled. Steam's cached portrait, wide,
|
||||||
|
hero and logo PNGs have the dimensions above; each shortcut points at its
|
||||||
|
256×256 icon. This Frame client mishandles custom-art type 4 (documented as
|
||||||
|
Icon), overwriting the wide capsule; the implementation uses custom types
|
||||||
|
0–3 and **SetShortcutIcon** separately.
|
||||||
|
|
||||||
|
Steam accepts display name, executable/start directory, icon, VR flag and
|
||||||
|
sort-as name. Android apps join **Android**, immersive apps also **Android
|
||||||
|
VR**; native sideloads join **Sideloaded**. Existing collection members and
|
||||||
|
unrelated collections are preserved (both games retained **Played**).
|
||||||
|
Dynamic/read-only collection conflicts produce warnings. The native notes
|
||||||
|
API supports a managed **Installation details** note (package, version and
|
||||||
|
source) while preserving other notes. Notes are keyed by sanitized shortcut
|
||||||
|
name, so Steam itself cannot distinguish equal-name shortcut notes. No
|
||||||
|
supported shortcut description/store-page, developer/publisher, release
|
||||||
|
metadata or custom achievement API was found; these are not fabricated.
|
||||||
|
|
||||||
|
The launcher supervises Lepton and handles TERM/INT/HUP and normal exit by
|
||||||
|
stopping its own container and child process group. A lock refuses duplicate launches;
|
||||||
|
a container still running while the lock is free was orphaned by a killed
|
||||||
|
launcher and is stopped before the new launch. Lepton doesn't inherit the
|
||||||
|
lock. Orphan recovery only stops the app's own, deterministically named
|
||||||
|
container; a Lepton host process whose launcher was killed before it created
|
||||||
|
the container may linger briefly. Removing an app or title still deletes its files when Steam isn't
|
||||||
|
running; tidying Steam's collections and artwork is best effort. Steam Stop uses `TerminateApp` with the exact
|
||||||
|
64-bit game ID string. Frame Control's Stop additionally has a direct-container
|
||||||
|
fallback. The stable instance ID and compatdata paths remain unchanged.
|
||||||
|
|
||||||
|
Lepton normally forwards the instance `SteamAppId` to Android, causing
|
||||||
|
SteamVR to associate the scene with a different, artwork-less app. The
|
||||||
|
launcher uses Lepton's supported `LEPTON_ENV_SteamAppId` passthrough to send
|
||||||
|
the actual shortcut ID to Android while retaining the stable container ID.
|
||||||
|
**Verified:** Open Saber was alive 22 seconds after Steam Play, SteamVR
|
||||||
|
identified `steam.app.3346865537`, and its scene appeared in the headset
|
||||||
|
capture without the previous blank Resume tile. Steam Stop then removed its
|
||||||
|
tracked process and stopped the container. An earlier 32-second session was
|
||||||
|
also tracked until Steam Stop. No global standby or dashboard overrides were
|
||||||
|
installed; wear detection and other user-opened overlays still apply.
|
||||||
|
|
||||||
|
**SuperTux limitation:** Steam launched and tracked it, but SDL crashed during
|
||||||
|
activity creation because Lepton lacks `ClipboardManager`. Its container
|
||||||
|
cleaned up on exit after about 17 seconds. Consequently sustained SuperTux
|
||||||
|
Play/Stop and its VR scene could not be verified. This is an APK/runtime
|
||||||
|
compatibility failure, separate from library presentation.
|
||||||
|
|
||||||
|
Evidence is under `/tmp/vrlib-evidence/` on the development Mac: final artwork
|
||||||
|
preview and three design passes, `steam-cache-final.log`,
|
||||||
|
`steam-details-targets.json`, `opensaber-identity-session.log`,
|
||||||
|
`opensaber-identity-headset.png`, and `supertux-lepton.log`. The preview is
|
||||||
|
rendered artwork, not a Steam UI screenshot; CDP screenshot capture timed
|
||||||
|
out. Authenticated SteamGridDB, Windows/Linux packaged builds and the sibling
|
||||||
|
source-search endpoint remain unverified (the public install seam is tested).
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
||||||
(`libovrplatformloader.so`) to check the Quest store licence need Meta's
|
(`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,
|
- **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
|
## Frame Control does this for you
|
||||||
|
|
||||||
@@ -123,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.
|
and keeps the current refresh rate when SteamVR refuses a requested one.
|
||||||
`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
|
`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
|
||||||
`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`.
|
`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):
|
Verified on the headset (2026-09-28):
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# VR comfort and performance
|
||||||
|
|
||||||
|
Frame Control owns its HUD and telemetry. They use SteamVR/OpenVR, gamescope,
|
||||||
|
Python and xterm already on the Frame, plus the Frame's sensors. No feature
|
||||||
|
requires fpsVR, OVR Advanced Settings, XSOverlay or another third-party app.
|
||||||
|
The software list is a separate, optional convenience.
|
||||||
|
|
||||||
|
This is **part of [#25](https://github.com/saphid/frame-control/issues/25)**,
|
||||||
|
not completion of the issue. Playspace controls remain blocked on the device
|
||||||
|
checks below. The PR stays draft.
|
||||||
|
|
||||||
|
## Our performance HUD
|
||||||
|
|
||||||
|
On **Home → VR comfort and performance**, 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
|
||||||
|
it to a controller. **Close HUD**, or closing its terminal, ends it. Opening
|
||||||
|
it twice reuses the existing process.
|
||||||
|
|
||||||
|
| Value | Meaning and source |
|
||||||
|
|---|---|
|
||||||
|
| Compositor FPS / period | Differences between two `IVRCompositor_029::GetFrameTiming` frame indices and monotonic compositor timestamps, sampled 200 ms apart. Output cadence, not game FPS or a long-term average. |
|
||||||
|
| Application FPS | Reciprocal of OpenVR's client frame interval. Unavailable if there is no positive interval; not inferred from refresh rate. |
|
||||||
|
| Render GPU time | OpenVR total render GPU milliseconds, not GPU utilisation. |
|
||||||
|
| 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`. |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Missing sensors, a stopped or incompatible SteamVR runtime, and non-advancing
|
||||||
|
frame indices display **Unavailable**, never invented zero FPS. Failed status
|
||||||
|
refreshes clear the HUD card rather than keeping a stale live-looking sample.
|
||||||
|
The HUD itself adds CPU/GPU work; it is a diagnostic, not a zero-overhead benchmark.
|
||||||
|
|
||||||
|
**Verified 2026-09-28**, SteamOS 0.4.1, build `20260925.6191901`, SteamVR
|
||||||
|
2.18.1: the exact OpenVR interface and 192-byte timing layout returned advancing
|
||||||
|
frame indices and live GPU/CPU timing. Sensor reads, creation of overlay
|
||||||
|
`valve.steam.desktopgame.2000250025`, duplicate-open handling, and closing the
|
||||||
|
HUD passed. The temporary probe overlay disappeared after its process closed.
|
||||||
|
[Sanitized device sample](evidence/vr-utilities/device.json).
|
||||||
|
|
||||||
|
**Unverified:** visual placement while wearing the headset, controller docking,
|
||||||
|
and overhead during gameplay. The companion card was checked in the attached
|
||||||
|
preview using live Frame data. Creating an overlay does not establish that it
|
||||||
|
was visible to the wearer.
|
||||||
|
|
||||||
|
The optional HUD copies only `frame_status.py` and `frame_vr.py` into
|
||||||
|
`~/.local/share/frame-control/vr/`. It tags only the window whose X11 PID matches
|
||||||
|
its own xterm, avoiding other threads' windows. Stop checks both PID and Linux
|
||||||
|
process start time before sending SIGTERM. It does not stop Steam or SteamVR,
|
||||||
|
edit their settings, install a service, or need sudo.
|
||||||
|
|
||||||
|
## Playspace, seated height and recenter: paused
|
||||||
|
|
||||||
|
**Verified 2026-09-28:** SteamVR exposes `IVRChaperoneSetup_006` and
|
||||||
|
`IVRChaperone_004`. An initial 1 cm seated zero-pose translation committed,
|
||||||
|
read back and restored numerically. A later trial, while the shared Frame was
|
||||||
|
in use, changed universe IDs after commits and returned transforms that did
|
||||||
|
not match the requested write or restore. Journal entries reported
|
||||||
|
`CommitWorkingCopy`, `VREvent_ChaperoneUniverseHasChanged` and
|
||||||
|
`VREvent_ChaperoneRoomSetupCommitted`. The collision-bound arrays and play-area
|
||||||
|
size matched in the saved before/after records, but the origin matrices did not.
|
||||||
|
|
||||||
|
Alex confirmed the headset was in use and asked to pause control tests. No
|
||||||
|
further playspace writes were made. The exploratory control implementation was
|
||||||
|
removed from the shipping API; `recenter`, `adjust` and `restore` are rejected.
|
||||||
|
This is an **unresolved feasibility check**, not evidence that OpenVR controls
|
||||||
|
cannot work. Concurrent use and the Frame driver's coordinate-system handling
|
||||||
|
still need to be separated.
|
||||||
|
|
||||||
|
The probe's original and last-read poses remain in the Frame's
|
||||||
|
`~/.local/share/frame-control/vr/comfort.json` for investigation. This draft
|
||||||
|
neither reads nor applies that baseline. Do not blindly replay it into a room
|
||||||
|
that may have changed. No recenter test was reached in the later trial.
|
||||||
|
|
||||||
|
Before adding controls, on an idle Frame:
|
||||||
|
|
||||||
|
1. Establish current room and tracking state, and inspect the retained probe
|
||||||
|
evidence before considering any restoration.
|
||||||
|
2. Prove seated and standing height/move operations in the Frame driver's
|
||||||
|
current coordinates, including delayed readback, coordinate rebasing and
|
||||||
|
recovery. Show that the physical safety boundary stays correct.
|
||||||
|
3. Verify recenter independently, and test a full apply/restore cycle plus a
|
||||||
|
concurrent room-change refusal. Add a fake OpenVR test for those contracts.
|
||||||
|
4. Check the apparent result in a seated and a standing app before exposing UI.
|
||||||
|
|
||||||
|
**Documented:** seated and standing are tracking origins selected by an app;
|
||||||
|
changing a seated origin cannot force every game to support seated play.
|
||||||
|
|
||||||
|
**Inferred from the installed SteamVR defaults:** there is no generic snap-turn
|
||||||
|
or locomotion-vignette setting. `dashboard.verticalOffsetCm_2` and
|
||||||
|
`steamvr.panelMaskVignette` affect panels, not the player's height or game
|
||||||
|
locomotion. The app gives game-setting hints for snap-turn, teleport movement
|
||||||
|
and movement vignette instead of writing these unrelated settings.
|
||||||
|
|
||||||
|
## Optional software
|
||||||
|
|
||||||
|
**Verified 2026-09-28 (same build):** a read-only query of Steam's loaded
|
||||||
|
`appStore.allApps` found none of these five apps on the Frame account. The query
|
||||||
|
includes software, which the existing games-only library filter excludes.
|
||||||
|
Steam's public app-details API listed only Desktop+ as free. No software was
|
||||||
|
purchased, installed or launched during these ownership checks.
|
||||||
|
|
||||||
|
| Utility | Local Frame status | Public evidence / optional source |
|
||||||
|
|---|---|---|
|
||||||
|
| OVR Advanced Settings (1009850) | **Untested**, not owned | Steam edition is paid. Developer's [free source and releases](https://github.com/OpenVR-Advanced-Settings/OpenVR-AdvancedSettings). No verified Frame result in this work. |
|
||||||
|
| XSOverlay (1173510) | **Untested**, not owned | Supplied research attributes Proton support with tweaks to [Road to VR](https://www.roadtovr.com/valve-steam-frame-review/). This is a public report, not our verification. |
|
||||||
|
| OVR Toolkit (1068820) | **Untested**, not owned | Same [public Frame report](https://www.roadtovr.com/valve-steam-frame-review/); no local verification. |
|
||||||
|
| fpsVR (908520) | **Untested**, not owned | [Steam listing](https://store.steampowered.com/app/908520/). No Frame-specific result established in the supplied research. General PC VR reviews do not verify Frame support. |
|
||||||
|
| Desktop+ (1494460) | **Untested**, free | [Developer source](https://github.com/elvissteinjr/DesktopPlus). No verified Frame result in this work. |
|
||||||
|
|
||||||
|
**Documented (supplied research):** the XSOverlay/OVR Toolkit claims are kept as
|
||||||
|
attributed leads. **Verified source check 2026-09-28:** fetching the linked
|
||||||
|
review returned HTTP 200 and the expected review title, but neither utility name
|
||||||
|
appeared in the fetched HTML or extracted text. The claims could not be
|
||||||
|
corroborated from that page; this is not proof of incompatibility. They do not make those apps dependencies or mark them
|
||||||
|
locally verified. No paid utility is auto-acquired. A server-side check blocks
|
||||||
|
installation through the Steam endpoint if the paid utility is absent from the
|
||||||
|
loaded Frame library; an empty/unavailable library fails closed. Desktop+ uses
|
||||||
|
the existing free Steam install flow, which may need a license confirmation in
|
||||||
|
the headset. None is advertised as known-good without local evidence.
|
||||||
|
|
||||||
|
Compatibility reports reuse the existing `compat-db` storage and validation
|
||||||
|
with `package: "steam:<appid>"`, rather than colliding with Android package IDs.
|
||||||
|
The optional list shows the latest `works`, `issues` or `broken` report with its
|
||||||
|
date, build and notes, separately from public sources and ownership. With no
|
||||||
|
report the status stays **untested**. No shared database schema change or
|
||||||
|
production deployment is needed; no reports were published by this work.
|
||||||
|
|
||||||
|
## Validation and review
|
||||||
|
|
||||||
|
166 unit tests and 8 website tests passed locally. The new fake-Frame cases
|
||||||
|
passed in GitHub CI (Docker was unavailable locally). Desktop and phone-width
|
||||||
|
preview checks passed. The two independent review attempts did not produce a
|
||||||
|
verdict; [commands, real exit statuses and limitations](evidence/vr-utilities/review.md).
|
||||||
@@ -75,6 +75,34 @@ All test media were generated by us. No paid content or DRM was involved.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
**Verified end to end, 2026-09-29** (same build; headset unworn): uploads
|
||||||
|
through the HTTP API, the web UI and `scripts/push-vr-video.sh`, all played by
|
||||||
|
the owned player. Our generated test files:
|
||||||
|
|
||||||
|
| Case | Result |
|
||||||
|
|---|---|
|
||||||
|
| H.264 half-SBS 1920×1080 with AAC, theatre | 240/240 frames in 8.09 s; audio stream "Frame Control Media" in PulseAudio |
|
||||||
|
| H.265 half-OU 1920×1080 | 180/180 frames in 6.03 s; red left eye, cyan right |
|
||||||
|
| H.264 full-SBS 3840×1080, `stereo_mode=left_right` only | Detected from metadata; 150/150 frames in 5.03 s |
|
||||||
|
| H.264 1280×720, explicit 2D | 150/150 frames in 5.02 s |
|
||||||
|
| SBS PNG, OU JPEG (theatre) | Correct eye in each capture |
|
||||||
|
| 3,000-Gaussian `.splat` | Rendered in about 5 s, then held until Stop |
|
||||||
|
| 2D file on Auto, `_SBS_OU` file, HEIC, VP9 | Refused with the documented message |
|
||||||
|
| Second Play while one runs | Refused: "Stop the current media…" |
|
||||||
|
|
||||||
|
Stop always left the unit inactive, and no player process remained.
|
||||||
|
|
||||||
|
**Standby (verified):** an unworn Frame turns its displays off a few seconds
|
||||||
|
after it wakes. `SetOverlayRaw` then returns `RequestFailed` (23). The
|
||||||
|
first run's movie died there. The player now drops frames while the headset
|
||||||
|
is in standby, keeps the audio and its clock going, and resumes the picture
|
||||||
|
when the headset wakes. The 8 s movie above dropped 40 frames and finished.
|
||||||
|
Stills and the theatre surround are re-sent after waking. Five minutes
|
||||||
|
without an accepted frame is reported as an error. Headset-view captures
|
||||||
|
taken during standby show a flat dark frame, not our screen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
|
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
|
||||||
`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
|
`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
|
||||||
in the basic appsink probe and the OpenVR probe. We do not ship that route.
|
in the basic appsink probe and the OpenVR probe. We do not ship that route.
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Private-data archives, run under podman unshare on the Frame. Stdlib only."""
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
MAX_BYTES = 20 * 1024 ** 3
|
||||||
|
MAX_FILES = 100000
|
||||||
|
MAX_MANIFEST = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_archive(path, package, instance):
|
||||||
|
names, total, manifest = set(), 0, None
|
||||||
|
with tarfile.open(path, 'r:gz') as archive:
|
||||||
|
for member in archive:
|
||||||
|
name = member.name
|
||||||
|
parts = PurePosixPath(name).parts
|
||||||
|
if (not parts or name.startswith('/') or '..' in parts or
|
||||||
|
name != '/'.join(parts) or name in names or '\\' in name):
|
||||||
|
raise ValueError('unsafe or duplicate archive path')
|
||||||
|
if name == 'data' and not member.isdir():
|
||||||
|
raise ValueError('data root must be a directory')
|
||||||
|
names.add(name)
|
||||||
|
if len(names) > MAX_FILES or not (member.isdir() or member.isfile()):
|
||||||
|
raise ValueError('archive has too many files, links or special files')
|
||||||
|
if member.uid < 0 or member.gid < 0 or member.uid > 65535 or member.gid > 65535:
|
||||||
|
raise ValueError('archive owner outside Android user namespace')
|
||||||
|
total += member.size
|
||||||
|
if total > MAX_BYTES:
|
||||||
|
raise ValueError('archive exceeds 20 GiB')
|
||||||
|
if name == 'manifest.json' and member.isfile() and member.size <= MAX_MANIFEST:
|
||||||
|
manifest = json.load(archive.extractfile(member))
|
||||||
|
elif parts[0] != 'data':
|
||||||
|
raise ValueError('unexpected archive member')
|
||||||
|
if (not isinstance(manifest, dict) or manifest.get('format') != 1 or
|
||||||
|
manifest.get('package') != package or manifest.get('instance') != instance or
|
||||||
|
'data' not in names):
|
||||||
|
raise ValueError('backup does not match this package and instance')
|
||||||
|
return {'files': len(names) - 1, 'bytes': total, 'package': package, 'instance': instance,
|
||||||
|
'skipped_links': manifest.get('skipped_link_count', 0)}
|
||||||
|
|
||||||
|
|
||||||
|
def backup(root, package, instance, output):
|
||||||
|
import io
|
||||||
|
source = root / package
|
||||||
|
if source.is_symlink() or not source.is_dir():
|
||||||
|
raise ValueError('private app data does not exist or is a symlink')
|
||||||
|
count, total, links, skipped = 0, 0, [], 0
|
||||||
|
|
||||||
|
def checked(member):
|
||||||
|
nonlocal count, total, skipped
|
||||||
|
if member.issym(): # never followed or restored; listed in the manifest instead
|
||||||
|
skipped += 1
|
||||||
|
if len(links) < 1000:
|
||||||
|
links.append({'path': member.name[:512], 'target': member.linkname[:256]})
|
||||||
|
return None
|
||||||
|
if member.islnk(): # a second name for a file already archived: store its content again
|
||||||
|
member.type, member.linkname = tarfile.REGTYPE, ''
|
||||||
|
member.size = os.lstat(str(source / member.name[len('data/'):])).st_size
|
||||||
|
count += 1
|
||||||
|
total += member.size
|
||||||
|
if not (member.isdir() or member.isfile()) or count > MAX_FILES or total > MAX_BYTES:
|
||||||
|
raise ValueError('private data contains special files or exceeds backup limits')
|
||||||
|
return member
|
||||||
|
|
||||||
|
with tarfile.open(fileobj=output, mode='w|gz', dereference=False) as archive:
|
||||||
|
archive.add(str(source), arcname='data', filter=checked)
|
||||||
|
# Written last so that it can list what was skipped.
|
||||||
|
manifest = json.dumps({'format': 1, 'package': package, 'instance': instance,
|
||||||
|
'skipped_links': links, 'skipped_link_count': skipped}).encode()
|
||||||
|
member = tarfile.TarInfo('manifest.json')
|
||||||
|
member.size, member.mode = len(manifest), 0o600
|
||||||
|
archive.addfile(member, io.BytesIO(manifest))
|
||||||
|
|
||||||
|
|
||||||
|
def restore(root, package, instance, input_stream):
|
||||||
|
source = root / package
|
||||||
|
if source.is_symlink() or not source.is_dir():
|
||||||
|
raise ValueError('private app data does not exist or is a symlink')
|
||||||
|
with tempfile.TemporaryDirectory(prefix='.frame-restore-', dir=str(root)) as work:
|
||||||
|
work = Path(work)
|
||||||
|
archive_path = work / 'backup.tar.gz'
|
||||||
|
with archive_path.open('wb') as output:
|
||||||
|
size = 0
|
||||||
|
while True:
|
||||||
|
chunk = input_stream.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_BYTES:
|
||||||
|
raise ValueError('compressed backup exceeds 20 GiB')
|
||||||
|
output.write(chunk)
|
||||||
|
result = inspect_archive(archive_path, package, instance)
|
||||||
|
stage = work / 'stage'
|
||||||
|
stage.mkdir(mode=0o700)
|
||||||
|
with tarfile.open(archive_path, 'r:gz') as archive:
|
||||||
|
directories = []
|
||||||
|
for member in archive:
|
||||||
|
if member.name == 'manifest.json':
|
||||||
|
continue
|
||||||
|
target = stage / member.name
|
||||||
|
if member.isdir():
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
directories.append((target, member))
|
||||||
|
else:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with archive.extractfile(member) as src, target.open('xb') as dst:
|
||||||
|
shutil.copyfileobj(src, dst, 1024 * 1024)
|
||||||
|
apply_metadata(target, member)
|
||||||
|
for target, member in reversed(directories):
|
||||||
|
apply_metadata(target, member)
|
||||||
|
with package_lock(root, package): # another restore of this package must not delete our copy
|
||||||
|
previous = root / ('.' + package + '.before-restore-' + str(time.time_ns()))
|
||||||
|
source.rename(previous)
|
||||||
|
try:
|
||||||
|
(stage / 'data').rename(source)
|
||||||
|
except BaseException:
|
||||||
|
previous.rename(source)
|
||||||
|
raise
|
||||||
|
# Keep only the newest pre-restore copy of this package's data.
|
||||||
|
for old in root.glob('.' + package + '.before-restore-*'):
|
||||||
|
if old != previous and not old.is_symlink():
|
||||||
|
shutil.rmtree(str(old), ignore_errors=True)
|
||||||
|
result['previous'] = str(previous)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def package_lock(root, package):
|
||||||
|
import fcntl
|
||||||
|
fd = os.open(str(root / ('.' + package + '.restore.lock')), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||||
|
try:
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
os.close(fd) # releases the lock
|
||||||
|
|
||||||
|
|
||||||
|
def apply_metadata(path, member):
|
||||||
|
os.chown(str(path), member.uid, member.gid)
|
||||||
|
os.chmod(str(path), member.mode & 0o777)
|
||||||
|
os.utime(str(path), (member.mtime, member.mtime))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
action, package, instance = sys.argv[1:]
|
||||||
|
instance = int(instance)
|
||||||
|
root = Path.home() / '.local/share/Steam/steamapps/compatdata' / str(instance) / 'internal'
|
||||||
|
if root.is_symlink() or root.resolve() != root.absolute():
|
||||||
|
raise ValueError('private-data directory traverses a symlink')
|
||||||
|
if action == 'backup':
|
||||||
|
backup(root, package, instance, sys.stdout.buffer)
|
||||||
|
elif action == 'restore':
|
||||||
|
print(json.dumps(restore(root, package, instance, sys.stdin.buffer)))
|
||||||
|
else:
|
||||||
|
raise ValueError('unknown app-data action')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (OSError, ValueError, tarfile.TarError) as error:
|
||||||
|
sys.exit(str(error))
|
||||||
@@ -19,6 +19,25 @@ done
|
|||||||
|
|
||||||
# A number that isn't a real Steam app; it names this app's Lepton context.
|
# A number that isn't a real Steam app; it names this app's Lepton context.
|
||||||
export SteamAppId="$(cat "$DIR/instance.id")"
|
export SteamAppId="$(cat "$DIR/instance.id")"
|
||||||
|
[[ "$SteamAppId" =~ ^[0-9]+$ ]] || { echo "invalid instance.id" >&2; exit 1; }
|
||||||
|
# Keep the stable Lepton context, but identify the Android VR client as its
|
||||||
|
# actual Steam shortcut. Lepton applies LEPTON_ENV_* after its own passthrough.
|
||||||
|
if [[ -f "$DIR/shortcut.id" ]]; then
|
||||||
|
shortcut="$(cat "$DIR/shortcut.id")"
|
||||||
|
[[ "$shortcut" =~ ^[0-9]+$ ]] || { echo "invalid shortcut.id" >&2; exit 1; }
|
||||||
|
export LEPTON_ENV_SteamAppId="$shortcut"
|
||||||
|
fi
|
||||||
|
exec 9>"$DIR/launch.lock"
|
||||||
|
flock -n 9 || { echo "Android app is already running" >&2; exit 1; }
|
||||||
|
CONTAINER="lepton-steamlaunch-$SteamAppId"
|
||||||
|
# Holding the lock means no launcher owns a running container: it was orphaned
|
||||||
|
# (this script SIGKILLed), so stop it rather than refuse every later Play. The
|
||||||
|
# name is this app's alone. A Lepton host process whose launcher was killed
|
||||||
|
# before it made the container may linger briefly; nothing else is killed.
|
||||||
|
if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then
|
||||||
|
echo "Stopping orphaned $CONTAINER" >&2
|
||||||
|
podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
export STEAM_COMPAT_INSTALL_PATH="$DIR"
|
export STEAM_COMPAT_INSTALL_PATH="$DIR"
|
||||||
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
|
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
|
||||||
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
|
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
|
||||||
@@ -29,4 +48,29 @@ mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
|
|||||||
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
|
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
|
||||||
# and SSH don't have; give it its own session instead.
|
# and SSH don't have; give it its own session instead.
|
||||||
export IS_PARENT=true
|
export IS_PARENT=true
|
||||||
exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk"
|
# Keep this shell in Steam's process tree; setsid alone has no container cleanup.
|
||||||
|
child=""
|
||||||
|
cleanup() {
|
||||||
|
trap '' TERM INT HUP
|
||||||
|
if [[ -n "$child" ]]; then
|
||||||
|
kill -TERM -- "-$child" 2>/dev/null || true
|
||||||
|
kill -TERM "$child" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true
|
||||||
|
if [[ -n "$child" ]]; then
|
||||||
|
kill -KILL -- "-$child" 2>/dev/null || true
|
||||||
|
kill -KILL "$child" 2>/dev/null || true
|
||||||
|
wait "$child" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
# 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held.
|
||||||
|
setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- &
|
||||||
|
child=$!
|
||||||
|
rc=0
|
||||||
|
wait "$child" || rc=$?
|
||||||
|
child=""
|
||||||
|
exit "$rc"
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Runs in Steam's Chromium context: identical fonts/rendering from every host OS.
|
||||||
|
async function renderLibraryArtwork(input) {
|
||||||
|
const sizes = {grid:[600,900], wide:[920,430], hero:[3840,1240], logo:[1280,480], icon:[256,256]};
|
||||||
|
const label = String(input.label || 'Untitled').trim().slice(0,180);
|
||||||
|
const font = '"Motiva Sans", "Noto Sans", Arial, sans-serif';
|
||||||
|
await document.fonts.load(`800 120px ${font}`, label);
|
||||||
|
const images = {}, warnings = [];
|
||||||
|
for (const [slot, item] of Object.entries(input.images || {})) {
|
||||||
|
try {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = `data:image/${item[0]};base64,${item[1]}`;
|
||||||
|
await img.decode();
|
||||||
|
if (!img.width || !img.height || img.width*img.height > 16777216) throw Error('dimensions');
|
||||||
|
images[slot] = img;
|
||||||
|
} catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); }
|
||||||
|
}
|
||||||
|
let icon = images.icon;
|
||||||
|
if (icon) {
|
||||||
|
// Remove only a near-black matte connected to the outside of an opaque icon.
|
||||||
|
const cut=document.createElement('canvas');cut.width=icon.width;cut.height=icon.height;
|
||||||
|
const c=cut.getContext('2d');c.drawImage(icon,0,0);
|
||||||
|
const pixels=c.getImageData(0,0,cut.width,cut.height), d=pixels.data, w=cut.width,h=cut.height;
|
||||||
|
const corners=[0,w-1,(h-1)*w,h*w-1];
|
||||||
|
if(corners.every(i=>d[i*4+3]>250 && Math.max(d[i*4],d[i*4+1],d[i*4+2])<24)) {
|
||||||
|
const seen=new Uint8Array(w*h), queue=corners.slice();
|
||||||
|
for(let q=0;q<queue.length;q++) {
|
||||||
|
const i=queue[q];if(seen[i])continue;seen[i]=1;
|
||||||
|
if(Math.max(d[i*4],d[i*4+1],d[i*4+2])>24)continue;
|
||||||
|
d[i*4+3]=0;
|
||||||
|
if(i%w)queue.push(i-1);if(i%w<w-1)queue.push(i+1);
|
||||||
|
if(i>=w)queue.push(i-w);if(i<w*(h-1))queue.push(i+w);
|
||||||
|
}
|
||||||
|
c.putImageData(pixels,0,0);icon=cut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Quantized, saturated dominant colors avoid white/black icon margins.
|
||||||
|
let colors = [[48,91,137], [24,36,63]];
|
||||||
|
if (icon) {
|
||||||
|
const sample = document.createElement('canvas'); sample.width=48; sample.height=48;
|
||||||
|
const s=sample.getContext('2d'); s.drawImage(icon,0,0,48,48);
|
||||||
|
const data=s.getImageData(0,0,48,48).data, bins=new Map();
|
||||||
|
for (let i=0;i<data.length;i+=4) {
|
||||||
|
const rgb=[data[i],data[i+1],data[i+2]], hi=Math.max(...rgb), lo=Math.min(...rgb);
|
||||||
|
if (data[i+3]<150 || hi<45 || lo>220 || hi-lo<25) continue;
|
||||||
|
const key=rgb.map(v=>Math.round(v/32)*32).join(',');
|
||||||
|
bins.set(key,(bins.get(key)||0)+1);
|
||||||
|
}
|
||||||
|
const ranked=[...bins].sort((a,b)=>b[1]-a[1]);
|
||||||
|
if (ranked.length) {
|
||||||
|
colors[0]=ranked[0][0].split(',').map(Number);
|
||||||
|
colors[1]=(ranked.find(([key])=>key.split(',').reduce((n,v,i)=>n+Math.abs(Number(v)-colors[0][i]),0)>170)||ranked[0])[0].split(',').map(Number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Preserve hue while lifting muted icon colors into a richer background palette.
|
||||||
|
colors=colors.map(c=>{const low=Math.min(...c),range=Math.max(...c)-low||1;
|
||||||
|
return c.map(v=>45+(v-low)/range*165);});
|
||||||
|
const rgb=(c,a=1)=>`rgba(${c.map(v=>Math.min(255,Math.round(v))).join(',')},${a})`;
|
||||||
|
function image(ctx,img,x,y,w,h,cover=false) {
|
||||||
|
const scale=cover?Math.max(w/img.width,h/img.height):Math.min(w/img.width,h/img.height);
|
||||||
|
const dw=img.width*scale,dh=img.height*scale;
|
||||||
|
ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip();
|
||||||
|
ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); ctx.restore();
|
||||||
|
}
|
||||||
|
function title(ctx,w,h,top,bottom,maxSize) {
|
||||||
|
let lines=[],size=maxSize;
|
||||||
|
const maxWidth=w*.84;
|
||||||
|
for (;size>=18;size-=2) {
|
||||||
|
ctx.font=`800 ${size}px ${font}`;
|
||||||
|
lines=[]; let line='';
|
||||||
|
for (const word of label.split(/\s+/)) {
|
||||||
|
const next=line?line+' '+word:word;
|
||||||
|
if (line && ctx.measureText(next).width>maxWidth) {lines.push(line);line=word;} else line=next;
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
if (lines.length*size*1.08<=bottom-top && lines.every(l=>ctx.measureText(l).width<=maxWidth)) break;
|
||||||
|
}
|
||||||
|
if(lines.length===2) {
|
||||||
|
const words=lines[0].split(' ');
|
||||||
|
if(words.length>1) {
|
||||||
|
const first=words.slice(0,-1).join(' '), second=words.slice(-1)[0]+' '+lines[1];
|
||||||
|
if(ctx.measureText(second).width<=maxWidth &&
|
||||||
|
Math.abs(ctx.measureText(first).width-ctx.measureText(second).width)<
|
||||||
|
Math.abs(ctx.measureText(lines[0]).width-ctx.measureText(lines[1]).width)) lines=[first,second];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A long unbroken label is still fitted, including scripts without spaces.
|
||||||
|
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillStyle='#fff';
|
||||||
|
ctx.shadowColor='rgba(0,0,0,.45)'; ctx.shadowBlur=size*.28; ctx.shadowOffsetY=size*.06;
|
||||||
|
let y=top+(bottom-top-lines.length*size*1.08)/2+size*.54;
|
||||||
|
for (const line of lines) {ctx.fillText(line,w/2,y,maxWidth); y+=size*1.08;}
|
||||||
|
ctx.shadowBlur=0; ctx.shadowOffsetY=0;
|
||||||
|
}
|
||||||
|
const result={};
|
||||||
|
for (const [slot,[w,h]] of Object.entries(sizes)) {
|
||||||
|
const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h;
|
||||||
|
const ctx=canvas.getContext('2d'); ctx.imageSmoothingQuality='high';
|
||||||
|
const direct=images[slot];
|
||||||
|
const feature=images.feature_graphic||images.banner;
|
||||||
|
const scene=direct || ((slot==='hero'||slot==='wide') && (feature||images.screenshot));
|
||||||
|
if (scene) {
|
||||||
|
if (slot==='logo'||slot==='icon') image(ctx,scene,0,0,w,h);
|
||||||
|
else image(ctx,scene,0,0,w,h,true);
|
||||||
|
} else if (slot==='logo') {
|
||||||
|
title(ctx,w,h,h*.08,h*.92,150);
|
||||||
|
} else {
|
||||||
|
const gradient=ctx.createLinearGradient(0,0,w,h);
|
||||||
|
gradient.addColorStop(0,rgb(colors[0].map(v=>v*.68)));
|
||||||
|
gradient.addColorStop(.6,rgb(colors[1].map(v=>v*.32)));
|
||||||
|
gradient.addColorStop(1,'#080c16'); ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h);
|
||||||
|
if (icon) {
|
||||||
|
ctx.save();ctx.globalAlpha=.16;ctx.filter=`blur(${Math.round(w*.055)}px) saturate(1.4)`;
|
||||||
|
image(ctx,icon,-w*.15,-h*.15,w*1.3,h*1.3,true);ctx.restore();
|
||||||
|
}
|
||||||
|
const glow=ctx.createRadialGradient(w*.5,h*.32,0,w*.5,h*.32,w*.8);
|
||||||
|
glow.addColorStop(0,rgb(colors[0],.27));glow.addColorStop(1,rgb(colors[1],0));
|
||||||
|
ctx.fillStyle=glow;ctx.fillRect(0,0,w,h);
|
||||||
|
const vignette=ctx.createLinearGradient(0,h*.25,0,h);
|
||||||
|
vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.56)');
|
||||||
|
ctx.fillStyle=vignette;ctx.fillRect(0,0,w,h);
|
||||||
|
const box=slot==='grid'?[w*.12,h*.14,w*.76,w*.76]:
|
||||||
|
slot==='wide'?[w*.36,h*.06,w*.28,h*.59]:
|
||||||
|
slot==='hero'?[w*.365,h*.12,w*.27,h*.78]:[w*.08,h*.08,w*.84,h*.84];
|
||||||
|
if (icon) {
|
||||||
|
ctx.save();ctx.shadowColor='rgba(0,0,0,.65)';ctx.shadowBlur=Math.min(w,h)*.055;
|
||||||
|
ctx.shadowOffsetY=Math.min(w,h)*.022;
|
||||||
|
// Opaque square icons read as deliberate app tiles, not pasted rectangles.
|
||||||
|
const [x,y,bw,bh]=box, side=Math.min(bw,bh);
|
||||||
|
if(slot!=='hero' && icon===images.icon) {
|
||||||
|
ctx.beginPath();ctx.roundRect(x+(bw-side)/2,y+(bh-side)/2,side,side,side*.14);ctx.clip();
|
||||||
|
}
|
||||||
|
image(ctx,icon,...box);ctx.restore();
|
||||||
|
} else if (slot !== 'hero') {
|
||||||
|
// A typographic monogram when the APK contains no usable image.
|
||||||
|
ctx.font=`800 ${Math.min(w,h)*.48}px ${font}`;ctx.fillStyle='rgba(255,255,255,.94)';
|
||||||
|
ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText([...label][0]||'A',w/2,h*.38);
|
||||||
|
}
|
||||||
|
if (slot==='grid') title(ctx,w,h,h*.7,h*.93,66);
|
||||||
|
if (slot==='wide') title(ctx,w,h,h*.69,h*.92,52);
|
||||||
|
// Hero intentionally has no title: Steam overlays the transparent logo.
|
||||||
|
}
|
||||||
|
// Photos as PNG can pass Steam's 12 MiB limit at hero size; the logo keeps its transparency.
|
||||||
|
const jpeg=scene && slot!=='logo' && slot!=='icon';
|
||||||
|
result[slot]=[jpeg?'jpg':'png', canvas.toDataURL(jpeg?'image/jpeg':'image/png',.9).split(',')[1]];
|
||||||
|
}
|
||||||
|
return {images:result,warnings,font};
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
|
|||||||
|
|
||||||
steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id
|
steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id
|
||||||
steam_shortcuts.py list -> JSON [{appid, name, exe}]
|
steam_shortcuts.py list -> JSON [{appid, name, exe}]
|
||||||
|
steam_shortcuts.py configure APPID NAME EXE START_DIR ICON VR ARTWORK_JSON
|
||||||
|
steam_shortcuts.py stop APPID
|
||||||
steam_shortcuts.py remove APPID
|
steam_shortcuts.py remove APPID
|
||||||
"""
|
"""
|
||||||
import base64, json, os, socket, struct, sys, urllib.request
|
import base64, glob, json, os, re, socket, struct, sys, urllib.request
|
||||||
|
|
||||||
DEVTOOLS = 'http://127.0.0.1:8080/json'
|
DEVTOOLS = 'http://127.0.0.1:8080/json'
|
||||||
|
|
||||||
@@ -22,10 +24,10 @@ def target_ws():
|
|||||||
class WS:
|
class WS:
|
||||||
"""Just enough RFC 6455 for one CDP request/response on loopback."""
|
"""Just enough RFC 6455 for one CDP request/response on loopback."""
|
||||||
|
|
||||||
def __init__(self, url):
|
def __init__(self, url, timeout=20):
|
||||||
host_port, path = url[len('ws://'):].split('/', 1)
|
host_port, path = url[len('ws://'):].split('/', 1)
|
||||||
host, port = host_port.split(':')
|
host, port = host_port.split(':')
|
||||||
self.s = socket.create_connection((host, int(port)), timeout=20)
|
self.s = socket.create_connection((host, int(port)), timeout=timeout)
|
||||||
key = base64.b64encode(os.urandom(16)).decode()
|
key = base64.b64encode(os.urandom(16)).decode()
|
||||||
self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n'
|
self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n'
|
||||||
f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n'
|
f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n'
|
||||||
@@ -69,8 +71,8 @@ class WS:
|
|||||||
return msg.decode()
|
return msg.decode()
|
||||||
|
|
||||||
|
|
||||||
def evaluate(js):
|
def evaluate(js, timeout=20):
|
||||||
ws = WS(target_ws())
|
ws = WS(target_ws(), timeout)
|
||||||
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
|
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
|
||||||
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
|
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
|
||||||
while True:
|
while True:
|
||||||
@@ -83,6 +85,206 @@ def evaluate(js):
|
|||||||
return res.get('result', {}).get('value')
|
return res.get('result', {}).get('value')
|
||||||
|
|
||||||
|
|
||||||
|
# Steam's ELibraryAssetType (Capsule, Hero, Logo, Header, Icon).
|
||||||
|
ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4}
|
||||||
|
|
||||||
|
|
||||||
|
def collections_js(appid, wanted=()):
|
||||||
|
wanted = list(wanted)
|
||||||
|
return f'''async function syncCollections() {{
|
||||||
|
const wanted = {json.dumps(wanted)};
|
||||||
|
if (typeof collectionStore === "undefined" ||
|
||||||
|
typeof collectionStore.GetUserCollectionsByName !== "function" ||
|
||||||
|
typeof collectionStore.NewUnsavedCollection !== "function" ||
|
||||||
|
typeof collectionStore.SaveCollection !== "function")
|
||||||
|
return ["Steam collections API unavailable"];
|
||||||
|
const app = {{appid: {appid}}};
|
||||||
|
const warnings = [];
|
||||||
|
for (const name of ["Android", "Android VR", "Sideloaded"]) {{
|
||||||
|
const matches = collectionStore.GetUserCollectionsByName(name);
|
||||||
|
let collection = matches.find(c => !c.bIsDynamic && c.bAllowsDragAndDrop);
|
||||||
|
if (wanted.includes(name)) {{
|
||||||
|
if (!collection && matches.length) {{
|
||||||
|
warnings.push(name + " is an existing dynamic or read-only collection");
|
||||||
|
continue;
|
||||||
|
}}
|
||||||
|
if (!collection) {{
|
||||||
|
collection = collectionStore.NewUnsavedCollection(name, undefined, [app]);
|
||||||
|
}} else {{
|
||||||
|
collection.AsDragDropCollection().AddApps([app]);
|
||||||
|
}}
|
||||||
|
await collectionStore.SaveCollection(collection);
|
||||||
|
}} else if (collection) {{
|
||||||
|
collection.AsDragDropCollection().RemoveApps([app]);
|
||||||
|
await collectionStore.SaveCollection(collection);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
return warnings;
|
||||||
|
}}'''
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def notes_js(name, details):
|
||||||
|
filename = 'notes_shortcut_' + re.sub(r'[!-/:-@ \[\\\]\^`]', '_', name.strip())
|
||||||
|
content = '\n'.join(str(details[k]) for k in ('package', 'version', 'source') if details.get(k))
|
||||||
|
return f'''if (SteamClient.GameNotes && typeof SteamClient.GameNotes.GetNotes === "function" &&
|
||||||
|
typeof SteamClient.GameNotes.SaveNotes === "function") {{
|
||||||
|
try {{
|
||||||
|
const file = {json.dumps(filename)};
|
||||||
|
const previous = await SteamClient.GameNotes.GetNotes(file, file + "_images/");
|
||||||
|
if (previous.result !== 1 && previous.result !== 9) throw Error("read " + previous.result);
|
||||||
|
const data = previous.result === 1 ? JSON.parse(previous.notes) : {{notes: [], shortcut_name: {json.dumps(name)}}};
|
||||||
|
if (!Array.isArray(data.notes)) throw Error("unexpected notes format");
|
||||||
|
const id = "frame-control-library", now = Math.floor(Date.now()/1000);
|
||||||
|
const old = data.notes.find(n => n.id === id);
|
||||||
|
const note = {{id, shortcut_name: {json.dumps(name)}, title: "Installation details",
|
||||||
|
content: {json.dumps(content)}, ordinal: old ? old.ordinal : data.notes.length,
|
||||||
|
time_created: old ? old.time_created : now, time_modified: now}};
|
||||||
|
data.notes = data.notes.filter(n => n.id !== id).concat([note]);
|
||||||
|
const result = await SteamClient.GameNotes.SaveNotes(file, JSON.stringify(data));
|
||||||
|
if (result !== 1) throw Error("save " + result);
|
||||||
|
}} catch (e) {{ warnings.push("Steam notes: " + String(e)); }}
|
||||||
|
}}'''
|
||||||
|
|
||||||
|
|
||||||
|
MAX_ART = 12 * 1024 * 1024 # Steam's custom artwork limit per slot
|
||||||
|
|
||||||
|
|
||||||
|
def render(plan):
|
||||||
|
with open(plan) as f:
|
||||||
|
source = json.load(f)
|
||||||
|
images = {}
|
||||||
|
for slot, path in source['images'].items():
|
||||||
|
ext = os.path.splitext(path)[1][1:]
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
data = f.read(MAX_ART + 1)
|
||||||
|
if len(data) > MAX_ART:
|
||||||
|
raise ValueError('source artwork too large')
|
||||||
|
images[slot] = [ext, base64.b64encode(data).decode()]
|
||||||
|
renderer = globals().get('ART_RENDERER')
|
||||||
|
if renderer is None:
|
||||||
|
with open(os.path.join(os.path.dirname(__file__), 'library_artwork.js')) as f:
|
||||||
|
renderer = f.read()
|
||||||
|
try:
|
||||||
|
return _render(plan, renderer, source['label'], images)
|
||||||
|
except (ValueError, OSError, EOFError, SystemExit) as e:
|
||||||
|
# Generated art from the icon alone always fits; a photo that didn't must not fail the install.
|
||||||
|
result = _render(plan, renderer, source['label'], {k: v for k, v in images.items() if k == 'icon'})
|
||||||
|
result['warnings'].insert(0, 'Source artwork could not be rendered (' + str(e)[:120] + '); generated art used')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _render(plan, renderer, label, images):
|
||||||
|
# A 4K photo takes seconds to decode and encode on the Frame; allow well beyond that.
|
||||||
|
result = evaluate(renderer + '\nrenderLibraryArtwork(' + json.dumps({'label': label, 'images': images}) + ')',
|
||||||
|
timeout=75)
|
||||||
|
if not isinstance(result, dict) or set(result.get('images', {})) != set(ASSETS):
|
||||||
|
raise ValueError('incomplete artwork render')
|
||||||
|
paths = {}
|
||||||
|
for slot, (ext, encoded) in result['images'].items():
|
||||||
|
data = base64.b64decode(encoded, validate=True)
|
||||||
|
signature = {'png': b'\x89PNG\r\n\x1a\n', 'jpg': b'\xff\xd8\xff'}.get(ext)
|
||||||
|
if not signature or not data.startswith(signature) or len(data) > MAX_ART:
|
||||||
|
raise ValueError(slot + ' render is ' + str(len(data)) + ' bytes of ' + str(ext))
|
||||||
|
paths[slot] = os.path.join(os.path.dirname(plan), slot + '.' + ext)
|
||||||
|
with open(paths[slot] + '.tmp', 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
for slot, path in paths.items():
|
||||||
|
os.replace(path + '.tmp', path)
|
||||||
|
for stale in ('png', 'jpg'):
|
||||||
|
other = os.path.join(os.path.dirname(plan), slot + '.' + stale)
|
||||||
|
if other != path and os.path.exists(other):
|
||||||
|
os.remove(other)
|
||||||
|
return {'paths': paths, 'warnings': list(result.get('warnings', []))}
|
||||||
|
|
||||||
|
|
||||||
|
# Steam's own file for each custom-art type in userdata/*/config/grid/.
|
||||||
|
GRID_FILES = {0: 'p', 1: '_hero', 2: '_logo', 3: ''}
|
||||||
|
|
||||||
|
|
||||||
|
def custom_art(appid):
|
||||||
|
"""The custom-art types this shortcut already has in Steam, for any local user."""
|
||||||
|
found = set()
|
||||||
|
for kind, suffix in GRID_FILES.items():
|
||||||
|
pattern = os.path.expanduser(f'~/.local/share/Steam/userdata/*/config/grid/{int(appid)}{suffix}.*')
|
||||||
|
if any(os.path.splitext(p)[1].lower() in ('.png', '.jpg', '.jpeg') for p in glob.glob(pattern)):
|
||||||
|
found.add(kind)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
|
||||||
|
"""options: category, details, fill_only (only empty slots and a missing icon; name and flags untouched)."""
|
||||||
|
options = options or {}
|
||||||
|
category = options.get('category', 'Android')
|
||||||
|
fill = bool(options.get('fill_only'))
|
||||||
|
existing = custom_art(appid) if fill else set()
|
||||||
|
if set(artwork) != set(ASSETS):
|
||||||
|
raise ValueError('all five Steam artwork slots are required')
|
||||||
|
images = []
|
||||||
|
for slot, path in artwork.items():
|
||||||
|
if slot not in ASSETS:
|
||||||
|
raise ValueError('unknown artwork slot')
|
||||||
|
ext = os.path.splitext(path)[1][1:]
|
||||||
|
if ext not in ('png', 'jpg'):
|
||||||
|
raise ValueError('artwork must be PNG or JPEG')
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
data = f.read(MAX_ART + 1)
|
||||||
|
if len(data) > MAX_ART:
|
||||||
|
raise ValueError('artwork is too large')
|
||||||
|
# Frame's custom-art API maps type 4 to Header; use SetShortcutIcon.
|
||||||
|
if slot != 'icon' and ASSETS[slot] not in existing:
|
||||||
|
images.append([ASSETS[slot], ext, base64.b64encode(data).decode()])
|
||||||
|
return evaluate(f'''(async () => {{
|
||||||
|
const id = {int(appid)}, warnings = [], fill = {json.dumps(fill)};
|
||||||
|
const overview = appStore.GetAppOverviewByAppID(id);
|
||||||
|
if (!fill) {{
|
||||||
|
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
|
||||||
|
if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)});
|
||||||
|
if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
|
||||||
|
if (typeof SteamClient.Apps.SetShortcutSortAs === "function")
|
||||||
|
SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)});
|
||||||
|
}}
|
||||||
|
if (!fill || !(overview && overview.icon_data)) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
|
||||||
|
// null (devkit titles) or filling gaps: leave the VR flag as Steam has it.
|
||||||
|
if ({json.dumps(vr)} !== null && !fill) {{
|
||||||
|
if (typeof SteamClient.Apps.SetShortcutIsVR === "function")
|
||||||
|
SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)});
|
||||||
|
else warnings.push("Steam VR shortcut flag API unavailable");
|
||||||
|
}}
|
||||||
|
if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{
|
||||||
|
for (const [type, ext, data] of {json.dumps(images)}) {{
|
||||||
|
// Steam keeps a slot's PNG and JPEG side by side; clear it so a stale one can't win.
|
||||||
|
if (!fill && typeof SteamClient.Apps.ClearCustomArtworkForApp === "function")
|
||||||
|
try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}}
|
||||||
|
await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type);
|
||||||
|
}}
|
||||||
|
}} else throw new Error("Steam artwork API unavailable; installation is incomplete");
|
||||||
|
{collections_js(int(appid), [category] + (['Android VR'] if vr and category == 'Android' else []))}
|
||||||
|
try {{ warnings.push(...await syncCollections()); }}
|
||||||
|
catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
|
||||||
|
{notes_js(name, options.get('details', {}))}
|
||||||
|
return {{warnings}};
|
||||||
|
}})()''', timeout=60)
|
||||||
|
|
||||||
|
|
||||||
|
def remove(appid):
|
||||||
|
# Collections and artwork are tidy-up: only a missing RemoveShortcut may fail the removal.
|
||||||
|
return evaluate(f'''(async () => {{
|
||||||
|
const id = {int(appid)}, warnings = [];
|
||||||
|
{collections_js(int(appid))}
|
||||||
|
try {{ warnings.push(...await syncCollections()); }}
|
||||||
|
catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
|
||||||
|
if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{
|
||||||
|
for (const type of [0, 1, 2, 3]) {{
|
||||||
|
try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }}
|
||||||
|
catch (e) {{ warnings.push("Steam artwork " + type + ": " + String(e)); }}
|
||||||
|
}}
|
||||||
|
}} else warnings.push("Steam artwork removal API unavailable");
|
||||||
|
SteamClient.Apps.RemoveShortcut(id);
|
||||||
|
return {{warnings}};
|
||||||
|
}})()''')
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
cmd, args = sys.argv[1], sys.argv[2:]
|
cmd, args = sys.argv[1], sys.argv[2:]
|
||||||
if cmd == 'add':
|
if cmd == 'add':
|
||||||
@@ -97,12 +299,30 @@ def main():
|
|||||||
}})()'''
|
}})()'''
|
||||||
print(evaluate(js))
|
print(evaluate(js))
|
||||||
elif cmd == 'list':
|
elif cmd == 'list':
|
||||||
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
|
# Overviews carry no exe or devkit id (checked 2026-09-28); app details do, once registered.
|
||||||
.map(a => ({appid: a.appid, name: a.display_name})))()'''
|
js = '''(async () => Promise.all(appStore.allApps.filter(a => a.app_type === 1073741824).map(async a => {
|
||||||
|
let d = typeof appDetailsStore !== "undefined" && appDetailsStore.GetAppDetails(a.appid);
|
||||||
|
if (!d && typeof SteamClient.Apps.RegisterForAppDetails === "function") d = await new Promise(ok => {
|
||||||
|
let reg;
|
||||||
|
const timer = setTimeout(() => { if (reg) reg.unregister(); ok(null); }, 3000);
|
||||||
|
reg = SteamClient.Apps.RegisterForAppDetails(a.appid, x => {
|
||||||
|
clearTimeout(timer); setTimeout(() => reg && reg.unregister()); ok(x); });
|
||||||
|
});
|
||||||
|
return {appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid,
|
||||||
|
exe: d ? d.strShortcutExe || "" : "", start_dir: d ? d.strShortcutStartDir || "" : ""};
|
||||||
|
})))()'''
|
||||||
print(json.dumps(evaluate(js)))
|
print(json.dumps(evaluate(js)))
|
||||||
|
elif cmd == 'render':
|
||||||
|
print(json.dumps(render(args[0])))
|
||||||
|
elif cmd == 'configure':
|
||||||
|
vr = {'1': True, '0': False}.get(args[5]) # '' leaves Steam's VR flag alone
|
||||||
|
print(json.dumps(configure(int(args[0]), *args[1:5], vr, json.loads(args[6]),
|
||||||
|
json.loads(args[7]) if len(args) > 7 else None)))
|
||||||
|
elif cmd == 'stop':
|
||||||
|
evaluate(f'SteamClient.Apps.TerminateApp({json.dumps(str((int(args[0]) << 32) | 0x02000000))}, false)')
|
||||||
|
print('stopping')
|
||||||
elif cmd == 'remove':
|
elif cmd == 'remove':
|
||||||
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
|
print(json.dumps(remove(int(args[0]))))
|
||||||
print('removed')
|
|
||||||
else:
|
else:
|
||||||
sys.exit(__doc__)
|
sys.exit(__doc__)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
The script produces arm64-v8a / android-24, C++17, `-O2`, static libc++, hidden
|
||||||
internal symbols, stripped output, and `-Wl,-z,max-page-size=16384`.
|
internal symbols, stripped output, and `-Wl,-z,max-page-size=16384`.
|
||||||
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so`.
|
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz`,
|
||||||
Its SHA-256 is recorded below with the APK checks.
|
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
|
[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.
|
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
|
-DANDROID_STL=c++_static -DCMAKE_BUILD_TYPE=Release
|
||||||
cmake --build "$here/build-android"
|
cmake --build "$here/build-android"
|
||||||
mkdir -p "$here/prebuilt/arm64-v8a"
|
mkdir -p "$here/prebuilt/arm64-v8a"
|
||||||
cp "$here/build-android/libXrApiLayer_FRAME_compat.so" "$here/prebuilt/arm64-v8a/"
|
# Committed and shipped gzipped: electron-builder's 7-Zip applies its ARM64
|
||||||
shasum -a 256 "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so"
|
# 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"
|
||||||
@@ -18,7 +18,7 @@ import tarfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
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/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||||
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
|
"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.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
|
||||||
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
|
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||||
|
|||||||
@@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args]
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
|
# Takes the lock Frame Control uses to edit ~/.ssh/config (ui/frame_devices.py), so a
|
||||||
|
# running app and this script never write over each other's change.
|
||||||
write_config() {
|
write_config() {
|
||||||
|
local lockfd="" rc
|
||||||
|
zmodload zsh/system 2>/dev/null
|
||||||
|
touch "$CONFIG.frame-control.lock" 2>/dev/null
|
||||||
|
zsystem flock -t 30 -f lockfd "$CONFIG.frame-control.lock" 2>/dev/null || lockfd=""
|
||||||
|
write_config_locked; rc=$?
|
||||||
|
[[ -n "$lockfd" ]] && zsystem flock -u "$lockfd"
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
|
||||||
|
write_config_locked() {
|
||||||
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
|
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
|
||||||
local tmp
|
local tmp new="$CONFIG.frame-control.$$"
|
||||||
tmp=$(mktemp) || return 1
|
tmp=$(mktemp) || return 1
|
||||||
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
|
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
|
||||||
# value it sees per option, so this block must precede any other "Host frame"
|
# value it sees per option, so this block must precede any other "Host frame"
|
||||||
@@ -126,7 +138,8 @@ write_config() {
|
|||||||
print -r -- "Host *"
|
print -r -- "Host *"
|
||||||
print -r -- "$END_MARK"
|
print -r -- "$END_MARK"
|
||||||
cat "$tmp"
|
cat "$tmp"
|
||||||
} > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
|
} > "$new" && chmod 600 "$new" && mv -f "$new" "$CONFIG" \
|
||||||
|
|| { rm -f "$new"; print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,13 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||||
|
# Frame Control passes the headset it has chosen: its address and pinned identity.
|
||||||
|
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
|
||||||
HERE=${0:A:h}
|
HERE=${0:A:h}
|
||||||
cmd=${1:-status}
|
cmd=${1:-status}
|
||||||
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
|
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
|
||||||
|
|
||||||
ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
ssh "${ssh_opts[@]}" -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
||||||
'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py"
|
'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py"
|
||||||
|
|
||||||
# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
|
# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
|
||||||
@@ -27,7 +29,7 @@ ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
|||||||
# written the way Steam's settings page does (steamui module exporting the
|
# written the way Steam's settings page does (steamui module exporting the
|
||||||
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
|
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
|
||||||
# ("Interactive authentication required") but allows one from a user unit.
|
# ("Interactive authentication required") but allows one from a user unit.
|
||||||
ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
|
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
|
||||||
import json, os, subprocess, sys
|
import json, os, subprocess, sys
|
||||||
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
||||||
from frame_steam import Page
|
from frame_steam import Page
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||||
|
# Frame Control passes the headset it has chosen: its address and pinned identity.
|
||||||
|
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
|
||||||
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
|
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
|
||||||
id="" name=""
|
id="" name=""
|
||||||
|
|
||||||
@@ -109,4 +111,4 @@ EOF
|
|||||||
)
|
)
|
||||||
b64=$(print -rn -- "$remote" | base64)
|
b64=$(print -rn -- "$remote" | base64)
|
||||||
|
|
||||||
ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
|
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
|
||||||
@@ -3,23 +3,65 @@
|
|||||||
# (docs/releasing.md). Checks every installer is attached with a SHA-256
|
# (docs/releasing.md). Checks every installer is attached with a SHA-256
|
||||||
# digest first, since the app's updater refuses assets without one, then
|
# digest first, since the app's updater refuses assets without one, then
|
||||||
# attaches update.json, the manifest the updater reads.
|
# attaches update.json, the manifest the updater reads.
|
||||||
# Usage: scripts/publish-release.sh v0.4.0
|
# Usage: scripts/publish-release.sh [--dry-run] v0.4.0
|
||||||
|
#
|
||||||
|
# Everything goes through the REST API (gh api), not `gh release view/edit`:
|
||||||
|
# those look a draft up by its tag, and a draft can show tag_name
|
||||||
|
# "untagged-..." until it's published, so they report "release not found"
|
||||||
|
# (and GraphQL is often rate-limited). A draft's html_url is an untagged-...
|
||||||
|
# link that dies on publishing, so update.json's page is built from the tag.
|
||||||
set -eu
|
set -eu
|
||||||
tag="${1:?usage: $0 vX.Y.Z}"
|
dry=""
|
||||||
|
[ "${1:-}" = "--dry-run" ] && { dry=1; shift; }
|
||||||
|
tag="${1:?usage: $0 [--dry-run] vX.Y.Z}"
|
||||||
repo=saphid/frame-control
|
repo=saphid/frame-control
|
||||||
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
|
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
|
||||||
Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
|
Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
|
||||||
Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
|
Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
|
||||||
|
page="https://github.com/$repo/releases/tag/$tag"
|
||||||
|
|
||||||
info=$(gh release view "$tag" -R "$repo" --json isDraft,isPrerelease,assets)
|
|
||||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json")
|
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json")
|
||||||
[ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)"
|
[ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)"
|
||||||
|
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
|
# Publishing sets tag_name; if the tag didn't exist GitHub would create it on
|
||||||
|
# the default branch, which isn't what was built and tested.
|
||||||
|
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|
||||||
|
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Every release, drafts included, one JSON object per line.
|
||||||
|
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
|
||||||
|
# The release whose tag_name is the tag; failing that, the one untagged-... draft
|
||||||
|
# titled "Frame Control X.Y.Z" (release.yml's title), optionally ": subtitle".
|
||||||
|
python3 - "$tag" "$tmp/releases" > "$tmp/release.json" <<'EOF'
|
||||||
|
import json, re, sys
|
||||||
|
tag, path = sys.argv[1], sys.argv[2]
|
||||||
|
rels = [json.loads(line) for line in open(path) if line.strip()]
|
||||||
|
hits = [r for r in rels if r.get("tag_name") == tag]
|
||||||
|
if not hits:
|
||||||
|
# Exactly this version: "Frame Control 0.4.0", or that followed by ": <subtitle>".
|
||||||
|
# Never "Frame Control 0.4.0-rc.1" or "0.4.00", and only drafts with no real tag.
|
||||||
|
title = re.compile(r"Frame Control " + re.escape(tag.lstrip("v")) + r"(: .*)?", re.S)
|
||||||
|
hits = [r for r in rels if r.get("draft") and str(r.get("tag_name") or "").startswith("untagged-")
|
||||||
|
and title.fullmatch(r.get("name") or "")]
|
||||||
|
if len(hits) != 1:
|
||||||
|
why = "no release" if not hits else "%d releases (ids %s)" % (len(hits), ", ".join(str(r["id"]) for r in hits))
|
||||||
|
sys.exit("found %s for %s; expected one draft" % (why, tag))
|
||||||
|
r = hits[0]
|
||||||
|
if not r.get("draft"):
|
||||||
|
print("note: %s is already published; refreshing update.json and marking it latest" % tag, file=sys.stderr)
|
||||||
|
json.dump(r, sys.stdout)
|
||||||
|
EOF
|
||||||
|
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
|
||||||
|
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
|
||||||
|
|
||||||
missing=""
|
missing=""
|
||||||
for name in $expected; do
|
for name in $expected; do
|
||||||
digest=$(printf '%s' "$info" | python3 -c 'import json,sys
|
digest=$(python3 -c 'import json,sys
|
||||||
d=json.load(sys.stdin); n=sys.argv[1]
|
d=json.load(open(sys.argv[1])); n=sys.argv[2]
|
||||||
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name")
|
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$tmp/release.json" "$name")
|
||||||
case "$digest" in
|
case "$digest" in
|
||||||
sha256:*) echo "ok $name" ;;
|
sha256:*) echo "ok $name" ;;
|
||||||
absent) echo "MISSING $name"; missing=1 ;;
|
absent) echo "MISSING $name"; missing=1 ;;
|
||||||
@@ -30,16 +72,32 @@ done
|
|||||||
|
|
||||||
# update.json: what running copies read (app/updater.js), from github.com's
|
# update.json: what running copies read (app/updater.js), from github.com's
|
||||||
# latest/download link rather than the rate-limited REST API.
|
# latest/download link rather than the rate-limited REST API.
|
||||||
tmp=$(mktemp -d)
|
python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF'
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
import json, sys
|
||||||
gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys
|
d = json.load(open(sys.argv[1]))
|
||||||
d=json.load(sys.stdin)
|
tag, page, names = sys.argv[2], sys.argv[3], set(sys.argv[4].split())
|
||||||
names=set(sys.argv[1].split())
|
print(json.dumps({"version": tag.lstrip("v"), "page": page, "notes": (d.get("body") or "")[:4000],
|
||||||
print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000],
|
|
||||||
"assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]}
|
"assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]}
|
||||||
for a in d["assets"] if a["name"] in names]}, indent=1))' "$expected" > "$tmp/update.json"
|
for a in d["assets"] if a["name"] in names]}, indent=1))
|
||||||
gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber
|
EOF
|
||||||
|
old=$(python3 -c 'import json,sys
|
||||||
|
d=json.load(open(sys.argv[1]))
|
||||||
|
print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))' "$tmp/release.json")
|
||||||
|
|
||||||
|
if [ -n "$dry" ]; then
|
||||||
|
echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:"
|
||||||
|
cat "$tmp/update.json"
|
||||||
|
echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for asset in $old; do
|
||||||
|
gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null
|
||||||
|
done
|
||||||
|
gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \
|
||||||
|
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
|
||||||
echo "ok update.json"
|
echo "ok update.json"
|
||||||
|
|
||||||
gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest
|
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \
|
||||||
echo "published $tag; running copies will offer it at their next check"
|
-f make_latest=true --jq '"published " + .tag_name + " at " + .html_url'
|
||||||
|
echo "running copies will offer $tag at their next check"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regenerate ui/frame_timezones.py, the time zone -> country table usage statistics use.
|
||||||
|
|
||||||
|
From the IANA tz database's zone.tab (each zone's country) and tzdata.zi (the old
|
||||||
|
names that link to them), as installed on macOS and most Linux systems. It prints the
|
||||||
|
old names left out because their zone is shared by several countries: check any new
|
||||||
|
one, and add it to OLD_SPELLINGS if it's just an old spelling of a zone.tab zone.
|
||||||
|
|
||||||
|
Run: python3 scripts/timezones.py [/usr/share/zoneinfo]
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
src = Path(sys.argv[1] if len(sys.argv) > 1 else '/usr/share/zoneinfo')
|
||||||
|
country = {}
|
||||||
|
for line in (src / 'zone.tab').read_text().splitlines():
|
||||||
|
if line and not line.startswith('#'):
|
||||||
|
cc, _, zone = line.split('\t')[:3]
|
||||||
|
country[zone] = cc
|
||||||
|
links = {}
|
||||||
|
for line in (src / 'tzdata.zi').read_text().splitlines():
|
||||||
|
if line.startswith('L '):
|
||||||
|
_, target, alias = line.split()
|
||||||
|
links[alias] = target
|
||||||
|
def resolve(z):
|
||||||
|
for _ in range(5):
|
||||||
|
z = links.get(z, z)
|
||||||
|
return z
|
||||||
|
|
||||||
|
|
||||||
|
# An old name links to a zone with the same clocks, which can be in another country
|
||||||
|
# (Africa/Asmera, Eritrea, links to Africa/Nairobi), so it only gets a country when every
|
||||||
|
# zone.tab zone sharing that target is in one country.
|
||||||
|
countries_of = {}
|
||||||
|
for zone, cc in country.items():
|
||||||
|
countries_of.setdefault(resolve(zone), set()).add(cc)
|
||||||
|
# Clock rules rather than places (EET links to Athens, WET to Lisbon): no country.
|
||||||
|
GENERIC = {'CET', 'EET', 'MET', 'WET', 'EST', 'MST', 'HST', 'CST6CDT', 'EST5EDT', 'MST7MDT', 'PST8PDT'}
|
||||||
|
# Places that have left zone.tab, so their target's group looks like another country's.
|
||||||
|
PLACES = {'Pacific/Johnston': 'UM'}
|
||||||
|
dropped = []
|
||||||
|
for alias in links:
|
||||||
|
if alias in country or alias in GENERIC or alias.startswith('Etc/'):
|
||||||
|
continue
|
||||||
|
if alias in PLACES:
|
||||||
|
country[alias] = PLACES[alias]
|
||||||
|
continue
|
||||||
|
ccs = countries_of.get(resolve(alias), set())
|
||||||
|
if len(ccs) == 1:
|
||||||
|
country[alias] = next(iter(ccs))
|
||||||
|
else:
|
||||||
|
dropped.append(alias)
|
||||||
|
# Old names for a place in a zone.tab zone whose target is shared, checked by hand.
|
||||||
|
OLD_SPELLINGS = {
|
||||||
|
'Africa/Asmera': 'Africa/Asmara', 'Africa/Timbuktu': 'Africa/Bamako', 'America/Coral_Harbour': 'America/Atikokan',
|
||||||
|
'America/Montreal': 'America/Toronto', 'America/Nipigon': 'America/Toronto', 'America/Thunder_Bay': 'America/Toronto',
|
||||||
|
'Canada/Eastern': 'America/Toronto', 'America/Virgin': 'America/St_Thomas', 'Antarctica/South_Pole': 'Antarctica/McMurdo',
|
||||||
|
'Asia/Rangoon': 'Asia/Yangon', 'Atlantic/Jan_Mayen': 'Arctic/Longyearbyen', 'Europe/Belfast': 'Europe/London',
|
||||||
|
'GB': 'Europe/London', 'GB-Eire': 'Europe/London', 'Iceland': 'Atlantic/Reykjavik', 'NZ': 'Pacific/Auckland',
|
||||||
|
'Pacific/Samoa': 'Pacific/Pago_Pago', 'US/Samoa': 'Pacific/Pago_Pago', 'Singapore': 'Asia/Singapore',
|
||||||
|
'US/Arizona': 'America/Phoenix', 'Pacific/Truk': 'Pacific/Chuuk', 'Pacific/Ponape': 'Pacific/Pohnpei',
|
||||||
|
'Pacific/Yap': 'Pacific/Chuuk'}
|
||||||
|
for alias, zone in OLD_SPELLINGS.items():
|
||||||
|
if alias in links and zone in country:
|
||||||
|
country[alias] = country[zone]
|
||||||
|
dropped.remove(alias)
|
||||||
|
version = (src / '+VERSION').read_text().strip() if (src / '+VERSION').exists() else 'unknown'
|
||||||
|
rows = ''.join(f' {z!r}: {c!r},\n' for z, c in sorted(country.items()))
|
||||||
|
out = Path(__file__).resolve().parent.parent / 'ui' / 'frame_timezones.py'
|
||||||
|
out.write_text(f'''"""Time zone name -> two-letter country, from the IANA tz database {version} (zone.tab, and
|
||||||
|
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
|
||||||
|
Zones that aren't in one country, such as UTC, aren't here."""
|
||||||
|
COUNTRY = {{
|
||||||
|
{rows}}}
|
||||||
|
''')
|
||||||
|
print(f'{len(country)} zones, tz {version} -> {out}')
|
||||||
|
print('no country (shared by several):', ' '.join(sorted(dropped)) or 'none')
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Small APKs built in memory (binary manifest plus resource table), for test_frame_apk
|
||||||
|
and the end-to-end tests. Deliberately doesn't import sandbox: tests/e2e runs in a
|
||||||
|
throwaway container and keeps its real home, which sandbox would move."""
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
|
||||||
|
import frame_apk # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def pool(strings, utf8=False):
|
||||||
|
"""A ResStringPool chunk."""
|
||||||
|
data, offsets = b'', []
|
||||||
|
for s in strings:
|
||||||
|
offsets.append(len(data))
|
||||||
|
if utf8:
|
||||||
|
b = s.encode()
|
||||||
|
data += bytes([len(s), len(b)]) + b + b'\0'
|
||||||
|
else:
|
||||||
|
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
|
||||||
|
data += b'\0' * (-len(data) % 4)
|
||||||
|
start = 28 + 4 * len(strings)
|
||||||
|
body = struct.pack(f'<{len(strings)}I', *offsets) + data
|
||||||
|
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
|
||||||
|
|
||||||
|
|
||||||
|
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
|
||||||
|
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
|
||||||
|
|
||||||
|
package_raw=False drops the package's raw string (as some repackers do);
|
||||||
|
foreign_label adds a non-android `label` attribute after android:label.
|
||||||
|
"""
|
||||||
|
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
|
||||||
|
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
|
||||||
|
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
|
||||||
|
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
|
||||||
|
|
||||||
|
def element(name, attrs):
|
||||||
|
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
|
||||||
|
for aname, raw, dtype, value in attrs:
|
||||||
|
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
|
||||||
|
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
|
||||||
|
|
||||||
|
none = 0xffffffff
|
||||||
|
chunks = (pool(strings) + resmap
|
||||||
|
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
|
||||||
|
(2, none, frame_apk.T_REF, version_ref),
|
||||||
|
(11, none, frame_apk.T_INT_DEC, 210)])
|
||||||
|
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
|
||||||
|
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
|
||||||
|
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
|
||||||
|
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
|
||||||
|
|
||||||
|
|
||||||
|
def resources(values):
|
||||||
|
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
|
||||||
|
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
|
||||||
|
pkg_body = b''
|
||||||
|
for (tid, lang, density), entries in values.items():
|
||||||
|
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
|
||||||
|
count = max(entries) + 1
|
||||||
|
offsets, data = [], b''
|
||||||
|
for i in range(count):
|
||||||
|
if i in entries:
|
||||||
|
offsets.append(len(data))
|
||||||
|
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
|
||||||
|
else:
|
||||||
|
offsets.append(0xffffffff)
|
||||||
|
header = 20 + 64
|
||||||
|
estart = header + 4 * count
|
||||||
|
body = struct.pack(f'<{count}I', *offsets) + data
|
||||||
|
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
|
||||||
|
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
|
||||||
|
pkg = pkg_header + pkg_body
|
||||||
|
table = pool(strings, utf8=True) + pkg
|
||||||
|
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
|
||||||
|
|
||||||
|
|
||||||
|
def apk(files):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w') as z:
|
||||||
|
for name, data in files.items():
|
||||||
|
z.writestr(name, data)
|
||||||
|
return buf.getvalue()
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
// Real Control Center logic with a minimal DOM; no network, browser or headset.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const cc = require('../ui/control-center.js');
|
||||||
|
|
||||||
|
const sample = () => ({time:Date.now()/1000, hostname:'Test Frame', battery:{percent:99,status:'Discharging',watts:0,tempC:33,health:'Good'},
|
||||||
|
power:{type:'C PD [PD_PPS]',watts:18}, disk:{home:{total:256*1073741824,free:128*1073741824}},
|
||||||
|
memory:{total:16*1073741824,available:6*1073741824}, wifi:{ssid:'Test LAN',signal:80},
|
||||||
|
performance:{compositorFps:90,appFps:45,frameMs:11.11,gpuMs:3,cpuPercent:12,gpuMHz:600},
|
||||||
|
volume:{level:.4,muted:false}, os:{version:'0.4.1',build:'test-build'},services:{steamvr:true}, thermalAlerts:[],games:[],flatpaks:[]});
|
||||||
|
function fixture() {
|
||||||
|
const elements = new Map();
|
||||||
|
function element(id) {
|
||||||
|
if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
|
||||||
|
classList:{toggle(){}},querySelectorAll(){return [];},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
|
||||||
|
return elements.get(id);
|
||||||
|
}
|
||||||
|
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
|
||||||
|
const buttons = cc.categories.map(([id])=>{const e=element('category-'+id);e.dataset.ccCategory=id;e.querySelector=()=>element('count-'+id);return e;});
|
||||||
|
const metrics = Object.keys(cc.metrics(null)).map(id=>{const e=element('metric-'+id);e.dataset.ccMetric=id;return e;});
|
||||||
|
const page = element('controlCenter');
|
||||||
|
page.querySelectorAll = selector => selector === '[data-cc-category]' ? buttons : selector === '[data-cc-metric]' ? metrics : selector === '[data-cc-mac-link]' ? [element('macLink')] : [];
|
||||||
|
page.querySelector = selector => selector === '[href="#connection"]' ? element('connectionLink') : cards.find(e=>selector === `[data-cc-card="${e.dataset.ccCard}"]`);
|
||||||
|
return {document:{getElementById:element},element,cards,buttons};
|
||||||
|
}
|
||||||
|
function mounted(options = {}) {
|
||||||
|
const f = fixture(), calls = [];
|
||||||
|
let context = {online:true,generation:1,device:'frame-a'}, refreshes = 0, opened = 0;
|
||||||
|
const ui = cc.mount({document:f.document,platform:'darwin',api:async(...args)=>{calls.push(args);return {message:'Volume updated'};},
|
||||||
|
refresh:async()=>{refreshes++;},getContext:()=>context,openConnection:()=>opened++,...options});
|
||||||
|
return {...f,ui,calls,get context(){return context;},set context(v){context=v;},get refreshes(){return refreshes;},get opened(){return opened;}};
|
||||||
|
}
|
||||||
|
const pageSource = fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
||||||
|
const deferred = () => { let resolve, reject; const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject}; };
|
||||||
|
const flush = async () => { for(let i=0;i<12;i++)await Promise.resolve(); };
|
||||||
|
// Execute the actual shared refresh/deduplication, host callback and mount
|
||||||
|
// bootstrap, including the real Home-to-Control status handoff. Only unrelated
|
||||||
|
// rendering helpers, timers and transport are stubbed.
|
||||||
|
function sharedPage({productionApi=false} = {}) {
|
||||||
|
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
|
||||||
|
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
|
||||||
|
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
|
||||||
|
battery(){},setVolume(){},gb:String,dur:String,esc:String,meter(){},toast(){},
|
||||||
|
runJob(title,id){context.installing.add(id);return job.promise;},
|
||||||
|
api:(path,body,device)=>{
|
||||||
|
if(path==='/api/host')return host.promise;
|
||||||
|
if(path==='/api/status'){const request=deferred();requests.push(request);return request.promise;}
|
||||||
|
writes.push({path,body,device});return Promise.resolve({message:'Volume updated'});
|
||||||
|
},renderPerformance(){},setConn(){},setOnline(ok){vm.runInContext(`online = ${ok}`,context);},log(){},failed(){},
|
||||||
|
watchConnection(){context.watched=true;}};
|
||||||
|
vm.createContext(context);
|
||||||
|
const stateDeclaration=pageSource.match(/let state = null[^\n]*/)[0];
|
||||||
|
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
|
||||||
|
const refreshStart=pageSource.indexOf('let refreshing = null'),refreshEnd=pageSource.indexOf('// ---- our VR HUD',refreshStart);
|
||||||
|
const renderStart=pageSource.indexOf('function render(s) {'),renderEnd=pageSource.indexOf('// ---- view ----',renderStart);
|
||||||
|
vm.runInContext(pageSource.slice(renderStart,renderEnd)+pageSource.slice(refreshStart,refreshEnd),context);
|
||||||
|
if(productionApi) {
|
||||||
|
// Keep api/onConnection/setOnline intact: defer fetch itself, not api(), so
|
||||||
|
// rejection classification and the real switch/reset notifications run.
|
||||||
|
context.fetch=(path,opts)=>{
|
||||||
|
if(path==='/api/host')return host.promise.then(data=>({ok:true,status:200,json:async()=>data}));
|
||||||
|
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
|
||||||
|
};
|
||||||
|
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
|
||||||
|
resetShots(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||||
|
vm.runInContext(`const UI_KEY = 'test-only';
|
||||||
|
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
||||||
|
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
|
||||||
|
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], titlesSeq = 0, page = 'control';`,context);
|
||||||
|
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
|
||||||
|
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
|
||||||
|
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
|
||||||
|
vm.runInContext(pageSource.slice(apiStart,apiEnd)+pageSource.slice(onlineStart,onlineEnd)+pageSource.slice(connectionStart,connectionEnd),context);
|
||||||
|
}
|
||||||
|
const installStart=pageSource.indexOf('function installFlatpak(id) {'),installEnd=pageSource.indexOf('$("fpInstall").onclick',installStart);
|
||||||
|
vm.runInContext(pageSource.slice(installStart,installEnd),context);
|
||||||
|
const hostStart=pageSource.lastIndexOf('api("/api/host").then(h =>'),hostEnd=pageSource.indexOf('</script>',hostStart);
|
||||||
|
vm.runInContext(pageSource.slice(hostStart,hostEnd),context);
|
||||||
|
const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('</script>',bootstrapStart);
|
||||||
|
return {...f,context,requests,writes,host,refresh:()=>vm.runInContext('refresh()',context),
|
||||||
|
switchHeadset:()=>vm.runInContext('onConnection({now:Date.now()/1000,phase:"connected",devices_rev:0,device:{id:"frame-b",name:"Second Frame"}})',context),
|
||||||
|
installFlatpak:()=>vm.runInContext('installFlatpak("test.app")',context),
|
||||||
|
mount(){vm.runInContext(pageSource.slice(bootstrapStart,bootstrapEnd),context);return context.window.frameControlCenter;}};
|
||||||
|
}
|
||||||
|
const response = data => ({ok:true,status:200,json:async()=>data});
|
||||||
|
for(const rejection of ['transport','body'])for(const delayedMount of [false,true]) {
|
||||||
|
test(`production ${rejection} rejection from A preserves B ${delayedMount ? 'before delayed mount' : 'after mount'}`, async t => {
|
||||||
|
const f=sharedPage({productionApi:true});
|
||||||
|
if(!delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
|
||||||
|
const old=f.refresh(),body=deferred();
|
||||||
|
assert.equal(f.requests[0].path,'/api/status');
|
||||||
|
if(rejection==='body') {
|
||||||
|
f.requests[0].resolve({ok:true,status:200,json:()=>body.promise});await flush();
|
||||||
|
}
|
||||||
|
f.switchHeadset();
|
||||||
|
assert.equal(f.requests.length,2,'real connected B callback starts its own status fetch');
|
||||||
|
const next=sample();next.hostname='Second Frame';next.battery.percent=20;next.volume.level=.2;
|
||||||
|
f.requests[1].resolve(response(next));await flush();
|
||||||
|
const receipt=vm.runInContext('stateReceivedAt',f.context);
|
||||||
|
assert.equal(vm.runInContext('stateRead.valid && online && devGen === 2',f.context),true);
|
||||||
|
if(!delayedMount)assert.equal(f.element('metric-battery').textContent,'20%');
|
||||||
|
(rejection==='body' ? body : f.requests[0]).reject(Error('Old A request failed'));await old;
|
||||||
|
assert.equal(vm.runInContext('stateRead.valid',f.context),true,'A must not invalidate B');
|
||||||
|
assert.equal(vm.runInContext('stateRead.error',f.context),null);
|
||||||
|
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
|
||||||
|
if(delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
|
||||||
|
const assertB=()=>{
|
||||||
|
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'20%');
|
||||||
|
assert.equal(f.element('ccVolume').value,20);
|
||||||
|
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,false);
|
||||||
|
assert.doesNotMatch(f.element('ccSampleState').textContent,/No live sample|local server|Old A/);
|
||||||
|
};
|
||||||
|
assertB();vm.runInContext('render(state)',f.context);assertB();
|
||||||
|
assert.equal(f.requests.length,2,'preservation and redraw need no recovery poll');
|
||||||
|
// An ordinary failure on B must still invalidate the current sample.
|
||||||
|
const failed=f.refresh();
|
||||||
|
if(rejection==='body')f.requests[2].resolve({ok:true,status:200,json:async()=>{throw Error('B body failed');}});
|
||||||
|
else f.requests[2].reject(Error('B transport failed'));
|
||||||
|
await failed;
|
||||||
|
assert.equal(vm.runInContext('stateRead.valid',f.context),false);
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||||
|
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||||
|
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('production headset-free API keeps transport errors and responses across a switch', async t => {
|
||||||
|
for(const failure of [false,true]) {
|
||||||
|
const f=sharedPage({productionApi:true}),ui=f.mount();t.after(()=>ui.destroy());
|
||||||
|
const pending=vm.runInContext('api("/api/devices")',f.context);
|
||||||
|
const result=pending.then(value=>({value}),error=>({error}));
|
||||||
|
f.switchHeadset();f.requests[1].resolve(response(sample()));await flush();
|
||||||
|
if(failure)f.requests[0].reject(Error('Device list transport failed'));
|
||||||
|
else f.requests[0].resolve(response({devices:['frame-b']}));
|
||||||
|
const settled=await result;
|
||||||
|
if(failure){assert.equal(settled.error.stale,undefined);assert.match(settled.error.message,/local server isn't running/);}
|
||||||
|
else assert.deepEqual(settled.value,{devices:['frame-b']});
|
||||||
|
assert.equal(vm.runInContext('stateRead.valid',f.context),true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('search finds settings by aliases, all words, category and guidance', () => {
|
||||||
|
const find = (q,category='all')=>cc.catalog.filter(item=>cc.matches(item,q,category)).map(item=>item.id);
|
||||||
|
assert.ok(find('DPI').includes('display'));
|
||||||
|
assert.ok(find('sleep inactivity').includes('power'));
|
||||||
|
assert.ok(find('MOTION SICKNESS').includes('playspace'));
|
||||||
|
assert.ok(find('font','apps').includes('display'));
|
||||||
|
assert.deepEqual(find('font','vr'),[]);
|
||||||
|
assert.deepEqual(find('<script>alert(1)</script>'),[]);
|
||||||
|
assert.ok(find(' ').length > 20);
|
||||||
|
});
|
||||||
|
test('metrics use real fields, preserve zero, and distinguish unavailable values', () => {
|
||||||
|
const m = cc.metrics(sample());
|
||||||
|
assert.equal(m.battery,'99%');
|
||||||
|
assert.equal(m.power,'0 W');
|
||||||
|
assert.match(m.batteryDetail,/not charging/);
|
||||||
|
assert.equal(m.fps,'90'); assert.equal(m.appFps,'45');
|
||||||
|
assert.equal(m.storage,'128 GiB'); assert.equal(m.memory,'10 GiB');
|
||||||
|
assert.equal(m.thermal,'No hot / critical trip reported');
|
||||||
|
assert.equal(cc.metrics(null).fps,'Unavailable');
|
||||||
|
assert.equal(cc.metrics({performance:{appFps:null,gpuMHz:0,cpuPercent:0}}).appFps,'Unavailable');
|
||||||
|
assert.equal(cc.metrics({performance:{cpuPercent:0}}).cpu,'0%');
|
||||||
|
assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%');
|
||||||
|
assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/);
|
||||||
|
});
|
||||||
|
test('audio controller validates before any API call and rejects offline or expired readings', async () => {
|
||||||
|
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[];
|
||||||
|
const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context});
|
||||||
|
for (const change of [{level:-.1},{level:1.1},{level:NaN},{level:Infinity},{level:'0.5'},{level:null},{muted:'false'},{unknown:true},{}]) {
|
||||||
|
await assert.rejects(controller.audio(change),/valid mute state/);
|
||||||
|
}
|
||||||
|
context.online=false;await assert.rejects(controller.audio({level:.5}),/Connect and refresh/);
|
||||||
|
context.online=true;context.fresh=false;await assert.rejects(controller.audio({muted:true}),/Connect and refresh/);
|
||||||
|
assert.equal(calls.length,0);
|
||||||
|
});
|
||||||
|
test('audio uses the captured device, prevents duplicate writes, and ignores switched-headset success', async () => {
|
||||||
|
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},complete,calls=[];
|
||||||
|
const controller=cc.createController({api:(...args)=>{calls.push(args);return new Promise(r=>complete=r);},getContext:()=>context});
|
||||||
|
const pending=controller.audio({level:.7});
|
||||||
|
await assert.rejects(controller.audio({muted:true}),/still in progress/);
|
||||||
|
assert.deepEqual(calls,[['/api/volume',{level:.7},'frame-a']]);
|
||||||
|
context={...context,device:'frame-b',generation:2};complete({message:'Done'});
|
||||||
|
assert.equal(await pending,null);assert.equal(controller.busy,false);
|
||||||
|
});
|
||||||
|
test('controller surfaces real API errors and allows a deliberate retry', async () => {
|
||||||
|
let calls=0;
|
||||||
|
const controller=cc.createController({api:async()=>{if(++calls===1)throw Error('SSH connection lost');return {message:'Done'};},
|
||||||
|
getContext:()=>({online:true,fresh:true,sample:sample(),device:'frame-a',generation:1})});
|
||||||
|
await assert.rejects(controller.audio({muted:true}),/SSH connection lost/);
|
||||||
|
assert.equal(controller.busy,false);
|
||||||
|
assert.deepEqual(await controller.audio({muted:true}),{message:'Done'});
|
||||||
|
});
|
||||||
|
test('mounted controls only write on Apply or Mute, show readback, and reset on switch', async t => {
|
||||||
|
const f=mounted();t.after(()=>f.ui.destroy());
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
f.ui.status(sample());
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||||
|
assert.equal(f.element('ccVolume').value,40);
|
||||||
|
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||||
|
assert.equal(f.calls.length,0);
|
||||||
|
assert.equal(f.element('ccVolumeValue').textContent,'65%');
|
||||||
|
f.ui.status(sample()); // a poll must not overwrite an unapplied value
|
||||||
|
assert.equal(f.element('ccVolume').value,'65');
|
||||||
|
await f.element('ccApplyVolume').onclick();
|
||||||
|
assert.deepEqual(f.calls,[['/api/volume',{level:.65},'frame-a']]);
|
||||||
|
assert.equal(f.refreshes,1);
|
||||||
|
await f.element('ccMute').onclick();
|
||||||
|
assert.deepEqual(f.calls[1],['/api/volume',{muted:true},'frame-a']);
|
||||||
|
f.context={online:null,generation:2,device:'frame-b'};f.ui.reset();
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||||
|
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||||
|
});
|
||||||
|
test('offline/errors clear live readings, disable audio and keep companion navigation usable', async t => {
|
||||||
|
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||||
|
f.context.online=false;f.ui.connection(false);
|
||||||
|
assert.equal(f.element('metric-fps').textContent,'Unavailable');
|
||||||
|
assert.equal(f.element('ccVolume').disabled,true);
|
||||||
|
await f.element('ccMute').onclick();
|
||||||
|
assert.equal(f.calls.length,0);
|
||||||
|
assert.match(f.element('ccAudioResult').textContent,/Connect and refresh/);
|
||||||
|
assert.equal(f.cards.find(c=>c.dataset.ccCard==='privacy').hidden,false);
|
||||||
|
f.ui.unavailable(Error('Local server is unavailable'));
|
||||||
|
assert.match(f.element('ccSampleState').textContent,/Local server is unavailable/);
|
||||||
|
f.context.online=true;f.ui.status(sample());
|
||||||
|
assert.equal(f.element('ccVolume').disabled,false);
|
||||||
|
});
|
||||||
|
test('mounted audio failure is visible and never reports success', async t => {
|
||||||
|
const f=mounted({api:async()=>{throw Error('Permission denied');}});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||||
|
await f.element('ccApplyVolume').onclick();
|
||||||
|
assert.match(f.element('ccAudioResult').textContent,/Couldn’t apply audio: Permission denied/);
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||||
|
assert.equal(f.refreshes,0);
|
||||||
|
});
|
||||||
|
test('audio confirmation depends on fresh readback rather than command acknowledgment', async t => {
|
||||||
|
let expected=.65, mode='confirmed';
|
||||||
|
const f=mounted({refresh:async()=>{
|
||||||
|
if(mode==='offline')return f.ui.unavailable(Error('Headset unreachable'));
|
||||||
|
const reading=sample();reading.volume.level=mode==='different' ? .64 : expected;f.ui.status(reading);
|
||||||
|
}});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||||
|
f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
|
||||||
|
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
|
||||||
|
mode='different';f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
|
||||||
|
assert.match(f.element('ccAudioResult').textContent,/reported output differs: 64%/);
|
||||||
|
mode='offline';await f.element('ccApplyVolume').onclick();
|
||||||
|
assert.match(f.element('ccAudioResult').textContent,/command sent; current output could not be confirmed/);
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
});
|
||||||
|
test('pending audio never overwrites the new headset with old success or error', async t => {
|
||||||
|
let complete;
|
||||||
|
const f=mounted({api:()=>new Promise(resolve=>{complete=resolve;})});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||||
|
const pending=f.element('ccMute').onclick();
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
|
||||||
|
const newSample=sample();newSample.hostname='Second Frame';newSample.volume.muted=true;f.ui.status(newSample);
|
||||||
|
complete({message:'Old headset muted'});await pending;
|
||||||
|
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
|
||||||
|
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||||
|
assert.equal(f.refreshes,0);
|
||||||
|
assert.equal(f.element('ccMute').textContent,'Unmute');
|
||||||
|
f.ui.unavailable({stale:true,message:'Switched headsets'});
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||||
|
assert.equal(f.element('ccMute').disabled,false);
|
||||||
|
});
|
||||||
|
test('pending old-headset volume cannot become a draft on the new headset', async t => {
|
||||||
|
for(const failure of [false,true]) {
|
||||||
|
const request=deferred(),f=mounted({api:()=>request.promise});t.after(()=>f.ui.destroy());
|
||||||
|
f.ui.status(sample());f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||||
|
const pending=f.element('ccApplyVolume').onclick();
|
||||||
|
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
|
||||||
|
const next=sample();next.volume.level=.2;f.ui.status(next);
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
if(failure)request.reject(Error('Old headset failed'));else request.resolve({message:'Done'});
|
||||||
|
await pending;
|
||||||
|
assert.equal(f.element('ccVolume').value,20);
|
||||||
|
assert.equal(f.element('ccVolumeValue').textContent,'20%');
|
||||||
|
assert.equal(f.element('ccAudioReadback').textContent,'Reported 20% · unmuted');
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||||
|
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('production host response works before and after mounting with the actual bridge shape', async t => {
|
||||||
|
for(const early of [true,false]) {
|
||||||
|
const f=sharedPage();let ui;
|
||||||
|
if(!early){ui=f.mount();t.after(()=>ui.destroy());assert.equal(f.element('macLink').hidden,true);}
|
||||||
|
f.host.resolve({os:'macOS',mobile:false});await flush();
|
||||||
|
if(early){ui=f.mount();t.after(()=>ui.destroy());}
|
||||||
|
assert.equal(f.context.watched,true);
|
||||||
|
assert.equal(f.element('macLink').hidden,false);
|
||||||
|
f.element('ccSearch').value='mac';f.element('ccSearch').input();
|
||||||
|
assert.equal(f.element('macLink').hidden,false);
|
||||||
|
ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('production shared refresh drains a pre-command request then samples after acknowledgment', async t => {
|
||||||
|
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
|
||||||
|
const prior=f.refresh();assert.equal(f.requests.length,1);
|
||||||
|
assert.equal(f.refresh(),prior); // the ordinary poll/manual deduplication still holds
|
||||||
|
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||||
|
const command=f.element('ccApplyVolume').onclick();await flush();
|
||||||
|
assert.equal(f.writes.length,1);assert.equal(f.requests.length,1);
|
||||||
|
f.requests[0].resolve(sample());await prior;await flush();
|
||||||
|
assert.equal(f.requests.length,2,'readback must issue a real post-command status request');
|
||||||
|
assert.doesNotMatch(f.element('ccAudioResult').textContent,/Confirmed|reported output differs/);
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
const after=sample();after.volume.level=.65;f.requests[1].resolve(after);await command;
|
||||||
|
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||||
|
});
|
||||||
|
test('production audio readback survives an old poll failure and treats its own failed read as unconfirmed', async t => {
|
||||||
|
for(const failedReadback of [false,true]) {
|
||||||
|
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
|
||||||
|
const prior=f.refresh();f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||||
|
const command=f.element('ccApplyVolume').onclick();await flush();
|
||||||
|
f.requests[0].reject(Error('Pre-command poll failed'));await prior;await flush();
|
||||||
|
assert.equal(f.requests.length,2);
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
if(failedReadback)f.requests[1].reject(Error('Readback unavailable'));
|
||||||
|
else {const after=sample();after.volume.level=.64;f.requests[1].resolve(after);}
|
||||||
|
await command;
|
||||||
|
assert.match(f.element('ccAudioResult').textContent,failedReadback ? /command sent; current output could not be confirmed/ : /reported output differs: 64%/);
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,failedReadback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('production Flatpak redraw cannot revive a failed status; only a fresh successful poll recovers', async t => {
|
||||||
|
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());
|
||||||
|
const first=f.refresh(),reading=sample();f.requests[0].resolve(reading);await first;
|
||||||
|
const receipt=vm.runInContext('stateReceivedAt',f.context);
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||||
|
const failed=f.refresh();f.requests[1].reject(Error('Synthetic status parsing error'));await failed;
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
f.installFlatpak(); // actual production render(state), with job held pending
|
||||||
|
assert.equal(f.requests.length,2,'redraw must not start a replacement reading');
|
||||||
|
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||||
|
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||||
|
assert.match(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
|
||||||
|
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||||
|
const fresh=f.refresh();
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true,'starting a poll does not clear failure');
|
||||||
|
const next=sample();next.battery.percent=98;next.volume.level=.2;
|
||||||
|
f.requests[2].resolve(next);await fresh;
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'98%');
|
||||||
|
assert.equal(f.element('ccVolume').value,20);
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||||
|
assert.doesNotMatch(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
|
||||||
|
});
|
||||||
|
test('delayed production bootstrap retains failed-read state and fresh-poll recovery', async t => {
|
||||||
|
for(const priorSuccess of [true,false]) {
|
||||||
|
const f=sharedPage();
|
||||||
|
if(priorSuccess){const first=f.refresh();f.requests[0].resolve(sample());await first;}
|
||||||
|
const failed=f.refresh();f.requests.at(-1).reject(Error('Status failed before mount'));await failed;
|
||||||
|
const calls=f.requests.length,ui=f.mount();t.after(()=>ui.destroy());
|
||||||
|
assert.equal(f.requests.length,calls);
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||||
|
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||||
|
assert.match(f.element('ccSampleState').textContent,/Status failed before mount/);
|
||||||
|
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||||
|
const fresh=f.refresh();assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
f.requests.at(-1).resolve(sample());await fresh;
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||||
|
assert.doesNotMatch(f.element('ccSampleState').textContent,/Status failed before mount/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('same-object replay preserves invalidation and receipt age after a failed reading', t => {
|
||||||
|
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();
|
||||||
|
f.ui.status(reading);f.ui.unavailable(Error('Status failure'));
|
||||||
|
f.ui.status(reading,Date.now()+1000);
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
assert.match(f.element('ccSampleState').textContent,/Status failure/);
|
||||||
|
f.ui.status(sample());assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||||
|
});
|
||||||
|
test('delayed production bootstrap preserves client receipt age despite headset clock skew', async t => {
|
||||||
|
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||||
|
const f=sharedPage(),prior=f.refresh();const reading=sample();reading.time-=3600;
|
||||||
|
f.requests[0].resolve(reading);await prior;
|
||||||
|
now+=91000;const ui=f.mount();t.after(()=>ui.destroy());
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
assert.match(f.element('ccSampleState').textContent,/expired/);
|
||||||
|
const fresh=f.refresh();f.requests[1].resolve(sample());await fresh;
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||||
|
});
|
||||||
|
test('sample replay cannot renew receipt age; fresh receipts tolerate missing or skewed headset time', t => {
|
||||||
|
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||||
|
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();f.ui.status(reading);
|
||||||
|
now+=91000;f.ui.status(reading);
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||||
|
for(const time of [undefined,null,'invalid',NaN,Infinity,now/1000-3600,now/1000+3600]) {
|
||||||
|
f.ui.status({...sample(),time});
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||||
|
assert.doesNotMatch(f.element('ccSampleState').textContent,/Invalid Date/);
|
||||||
|
}
|
||||||
|
for(const receipt of [null,NaN,Infinity,now+1000,now-91000]) {
|
||||||
|
f.ui.status(sample(),receipt);assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('mute-only readback preserves an unapplied volume until Apply sends it', async t => {
|
||||||
|
let reading=sample();const f=mounted({api:async(path,change,device)=>{
|
||||||
|
f.calls.push([path,change,device]);reading={...reading,volume:{...reading.volume,...change}};return {message:'Done'};
|
||||||
|
},refresh:async()=>f.ui.status({...reading})});t.after(()=>f.ui.destroy());f.ui.status(reading);
|
||||||
|
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||||
|
await f.element('ccMute').onclick();
|
||||||
|
assert.deepEqual(f.calls[0],['/api/volume',{muted:true},'frame-a']);
|
||||||
|
assert.equal(f.element('ccVolume').value,'65');assert.equal(f.element('ccVolumeValue').textContent,'65%');
|
||||||
|
assert.match(f.element('ccAudioReadback').textContent,/Reported 40% · muted/);
|
||||||
|
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 40%, muted/);
|
||||||
|
await f.element('ccApplyVolume').onclick();
|
||||||
|
assert.deepEqual(f.calls[1],['/api/volume',{level:.65},'frame-a']);
|
||||||
|
assert.equal(f.element('ccVolume').value,65);
|
||||||
|
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, muted/);
|
||||||
|
});
|
||||||
|
test('live samples expire even when the status poll is paused', t => {
|
||||||
|
const original=Date.now;let now=100000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||||
|
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||||
|
now+=91000;
|
||||||
|
// A connection update must also re-check freshness before enabling a control.
|
||||||
|
f.ui.connection(true);
|
||||||
|
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||||
|
});
|
||||||
|
test('mounted filters show empty state, counts, reset and host-dependent features', t => {
|
||||||
|
const f=mounted();t.after(()=>f.ui.destroy());
|
||||||
|
f.element('ccSearch').value='dpi';f.element('ccSearch').input();
|
||||||
|
assert.equal(f.cards.find(c=>c.dataset.ccCard==='display').hidden,false);
|
||||||
|
assert.equal(f.element('ccResultCount').textContent,'1 section');
|
||||||
|
f.element('ccSearch').value='nothing-matches-this';f.element('ccSearch').input();
|
||||||
|
assert.equal(f.element('ccEmpty').hidden,false);
|
||||||
|
f.element('ccResetFilter').onclick();assert.equal(f.element('ccEmpty').hidden,true);
|
||||||
|
f.ui.host({os:'Windows'});assert.equal(f.element('macLink').hidden,true);
|
||||||
|
assert.equal(f.cards.find(c=>c.dataset.ccCard==='mac').hidden,false);
|
||||||
|
f.ui.host({os:'macOS'});assert.equal(f.element('macLink').hidden,false);
|
||||||
|
f.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||||||
|
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
|
||||||
|
});
|
||||||
|
test('every linked workflow selects its real page and section, preserving shortcuts', () => {
|
||||||
|
const html=fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
||||||
|
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
|
||||||
|
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
|
||||||
|
let activePage,scrolled;
|
||||||
|
const nodes=['home','games','android','tools','devices','control'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
|
||||||
|
const sections=new Map();
|
||||||
|
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
|
||||||
|
window:{scrollTo(){}},$:id=>{
|
||||||
|
if(!ids.has(id))return null;
|
||||||
|
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
|
||||||
|
return sections.get(id);
|
||||||
|
},live:false,toggleLive(){},loadMacView(){},loadPanelSwitcher(){}};
|
||||||
|
const androidStart=html.indexOf('function showAndroidTab(');
|
||||||
|
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
|
||||||
|
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\nPAGES.push("devices", "control");',context);
|
||||||
|
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
|
||||||
|
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
|
||||||
|
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'tools',privacy:'tools',panels:'tools',macview:'tools',devices:'devices'};
|
||||||
|
for(const item of cc.catalog) for(const [href] of item.links||[]) {
|
||||||
|
if(!href.startsWith('#')||href==='#connection')continue;
|
||||||
|
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
|
||||||
|
context.location.hash=href;scrolled=null;vm.runInContext('showPage()',context);
|
||||||
|
assert.equal(activePage,expected[id],href);
|
||||||
|
if(!['android','devices'].includes(id))assert.equal(scrolled,id,href);
|
||||||
|
if(id==='display'||id==='androidLibrary') { assert.equal(sections.get('androidLibrary').hidden,false);assert.equal(sections.get('appStore').hidden,true); }
|
||||||
|
if(id==='appStore') { assert.equal(sections.get('appStore').hidden,false);assert.equal(sections.get('androidLibrary').hidden,true); }
|
||||||
|
}
|
||||||
|
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
|
||||||
|
assert.equal(vm.runInContext('PAGES[5]',context),'control');
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import atexit
|
|||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -22,7 +23,22 @@ from pathlib import Path
|
|||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')]
|
sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')]
|
||||||
|
|
||||||
ENABLED = os.environ.get('FRAME_E2E') == '1'
|
# Only inside the compose `host` container (FRAME_E2E_CONTAINER from compose.yaml, plus
|
||||||
|
# the container runtime's marker file): these tests drive ui/server.py with the real
|
||||||
|
# home, ~/.ssh and cache, which is only safe in a throwaway container.
|
||||||
|
REQUESTED = os.environ.get('FRAME_E2E') == '1'
|
||||||
|
IN_CONTAINER = os.environ.get('FRAME_E2E_CONTAINER') == '1' and (
|
||||||
|
os.path.exists('/.dockerenv') or os.path.exists('/run/.containerenv'))
|
||||||
|
ENABLED = REQUESTED and IN_CONTAINER
|
||||||
|
if ENABLED:
|
||||||
|
# What tests/sandbox.py does, minus moving HOME: the container's real ~/.ssh is what
|
||||||
|
# points `ssh frame` at the fake Frame, and OpenSSH reads ~/.ssh from the password
|
||||||
|
# database, not $HOME. (These tests used to get this through test_frame_apk.)
|
||||||
|
_data = tempfile.mkdtemp(prefix='frame-control-e2e-')
|
||||||
|
atexit.register(shutil.rmtree, _data, ignore_errors=True)
|
||||||
|
os.environ['FRAME_CONTROL_DATA_DIR'] = _data
|
||||||
|
os.environ['FRAME_CONTROL_TELEMETRY'] = '0'
|
||||||
|
os.environ['FRAME_COMPAT_DB_URL'] = 'http://127.0.0.1:9'
|
||||||
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
|
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
|
||||||
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
|
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
|
||||||
HOME = '/home/steamos'
|
HOME = '/home/steamos'
|
||||||
@@ -30,9 +46,14 @@ SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log')
|
|||||||
|
|
||||||
|
|
||||||
def require():
|
def require():
|
||||||
"""Call at module level: skips the module unless the fake Frame is up."""
|
"""Skips (the module, at module level; the class, in setUpClass) unless this is
|
||||||
if not ENABLED:
|
scripts/e2e.sh's container with the fake Frame up."""
|
||||||
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
|
if ENABLED:
|
||||||
|
return
|
||||||
|
if REQUESTED:
|
||||||
|
raise unittest.SkipTest('FRAME_E2E=1 outside the e2e container: these tests use the real home, ~/.ssh '
|
||||||
|
'and app cache, so they only run in scripts/e2e.sh\'s throwaway container')
|
||||||
|
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
|
||||||
|
|
||||||
|
|
||||||
# ---- the fake Frame's control port --------------------------------------------
|
# ---- the fake Frame's control port --------------------------------------------
|
||||||
@@ -97,6 +118,7 @@ class Server:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def start(cls):
|
def start(cls):
|
||||||
|
require() # never a server on this computer's real data
|
||||||
if cls.proc and cls.proc.poll() is None:
|
if cls.proc and cls.proc.poll() is None:
|
||||||
return
|
return
|
||||||
with socket.socket() as s:
|
with socket.socket() as s:
|
||||||
@@ -193,6 +215,7 @@ class FrameTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
|
require()
|
||||||
Server.start()
|
Server.start()
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import sys
|
|||||||
import harness
|
import harness
|
||||||
from harness import api, ok, finished, ssh
|
from harness import api, ok, finished, ssh
|
||||||
|
|
||||||
|
harness.require()
|
||||||
|
|
||||||
sys.path.insert(0, str(harness.ROOT / 'ui'))
|
sys.path.insert(0, str(harness.ROOT / 'ui'))
|
||||||
import frame_mcp
|
import frame_mcp
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import unittest
|
|||||||
|
|
||||||
import harness
|
import harness
|
||||||
from harness import HOME, exists, ok, state, upload, wait_for
|
from harness import HOME, exists, ok, state, upload, wait_for
|
||||||
from test_frame_apk import apk, manifest, resources
|
from apk_fixtures import apk, manifest, resources
|
||||||
|
|
||||||
harness.require()
|
harness.require()
|
||||||
|
|
||||||
@@ -33,8 +33,8 @@ class AndroidApps(harness.FrameTestCase):
|
|||||||
self.assertEqual(shortcut['name'], 'App label')
|
self.assertEqual(shortcut['name'], 'App label')
|
||||||
self.assertEqual(shortcut['exe'], f'{APP_DIR}/launch.sh')
|
self.assertEqual(shortcut['exe'], f'{APP_DIR}/launch.sh')
|
||||||
self.assertEqual(shortcut['start_dir'], APP_DIR)
|
self.assertEqual(shortcut['start_dir'], APP_DIR)
|
||||||
self.assertEqual(shortcut['icon'], f'{APP_DIR}/icon.png')
|
self.assertEqual(shortcut['icon'], f'{APP_DIR}/artwork/icon.png')
|
||||||
for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'icon.png', 'lepton-show-flatscreen'):
|
for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'artwork/icon.png', 'lepton-show-flatscreen'):
|
||||||
self.assertTrue(exists(f'{APP_DIR}/{f}'), f)
|
self.assertTrue(exists(f'{APP_DIR}/{f}'), f)
|
||||||
self.assertEqual(meta['game_id'], (meta['shortcut'] << 32) | 0x02000000)
|
self.assertEqual(meta['game_id'], (meta['shortcut'] << 32) | 0x02000000)
|
||||||
|
|
||||||
|
|||||||
@@ -83,5 +83,27 @@ class Device(harness.FrameTestCase):
|
|||||||
self.assertEqual(state()['steam']['pages'][0]['title'], 'Hades on Steam')
|
self.assertEqual(state()['steam']['pages'][0]['title'], 'Hades on Steam')
|
||||||
|
|
||||||
|
|
||||||
|
class VRUtilities(harness.FrameTestCase):
|
||||||
|
def test_missing_vr_runtime_is_unavailable_not_zero_fps(self):
|
||||||
|
data = ok('GET', '/api/status')
|
||||||
|
self.assertIsNone(data['performance']['compositorFps'])
|
||||||
|
self.assertIsNone(data['performance']['appFps'])
|
||||||
|
self.assertEqual(data['temp'], 41.5)
|
||||||
|
self.assertEqual(data['battery']['percent'], 76)
|
||||||
|
|
||||||
|
def test_optional_paid_utilities_are_not_installed(self):
|
||||||
|
# The fake library contains games but none of these paid software titles.
|
||||||
|
for appid in (1009850, 1173510, 1068820, 908520):
|
||||||
|
code, body, _ = api('POST', '/api/steam', {'action': 'install', 'appid': appid})
|
||||||
|
self.assertEqual(code, 502, body)
|
||||||
|
self.assertIn('not owned', body['error'])
|
||||||
|
self.assertEqual(launches('install'), [])
|
||||||
|
|
||||||
|
def test_unverified_controls_are_not_exposed(self):
|
||||||
|
for action in ('recenter', 'adjust', 'restore'):
|
||||||
|
code, body, _ = api('POST', '/api/vr', {'action': action, 'origin': 'seated', 'y': .1})
|
||||||
|
self.assertEqual(code, 400, body)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A stand-in for Android's adb, for tests/test_quest.py: just enough of `devices -l`,
|
||||||
|
`shell`, and `pull` for frame_quest.py, run as a real subprocess.
|
||||||
|
|
||||||
|
$FAKEADB_STATE is a JSON file:
|
||||||
|
devices [{"serial", "state", "extra" (the rest of the `devices -l` line), "props" {getprop name: value}}]
|
||||||
|
root a folder standing in for each device's file system (remote /x/y is root/x/y)
|
||||||
|
packages {package: [APK paths, as `pm path` prints them]}
|
||||||
|
fail_pull a remote path whose pull fails
|
||||||
|
Every call is appended to $FAKEADB_LOG as a JSON line. Shell commands are the simple
|
||||||
|
`; `-separated ones frame_quest sends, each answered here: no real shell runs.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
with open(os.environ['FAKEADB_LOG'], 'a') as f:
|
||||||
|
f.write(json.dumps(args) + '\n')
|
||||||
|
with open(os.environ['FAKEADB_STATE']) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
root = state.get('root', '')
|
||||||
|
serial = None
|
||||||
|
if args[:1] == ['-s']:
|
||||||
|
serial, args = args[1], args[2:]
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message, code=1):
|
||||||
|
sys.stderr.write(message + '\n')
|
||||||
|
sys.exit(code)
|
||||||
|
|
||||||
|
|
||||||
|
def local(remote):
|
||||||
|
return os.path.join(root, remote.lstrip('/'))
|
||||||
|
|
||||||
|
|
||||||
|
def size_kb(path):
|
||||||
|
total = 0
|
||||||
|
for dirpath, _, files in os.walk(path):
|
||||||
|
total += sum(os.path.getsize(os.path.join(dirpath, n)) for n in files)
|
||||||
|
return max(4, total // 1024)
|
||||||
|
|
||||||
|
|
||||||
|
if args == ['devices', '-l']:
|
||||||
|
print('List of devices attached')
|
||||||
|
for d in state.get('devices', []):
|
||||||
|
print(f"{d['serial']:<22} {d['state']} {d.get('extra', '')}".rstrip())
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
device = next((d for d in state.get('devices', []) if d['serial'] == serial), None)
|
||||||
|
if serial is None or device is None:
|
||||||
|
fail(f"adb: device '{serial}' not found")
|
||||||
|
if device['state'] == 'unauthorized':
|
||||||
|
fail('adb: device unauthorized.\nThis adb server\'s $ADB_VENDOR_KEYS is not set')
|
||||||
|
if device['state'] != 'device':
|
||||||
|
fail(f"adb: device {device['state']}")
|
||||||
|
|
||||||
|
if args[:1] == ['shell']:
|
||||||
|
out = []
|
||||||
|
for command in ' '.join(args[1:]).split('; '):
|
||||||
|
command = command.replace(' 2>/dev/null', '').strip()
|
||||||
|
words = command.split()
|
||||||
|
if not words or command == 'true':
|
||||||
|
continue
|
||||||
|
if words[0] == 'getprop':
|
||||||
|
out.append(device.get('props', {}).get(words[1], ''))
|
||||||
|
elif words[0] == 'echo':
|
||||||
|
out.append(' '.join(words[1:]))
|
||||||
|
elif command == 'pm list packages -3':
|
||||||
|
out += ['package:' + p for p in state.get('packages', {})]
|
||||||
|
elif words[:2] == ['pm', 'path']:
|
||||||
|
out += ['package:' + p for p in state.get('packages', {}).get(words[2], [])]
|
||||||
|
elif words[:2] == ['du', '-sk']:
|
||||||
|
if os.path.isdir(local(words[2])):
|
||||||
|
out.append(f'{size_kb(local(words[2]))}\t{words[2]}')
|
||||||
|
elif words[:3] == ['stat', '-c', '%s']:
|
||||||
|
out += [str(os.path.getsize(local(p.strip("'")))) for p in words[3:]]
|
||||||
|
else:
|
||||||
|
fail(f'/system/bin/sh: {words[0]}: inaccessible or not found', 127)
|
||||||
|
print('\n'.join(out))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if args[:1] == ['pull'] and len(args) == 3:
|
||||||
|
remote, dest = args[1], args[2]
|
||||||
|
if remote == state.get('fail_pull'):
|
||||||
|
fail(f"adb: error: failed to copy '{remote}' to '{dest}': remote Connection reset")
|
||||||
|
src = local(remote)
|
||||||
|
if not os.path.exists(src):
|
||||||
|
fail(f"adb: error: failed to stat remote object '{remote}': No such file or directory")
|
||||||
|
if os.path.isdir(dest):
|
||||||
|
dest = os.path.join(dest, os.path.basename(remote.rstrip('/')))
|
||||||
|
if os.path.isdir(src):
|
||||||
|
shutil.copytree(src, dest)
|
||||||
|
else:
|
||||||
|
shutil.copyfile(src, dest)
|
||||||
|
print(f'{remote}: 1 file pulled.')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
fail('adb: unknown command ' + ' '.join(args))
|
||||||
@@ -43,6 +43,7 @@ services:
|
|||||||
FAKEFRAME_CTL: http://fakeframe:9999
|
FAKEFRAME_CTL: http://fakeframe:9999
|
||||||
FAKEFRAME_HOST: fakeframe
|
FAKEFRAME_HOST: fakeframe
|
||||||
FRAME_E2E: "1"
|
FRAME_E2E: "1"
|
||||||
|
FRAME_E2E_CONTAINER: "1" # tests/e2e/harness.py refuses to run anywhere else
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "test", "-f", "/home/tester/.ready"]
|
test: ["CMD", "test", "-f", "/home/tester/.ready"]
|
||||||
interval: 1s
|
interval: 1s
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)}
|
|||||||
if cmd == 'ps':
|
if cmd == 'ps':
|
||||||
for name, c in sorted(containers.items()):
|
for name, c in sorted(containers.items()):
|
||||||
print(f"{name} {c['port']}")
|
print(f"{name} {c['port']}")
|
||||||
|
elif cmd == 'inspect':
|
||||||
|
if args[-1] not in containers:
|
||||||
|
sys.exit(1)
|
||||||
|
print('true')
|
||||||
elif cmd == 'stop':
|
elif cmd == 'stop':
|
||||||
name = args[-1]
|
name = args[-1]
|
||||||
c = containers.get(name)
|
c = containers.get(name)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Synthetic canvas for API-path tests only. It emits transparent PNGs, not visual proof.
|
||||||
|
const zlib = require('zlib');
|
||||||
|
function crc(data) {
|
||||||
|
let c=0xffffffff;
|
||||||
|
for(const b of data) {c^=b;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);}
|
||||||
|
return (c^0xffffffff)>>>0;
|
||||||
|
}
|
||||||
|
function chunk(name,data) {
|
||||||
|
const body=Buffer.concat([Buffer.from(name),data]), n=Buffer.alloc(4), sum=Buffer.alloc(4);
|
||||||
|
n.writeUInt32BE(data.length);sum.writeUInt32BE(crc(body));return Buffer.concat([n,body,sum]);
|
||||||
|
}
|
||||||
|
function png(w,h) {
|
||||||
|
const header=Buffer.alloc(13);header.writeUInt32BE(w);header.writeUInt32BE(h,4);header[8]=8;header[9]=6;
|
||||||
|
return Buffer.concat([Buffer.from('89504e470d0a1a0a','hex'),chunk('IHDR',header),
|
||||||
|
chunk('IDAT',zlib.deflateSync(Buffer.alloc((w*4+1)*h))),chunk('IEND',Buffer.alloc(0))]).toString('base64');
|
||||||
|
}
|
||||||
|
function surface() {
|
||||||
|
const canvases=[];
|
||||||
|
const document={fonts:{load:async()=>[]},createElement(tag) {
|
||||||
|
if(tag!=='canvas')throw Error('unexpected element');
|
||||||
|
const canvas={width:1,height:1,text:[],draws:0};
|
||||||
|
const ctx={measureText:t=>({width:String(t).length*30}),fillText(t){canvas.text.push(t);},
|
||||||
|
drawImage(){canvas.draws++;},getImageData:()=>({data:new Uint8ClampedArray(canvas.width*canvas.height*4)}),
|
||||||
|
createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}})};
|
||||||
|
for(const method of ['save','restore','beginPath','rect','roundRect','clip','fillRect','putImageData','arc','fill','stroke'])ctx[method]=()=>{};
|
||||||
|
canvas.getContext=()=>ctx;canvas.toDataURL=type=>type==='image/jpeg'?'data:image/jpeg;base64,'+Buffer.from('ffd8ffe000104a464946','hex').toString('base64'):
|
||||||
|
'data:image/png;base64,'+png(canvas.width,canvas.height);
|
||||||
|
canvases.push(canvas);return canvas;
|
||||||
|
}};
|
||||||
|
class Image {constructor(){this.width=2;this.height=2;} async decode(){if(this.src.includes('YmFk'))throw Error('bad image');}}
|
||||||
|
return {document,Image,canvases};
|
||||||
|
}
|
||||||
|
module.exports={surface};
|
||||||
@@ -27,7 +27,7 @@ function newShortcutId(steam) {
|
|||||||
function build(steam) {
|
function build(steam) {
|
||||||
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
|
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
|
||||||
const gameOverview = a => ({
|
const gameOverview = a => ({
|
||||||
appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: 1,
|
appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: a.app_type ?? 1,
|
||||||
steam_hw_compat_category_packed: a.packed || 0, vr_supported: !!a.vr, vr_only: !!a.vr_only,
|
steam_hw_compat_category_packed: a.packed || 0, vr_supported: !!a.vr, vr_only: !!a.vr_only,
|
||||||
size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0,
|
size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0,
|
||||||
rt_last_time_played: a.last_played || 0,
|
rt_last_time_played: a.last_played || 0,
|
||||||
@@ -37,7 +37,8 @@ function build(steam) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const shortcutOverview = s => ({
|
const shortcutOverview = s => ({
|
||||||
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE,
|
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid,
|
||||||
|
icon_data: s.icon ? 'fake-icon' : undefined,
|
||||||
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
|
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
|
||||||
});
|
});
|
||||||
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
|
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
|
||||||
@@ -54,7 +55,30 @@ function build(steam) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Library API shapes from SteamTracking / decky-frontend-lib (2026-09-28).
|
||||||
|
// Not yet verified on this Frame build: Steam was unavailable during testing.
|
||||||
|
steam.collections ||= [];
|
||||||
|
const collection = value => ({
|
||||||
|
...value, displayName: value.name, bIsDynamic: !!value.dynamic, bAllowsDragAndDrop: true,
|
||||||
|
AsDragDropCollection() { return this; },
|
||||||
|
AddApps(apps) { value.apps = [...new Set([...value.apps, ...apps.map(a => a.appid)])]; },
|
||||||
|
RemoveApps(apps) { value.apps = value.apps.filter(id => !apps.some(a => a.appid === id)); },
|
||||||
|
value,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
|
...require('./canvas_stub').surface(),
|
||||||
|
collectionStore: {
|
||||||
|
GetUserCollectionsByName(name) { return steam.collections.filter(c => c.name === name).map(collection); },
|
||||||
|
NewUnsavedCollection(name, filter, apps) { return collection({name, apps: apps.map(a => a.appid)}); },
|
||||||
|
async SaveCollection(c) { if (!steam.collections.includes(c.value)) steam.collections.push(c.value); },
|
||||||
|
},
|
||||||
|
// Shortcut exe/start folder live in app details, not overviews (Frame, 2026-09-28).
|
||||||
|
appDetailsStore: {
|
||||||
|
GetAppDetails(id) {
|
||||||
|
const s = findShortcut(id);
|
||||||
|
return s ? { strShortcutExe: s.exe, strShortcutStartDir: s.start_dir, bShortcutIsVR: !!s.vr } : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
appStore: {
|
appStore: {
|
||||||
get allApps() { return allApps(); },
|
get allApps() { return allApps(); },
|
||||||
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
||||||
@@ -75,6 +99,17 @@ function build(steam) {
|
|||||||
SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); },
|
SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); },
|
||||||
SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); },
|
SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); },
|
||||||
SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); },
|
SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); },
|
||||||
|
SetShortcutIsVR(id, vr) { const s = findShortcut(id); if (s) s.vr = vr; },
|
||||||
|
async SetCustomArtworkForApp(id, data, ext, type) {
|
||||||
|
const s = findShortcut(id);
|
||||||
|
if (s) { s.artwork ||= {}; s.artwork[type] = {data, ext}; }
|
||||||
|
},
|
||||||
|
async ClearCustomArtworkForApp(id, type) {
|
||||||
|
const s = findShortcut(id);
|
||||||
|
if (s?.artwork) delete s.artwork[type];
|
||||||
|
},
|
||||||
|
// Container fallback in frame_android.stop performs the simulated stop.
|
||||||
|
TerminateApp(gameid) { steam.last_terminate = gameid; },
|
||||||
RemoveShortcut(id) {
|
RemoveShortcut(id) {
|
||||||
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
||||||
delete steam.compat_tools[String(id)];
|
delete steam.compat_tools[String(id)];
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py and
|
||||||
|
tests/test_release_workflow.py. Serves the releases in $FAKEGH_RELEASES (a JSON list,
|
||||||
|
drafts included) and the tags in $FAKEGH_TAGS (space-separated); an upload's body is
|
||||||
|
written to $FAKEGH_UPLOAD. Every call is appended to $FAKEGH_LOG as a JSON line.
|
||||||
|
Anything else fails, so the script under test can't fall back to `gh release ...`
|
||||||
|
(GraphQL) unnoticed. POST .../releases (release.yml's draft step) answers as release
|
||||||
|
$FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's list
|
||||||
|
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
|
||||||
|
(raw strings, compact JSON)."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
with open(os.environ["FAKEGH_LOG"], "a") as f:
|
||||||
|
f.write(json.dumps(args) + "\n")
|
||||||
|
if not args or args[0] != "api":
|
||||||
|
sys.exit("fakegh: only `gh api` is supported: %r" % args)
|
||||||
|
|
||||||
|
method, endpoint, fields, inp, jq, i = "GET", None, {}, None, None, 1
|
||||||
|
while i < len(args):
|
||||||
|
a = args[i]
|
||||||
|
if a == "-X":
|
||||||
|
method = args[i + 1]; i += 2
|
||||||
|
elif a in ("-f", "-F"):
|
||||||
|
k, _, v = args[i + 1].partition("="); fields[k] = v; i += 2
|
||||||
|
elif a == "--input":
|
||||||
|
inp = args[i + 1]; i += 2
|
||||||
|
elif a == "--jq":
|
||||||
|
jq = args[i + 1]; i += 2
|
||||||
|
elif a == "-H":
|
||||||
|
i += 2
|
||||||
|
elif a.startswith("-"):
|
||||||
|
i += 1
|
||||||
|
elif endpoint is None:
|
||||||
|
endpoint = a; i += 1
|
||||||
|
else:
|
||||||
|
sys.exit("fakegh: unexpected argument %r" % a)
|
||||||
|
|
||||||
|
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
|
||||||
|
repo = "repos/saphid/frame-control/"
|
||||||
|
|
||||||
|
|
||||||
|
def answer(response, fallback):
|
||||||
|
"""Print response through --jq when jq is here, else the fixed fallback text."""
|
||||||
|
if jq and shutil.which("jq"):
|
||||||
|
out = subprocess.run(["jq", "-r", "-c", jq], input=json.dumps(response),
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
|
||||||
|
sys.stdout.write(out.stdout)
|
||||||
|
if out.returncode:
|
||||||
|
sys.exit("fakegh: jq failed: " + out.stderr)
|
||||||
|
else:
|
||||||
|
print(fallback)
|
||||||
|
|
||||||
|
|
||||||
|
if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
|
||||||
|
tag = endpoint.rsplit("/", 1)[1]
|
||||||
|
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
|
||||||
|
sys.exit("gh: Not Found (HTTP 404)")
|
||||||
|
print(json.dumps({"ref": "refs/tags/" + tag}))
|
||||||
|
elif method == "GET" and endpoint.startswith(repo + "releases?"):
|
||||||
|
# The fallback is what --jq '.[]' prints.
|
||||||
|
answer(releases, "\n".join(json.dumps(r) for r in releases))
|
||||||
|
elif method == "POST" and endpoint == repo + "releases":
|
||||||
|
new = {"id": int(os.environ.get("FAKEGH_NEW_ID", "9001")), "tag_name": fields.get("tag_name"),
|
||||||
|
"name": fields.get("name"), "draft": fields.get("draft") == "true",
|
||||||
|
"body": fields.get("body"), "assets": [],
|
||||||
|
"html_url": "https://github.com/saphid/frame-control/releases/tag/untagged-be29e900f7855d794136"}
|
||||||
|
answer(new, json.dumps(new))
|
||||||
|
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
|
||||||
|
pass
|
||||||
|
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
|
||||||
|
with open(inp) as src, open(os.environ["FAKEGH_UPLOAD"], "w") as dst:
|
||||||
|
dst.write(src.read())
|
||||||
|
print("{}")
|
||||||
|
elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
|
||||||
|
rid = endpoint[len(repo + "releases/"):]
|
||||||
|
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
|
||||||
|
sys.exit("gh: Not Found (HTTP 404)")
|
||||||
|
page = "https://github.com/saphid/frame-control/releases/tag/%s" % fields.get("tag_name")
|
||||||
|
answer({"id": int(rid), "tag_name": fields.get("tag_name"), "html_url": page},
|
||||||
|
"published %s at %s" % (fields.get("tag_name"), page))
|
||||||
|
else:
|
||||||
|
sys.exit("fakegh: unhandled %s %s" % (method, endpoint))
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A stand-in for OpenSSH's ssh, for tests/test_link.py: prints what `ssh -v` prints at
|
||||||
|
each step of a connection, and plays a ControlMaster. What each HostName does comes
|
||||||
|
from $FAKESSH_HOSTS (JSON: host -> "ok", "wrong" (a different host key), "denied" or
|
||||||
|
"slow" (hangs after connecting);
|
||||||
|
every call is appended to $FAKESSH_LOG as a JSON line. POSIX only.
|
||||||
|
|
||||||
|
With $FAKESSH_PINS set, host keys are checked as OpenSSH does against UserKnownHostsFile under
|
||||||
|
HostKeyAlias: "wrong" is then just a different key from the others' (a reinstalled headset),
|
||||||
|
StrictHostKeyChecking=yes refuses a key not saved, accept-new saves a first one, and a saved key
|
||||||
|
that differs is refused. "evil" is a device with its own key that lets anyone in without
|
||||||
|
authentication ("none") and asks for the agent: it records in $FAKESSH_DIR what it got
|
||||||
|
(agent-forwarded, command-ran). Like ssh, the first value of an option counts."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
with open(os.environ["FAKESSH_LOG"], "a") as f:
|
||||||
|
f.write(json.dumps(args) + "\n")
|
||||||
|
hosts = json.loads(os.environ.get("FAKESSH_HOSTS", "{}"))
|
||||||
|
opts = {}
|
||||||
|
i = 0
|
||||||
|
while i < len(args) and args[i].startswith("-"):
|
||||||
|
if args[i] in ("-o", "-O", "-p", "-l", "-F", "-W"):
|
||||||
|
key = args[i]
|
||||||
|
val = args[i + 1]
|
||||||
|
if key == "-o":
|
||||||
|
k, _, v = val.partition("=")
|
||||||
|
opts.setdefault(k.lower(), v)
|
||||||
|
else:
|
||||||
|
opts[key] = val
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
opts[args[i]] = True
|
||||||
|
i += 1
|
||||||
|
alias = args[i] if i < len(args) else ""
|
||||||
|
command = args[i + 1:]
|
||||||
|
if opts.get("-F") != "none":
|
||||||
|
# $FAKESSH_CONFIG: what ~/.ssh/config says (JSON, lower-case option -> value), below the
|
||||||
|
# command line's options, as ssh has it; `-F none` reads no config.
|
||||||
|
for k, v in json.loads(os.environ.get("FAKESSH_CONFIG", "{}")).items():
|
||||||
|
opts.setdefault(k, v)
|
||||||
|
host = opts.get("hostname", alias).replace("%%", "%")
|
||||||
|
marker = os.path.join(os.environ["FAKESSH_DIR"], "master-" + host.replace("/", "_"))
|
||||||
|
say = lambda s: (sys.stderr.write(s + "\n"), sys.stderr.flush())
|
||||||
|
|
||||||
|
if "-G" in opts:
|
||||||
|
print(f"hostname {alias}\nport 22\nuser tester")
|
||||||
|
sys.exit(0)
|
||||||
|
if opts.get("-O") == "check":
|
||||||
|
sys.exit(0 if os.path.exists(marker) else 255)
|
||||||
|
if opts.get("-O") == "exit":
|
||||||
|
if os.path.exists(marker):
|
||||||
|
os.unlink(marker)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if "-W" in opts: # a jump host's ssh, forwarding to the headset: the call is logged, that's all
|
||||||
|
sys.exit(0)
|
||||||
|
if opts.get("proxycommand", "none") != "none":
|
||||||
|
# As OpenSSH: expand %-tokens, then `$SHELL -c "exec <line>"` (here /bin/sh).
|
||||||
|
line, out, n = opts["proxycommand"], "", 0
|
||||||
|
while n < len(line):
|
||||||
|
if line[n] == "%" and n + 1 < len(line):
|
||||||
|
out += {"%": "%", "h": host, "p": opts.get("port", "22"), "r": opts.get("user", "")}.get(
|
||||||
|
line[n + 1], line[n:n + 2])
|
||||||
|
n += 2
|
||||||
|
else:
|
||||||
|
out += line[n]
|
||||||
|
n += 1
|
||||||
|
if subprocess.run(["/bin/sh", "-c", "exec " + out], stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL).returncode:
|
||||||
|
say("kex_exchange_identification: Connection closed by remote host")
|
||||||
|
sys.exit(255)
|
||||||
|
what = hosts.get(host)
|
||||||
|
if what is None:
|
||||||
|
say(f"ssh: Could not resolve hostname {host}: nodename nor servname provided, or not known")
|
||||||
|
sys.exit(255)
|
||||||
|
say(f"debug1: Connecting to {host} [127.0.0.1] port {opts.get('port', 22)}.")
|
||||||
|
say("debug1: Connection established.")
|
||||||
|
if what == "slow":
|
||||||
|
time.sleep(30)
|
||||||
|
say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'")
|
||||||
|
if os.environ.get("FAKESSH_PINS") and "hostkeyalias" in opts and "userknownhostsfile" in opts:
|
||||||
|
name, path = opts["hostkeyalias"], os.path.expanduser(opts["userknownhostsfile"])
|
||||||
|
blob = base64.b64encode(b"fake-" + {"wrong": b"other", "evil": b"evil"}.get(what, b"headset") + b"-key").decode()
|
||||||
|
fp = base64.b64encode(hashlib.sha256(base64.b64decode(blob)).digest()).decode().rstrip("=")
|
||||||
|
say(f"debug1: Server host key: ssh-ed25519 SHA256:{fp}")
|
||||||
|
try:
|
||||||
|
saved = [ln.split() for ln in open(path, errors="replace").read().splitlines() if ln.strip()]
|
||||||
|
except OSError:
|
||||||
|
saved = []
|
||||||
|
saved = [f[2] for f in saved if len(f) >= 3 and name in f[0].split(",")]
|
||||||
|
strict = opts.get("stricthostkeychecking", "ask")
|
||||||
|
if saved and blob not in saved:
|
||||||
|
say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
|
||||||
|
say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @")
|
||||||
|
say("Host key verification failed.")
|
||||||
|
sys.exit(255)
|
||||||
|
if not saved and strict != "accept-new":
|
||||||
|
say(f"No ED25519 host key is known for {name} and you have requested strict checking.")
|
||||||
|
say("Host key verification failed.")
|
||||||
|
sys.exit(255)
|
||||||
|
if not saved:
|
||||||
|
try:
|
||||||
|
with open(path, "a") as f:
|
||||||
|
f.write(f"{name} ssh-ed25519 {blob}\n")
|
||||||
|
say(f"Warning: Permanently added '{name}' (ED25519) to the list of known hosts.")
|
||||||
|
except OSError: # ssh doesn't create the folder: it goes on, saving nothing
|
||||||
|
say(f"Failed to add the host to the list of known hosts ({path}).")
|
||||||
|
else:
|
||||||
|
say(f"debug1: Host '{name}' is known and matches the ED25519 host key.")
|
||||||
|
say("debug1: Next authentication method: publickey")
|
||||||
|
if what == "evil":
|
||||||
|
say(f'Authenticated to {host} ([127.0.0.1]:22) using "none".')
|
||||||
|
got = os.path.join(os.environ["FAKESSH_DIR"], "evil-")
|
||||||
|
if opts.get("forwardagent", "no") != "no" and opts.get("identityagent", "") != "none":
|
||||||
|
open(got + "agent-forwarded", "w").close()
|
||||||
|
if "-N" not in opts and (command or opts.get("remotecommand", "none") != "none"):
|
||||||
|
open(got + "command-ran", "w").close()
|
||||||
|
sys.exit(0)
|
||||||
|
time.sleep(30) # -N: holds the session open
|
||||||
|
sys.exit(0)
|
||||||
|
if opts.get("preferredauthentications") == "none":
|
||||||
|
say(f"tester@{host}: Permission denied (publickey,password).")
|
||||||
|
sys.exit(255)
|
||||||
|
if what == "denied":
|
||||||
|
say(f"tester@{host}: Permission denied (publickey).")
|
||||||
|
sys.exit(255)
|
||||||
|
what = "ok"
|
||||||
|
say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".')
|
||||||
|
if opts.get("controlmaster") != "yes":
|
||||||
|
sys.exit(0)
|
||||||
|
open(marker, "w").close()
|
||||||
|
def bye(*_):
|
||||||
|
if os.path.exists(marker):
|
||||||
|
os.unlink(marker)
|
||||||
|
sys.exit(0)
|
||||||
|
signal.signal(signal.SIGTERM, bye)
|
||||||
|
while True:
|
||||||
|
time.sleep(0.2)
|
||||||
|
if not os.path.exists(marker):
|
||||||
|
sys.exit(0)
|
||||||
|
say("debug1: Server host key: ssh-ed25519 SHA256:fakefakefakefakefakefakefakefakefakefakefak")
|
||||||
|
if what == "wrong":
|
||||||
|
say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
|
||||||
|
say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @")
|
||||||
|
say("Host key verification failed.")
|
||||||
|
sys.exit(255)
|
||||||
|
say(f"debug1: Host '{opts.get('hostkeyalias', host)}' is known and matches the ED25519 host key.")
|
||||||
|
say("debug1: Next authentication method: publickey")
|
||||||
|
if what == "denied":
|
||||||
|
say(f"tester@{host}: Permission denied (publickey).")
|
||||||
|
sys.exit(255)
|
||||||
|
say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".')
|
||||||
|
if opts.get("controlmaster") == "yes":
|
||||||
|
open(marker, "w").close()
|
||||||
|
def bye(*_):
|
||||||
|
if os.path.exists(marker):
|
||||||
|
os.unlink(marker)
|
||||||
|
sys.exit(0)
|
||||||
|
signal.signal(signal.SIGTERM, bye)
|
||||||
|
while True:
|
||||||
|
time.sleep(0.2)
|
||||||
|
if not os.path.exists(marker):
|
||||||
|
sys.exit(0)
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Store preview artwork
|
||||||
|
|
||||||
|
Recorded public artwork for offline UI verification, fetched 2026-09-28.
|
||||||
|
`urls.json` records each original URL. No unit test downloads these files.
|
||||||
|
|
||||||
|
- Open Brush banner, icon and screenshots: Icosa Foundation's public
|
||||||
|
`icosa-foundation/openbrush.app` website assets. Artwork remains credited to
|
||||||
|
its creators; used here to preview the Open Brush listing.
|
||||||
|
- Mindustry, AntennaPod and NewPipe icons/screenshots: their public F-Droid
|
||||||
|
listings. Corresponding projects use GPL licences; these images represent
|
||||||
|
those same apps in the store preview.
|
||||||
|
- Luanti, SuperTuxKart and other social previews: public GitHub-generated
|
||||||
|
repository preview images. Project names/logos belong to their owners.
|
||||||
|
|
||||||
|
`../store.json` contains illustrative listing metadata, including mock package
|
||||||
|
names, popularity, dates, version/size and compatibility fields. It is not a
|
||||||
|
catalogue or evidence that a particular release works on the Frame. `_demo.py`
|
||||||
|
is opt-in and cannot download APKs. `tests/search_preview.py` preloads these
|
||||||
|
recordings into the image cache and simulates installation without a headset.
|
||||||
|
After Width: | Height: | Size: 489 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 821 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"brush-banner.jpg": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg",
|
||||||
|
"brush-icon.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
|
||||||
|
"brush-shot1.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png",
|
||||||
|
"brush-shot2.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp",
|
||||||
|
"brush-shot3.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp",
|
||||||
|
"luanti.png": "https://opengraph.githubassets.com/1/luanti-org/luanti",
|
||||||
|
"kart.png": "https://opengraph.githubassets.com/1/supertuxkart/stk-code",
|
||||||
|
"luanti-icon.png": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png",
|
||||||
|
"pod-icon.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png",
|
||||||
|
"mindustry-icon.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png",
|
||||||
|
"mindustry-shot.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png",
|
||||||
|
"pod-shot.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png",
|
||||||
|
"newpipe-icon.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png",
|
||||||
|
"newpipe-shot.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{"source":"one","id":"brush","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":true,"version":"1","version_code":1,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2025-01-01"},
|
||||||
|
{"source":"two","id":"brush2","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":false,"version":"2","version_code":2,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2026-01-01"},
|
||||||
|
{"source":"one","id":"other","package":"org.other","name":"Open Brush","free":true,"downloadable":true,"min_sdk":31,"abis":["arm64-v8a"],"vr":true},
|
||||||
|
{"source":"one","id":"unknown","package":null,"name":"Pocket Radio!","free":true,"downloadable":false,"vr":false},
|
||||||
|
{"source":"two","id":"unknown2","package":null,"name":"pocket radio","free":true,"downloadable":false,"vr":false}
|
||||||
|
]
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "brush",
|
||||||
|
"package": "org.preview.brush",
|
||||||
|
"name": "Open Brush",
|
||||||
|
"summary": "Make the world your canvas. Paint, sculpt and create in a space without limits.",
|
||||||
|
"description": "Your imagination deserves more room. Open Brush turns the space around you into a canvas, with expressive brushes, vivid colors and light you can paint with.\n\nCreate something small, build something extraordinary, or just enjoy making your first mark in VR. This community-led painting app is free and open source.",
|
||||||
|
"developer": "Icosa Foundation",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"free": true,
|
||||||
|
"downloadable": true,
|
||||||
|
"version": "2.32.29",
|
||||||
|
"version_code": 1,
|
||||||
|
"min_sdk": 26,
|
||||||
|
"abis": [
|
||||||
|
"arm64-v8a"
|
||||||
|
],
|
||||||
|
"vr": true,
|
||||||
|
"updated": "2026-09-27",
|
||||||
|
"size": 85000000,
|
||||||
|
"popularity": 100,
|
||||||
|
"images": {
|
||||||
|
"banner": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg",
|
||||||
|
"icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
|
||||||
|
"screenshots": [
|
||||||
|
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png",
|
||||||
|
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp",
|
||||||
|
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"engine": "Unity OpenXR",
|
||||||
|
"frame_tested": true,
|
||||||
|
"icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
|
||||||
|
"page": "https://openbrush.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mindustry",
|
||||||
|
"package": "org.preview.mindustry",
|
||||||
|
"name": "Mindustry",
|
||||||
|
"summary": "Build a factory. Defend your world.",
|
||||||
|
"description": "Build a factory. Defend your world.",
|
||||||
|
"developer": "Anuken",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"free": true,
|
||||||
|
"downloadable": true,
|
||||||
|
"version": "1.2",
|
||||||
|
"version_code": 1,
|
||||||
|
"min_sdk": 26,
|
||||||
|
"abis": [
|
||||||
|
"arm64-v8a"
|
||||||
|
],
|
||||||
|
"vr": false,
|
||||||
|
"updated": "2026-09-26",
|
||||||
|
"size": 85000000,
|
||||||
|
"popularity": 92,
|
||||||
|
"images": {
|
||||||
|
"banner": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png",
|
||||||
|
"icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png",
|
||||||
|
"screenshots": [
|
||||||
|
"https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "luanti",
|
||||||
|
"package": "org.preview.luanti",
|
||||||
|
"name": "Luanti",
|
||||||
|
"summary": "A world of blocks. Endless possibilities.",
|
||||||
|
"description": "A world of blocks. Endless possibilities.",
|
||||||
|
"developer": "Luanti contributors",
|
||||||
|
"license": "LGPL-2.1",
|
||||||
|
"free": true,
|
||||||
|
"downloadable": true,
|
||||||
|
"version": "1.3",
|
||||||
|
"version_code": 1,
|
||||||
|
"min_sdk": 26,
|
||||||
|
"abis": [
|
||||||
|
"arm64-v8a"
|
||||||
|
],
|
||||||
|
"vr": false,
|
||||||
|
"updated": "2026-09-25",
|
||||||
|
"size": 85000000,
|
||||||
|
"popularity": 84,
|
||||||
|
"images": {
|
||||||
|
"banner": "https://opengraph.githubassets.com/1/luanti-org/luanti",
|
||||||
|
"icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png",
|
||||||
|
"screenshots": [
|
||||||
|
"https://opengraph.githubassets.com/1/luanti-org/luanti"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pod",
|
||||||
|
"package": "org.preview.pod",
|
||||||
|
"name": "AntennaPod",
|
||||||
|
"summary": "Your favorite stories, wherever you listen.",
|
||||||
|
"description": "Your favorite stories, wherever you listen.",
|
||||||
|
"developer": "AntennaPod contributors",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"free": true,
|
||||||
|
"downloadable": true,
|
||||||
|
"version": "1.4",
|
||||||
|
"version_code": 1,
|
||||||
|
"min_sdk": 26,
|
||||||
|
"abis": [
|
||||||
|
"arm64-v8a"
|
||||||
|
],
|
||||||
|
"vr": false,
|
||||||
|
"updated": "2026-09-24",
|
||||||
|
"size": 85000000,
|
||||||
|
"popularity": 76,
|
||||||
|
"images": {
|
||||||
|
"banner": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png",
|
||||||
|
"icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png",
|
||||||
|
"screenshots": [
|
||||||
|
"https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "newpipe",
|
||||||
|
"package": "org.preview.newpipe",
|
||||||
|
"name": "NewPipe",
|
||||||
|
"summary": "Your videos and music, without the distractions.",
|
||||||
|
"description": "Your videos and music, without the distractions.",
|
||||||
|
"developer": "Team NewPipe",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"free": true,
|
||||||
|
"downloadable": true,
|
||||||
|
"version": "1.5",
|
||||||
|
"version_code": 1,
|
||||||
|
"min_sdk": 26,
|
||||||
|
"abis": [
|
||||||
|
"arm64-v8a"
|
||||||
|
],
|
||||||
|
"vr": false,
|
||||||
|
"updated": "2026-09-23",
|
||||||
|
"size": 85000000,
|
||||||
|
"popularity": 68,
|
||||||
|
"images": {
|
||||||
|
"banner": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png",
|
||||||
|
"icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png",
|
||||||
|
"screenshots": [
|
||||||
|
"https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kart",
|
||||||
|
"package": "org.preview.kart",
|
||||||
|
"name": "SuperTuxKart",
|
||||||
|
"summary": "A little friendly competition. A lot of colorful chaos.",
|
||||||
|
"description": "A little friendly competition. A lot of colorful chaos.",
|
||||||
|
"developer": "SuperTuxKart Team",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"free": true,
|
||||||
|
"downloadable": false,
|
||||||
|
"version": "1.6",
|
||||||
|
"version_code": 1,
|
||||||
|
"min_sdk": 26,
|
||||||
|
"abis": [
|
||||||
|
"arm64-v8a"
|
||||||
|
],
|
||||||
|
"vr": false,
|
||||||
|
"updated": "2026-09-22",
|
||||||
|
"size": 85000000,
|
||||||
|
"popularity": 60,
|
||||||
|
"images": {
|
||||||
|
"banner": "https://opengraph.githubassets.com/1/supertuxkart/stk-code",
|
||||||
|
"icon": null,
|
||||||
|
"screenshots": [
|
||||||
|
"https://opengraph.githubassets.com/1/supertuxkart/stk-code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"icon": null,
|
||||||
|
"page": "https://supertuxkart.net"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# F-Droid verification fixtures
|
||||||
|
|
||||||
|
`entry.jar` and `index-v1.jar` are synthetic RSA-2048/SHA-256 signed JARs,
|
||||||
|
including CMS signed attributes. `fingerprint.txt` identifies their throwaway
|
||||||
|
certificate. Their JSON describes org.example.app; `example.apk` is deliberately
|
||||||
|
plain test data, not an installable app. The v2 index includes incompatible
|
||||||
|
Android-31 and x86-only versions to exercise the shared reducer.
|
||||||
|
|
||||||
|
`izzy-entry.jar` was recorded from
|
||||||
|
https://apt.izzysoft.de/fdroid/repo/entry.jar on 2026-09-28. Its certificate
|
||||||
|
fingerprint matches the operator's published fingerprint:
|
||||||
|
3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A.
|
||||||
|
It exercises an independent production JAR/CMS encoder without network access.
|
||||||
|
The index it references is not needed by this signature-only fixture test.
|
||||||
|
|
||||||
|
`artwork-v1.json` and `artwork-v2.json` are unsigned metadata/reducer fixtures
|
||||||
|
based on the synthetic indexes above. They exercise en-US preference, per-field
|
||||||
|
locale fallback, v1 artwork paths, phone/tablet ordering, the six-image cap,
|
||||||
|
author names and HTML/multiline summaries. The signed integrity fixtures remain
|
||||||
|
unchanged; artwork tests feed these JSON files directly through the reducer and
|
||||||
|
then round-trip the resulting entries through the source cache.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"packageName": "org.example.app",
|
||||||
|
"name": "Example",
|
||||||
|
"license": "MIT",
|
||||||
|
"authorName": "Example Developer",
|
||||||
|
"summary": "Fallback summary",
|
||||||
|
"localized": {
|
||||||
|
"de": {
|
||||||
|
"name": "Beispiel",
|
||||||
|
"summary": "Deutsch",
|
||||||
|
"icon": "german.png",
|
||||||
|
"phoneScreenshots": [
|
||||||
|
"german.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"en-US": {
|
||||||
|
"name": "Example",
|
||||||
|
"summary": "Offline <b>fixture</b> & music.\n One\t line.",
|
||||||
|
"icon": "icon.png",
|
||||||
|
"phoneScreenshots": [
|
||||||
|
"1.png",
|
||||||
|
"2.png",
|
||||||
|
"3.png",
|
||||||
|
"4.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"featureGraphic": "featureGraphic.png",
|
||||||
|
"sevenInchScreenshots": [
|
||||||
|
"1.png",
|
||||||
|
"2.png",
|
||||||
|
"3.png",
|
||||||
|
"4.png"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages": {
|
||||||
|
"org.example.app": [
|
||||||
|
{
|
||||||
|
"versionName": "1",
|
||||||
|
"versionCode": 1,
|
||||||
|
"apkName": "example1.apk",
|
||||||
|
"hash": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
|
||||||
|
"hashType": "sha256",
|
||||||
|
"size": 51,
|
||||||
|
"minSdkVersion": 21
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"repo": {
|
||||||
|
"name": {
|
||||||
|
"en-US": "Fixture"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"org.example.app": {
|
||||||
|
"metadata": {
|
||||||
|
"name": {
|
||||||
|
"en-US": "Example"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"en-US": "<p>Offline <b>fixture</b> & music.</p>\n<p>One\t line.</p><script>hidden()</script>"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"authorName": "Example Developer",
|
||||||
|
"icon": {
|
||||||
|
"de": {
|
||||||
|
"name": "/org.example.app/de/icon.png"
|
||||||
|
},
|
||||||
|
"en-US": {
|
||||||
|
"name": "/org.example.app/en-US/icon.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"featureGraphic": {
|
||||||
|
"fr": {
|
||||||
|
"name": "/org.example.app/fr/featureGraphic.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"screenshots": {
|
||||||
|
"phone": {
|
||||||
|
"de": [
|
||||||
|
{
|
||||||
|
"name": "/org.example.app/de/phoneScreenshots/1.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"en-US": [
|
||||||
|
{
|
||||||
|
"name": "/org.example.app/en-US/phoneScreenshots/1.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "/org.example.app/en-US/phoneScreenshots/2.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "/org.example.app/en-US/phoneScreenshots/3.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "/org.example.app/en-US/phoneScreenshots/4.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sevenInch": {
|
||||||
|
"fr": [
|
||||||
|
{
|
||||||
|
"name": "/org.example.app/fr/sevenInchScreenshots/1.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "/org.example.app/fr/sevenInchScreenshots/2.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "/org.example.app/fr/sevenInchScreenshots/3.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "/org.example.app/fr/sevenInchScreenshots/4.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"1": {
|
||||||
|
"manifest": {
|
||||||
|
"versionName": "1",
|
||||||
|
"versionCode": 1,
|
||||||
|
"usesSdk": {
|
||||||
|
"minSdkVersion": 21
|
||||||
|
},
|
||||||
|
"nativecode": []
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"name": "/example1.apk",
|
||||||
|
"sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
|
||||||
|
"size": 51
|
||||||
|
},
|
||||||
|
"added": 1700000000000
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"manifest": {
|
||||||
|
"versionName": "2",
|
||||||
|
"versionCode": 2,
|
||||||
|
"usesSdk": {
|
||||||
|
"minSdkVersion": 30
|
||||||
|
},
|
||||||
|
"nativecode": [
|
||||||
|
"arm64-v8a"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"name": "/example2.apk",
|
||||||
|
"sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
|
||||||
|
"size": 51
|
||||||
|
},
|
||||||
|
"added": 1700000000000
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"manifest": {
|
||||||
|
"versionName": "3",
|
||||||
|
"versionCode": 3,
|
||||||
|
"usesSdk": {
|
||||||
|
"minSdkVersion": 31
|
||||||
|
},
|
||||||
|
"nativecode": []
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"name": "/example3.apk",
|
||||||
|
"sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
|
||||||
|
"size": 51
|
||||||
|
},
|
||||||
|
"added": 1700000000000
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"manifest": {
|
||||||
|
"versionName": "4",
|
||||||
|
"versionCode": 4,
|
||||||
|
"usesSdk": {
|
||||||
|
"minSdkVersion": 21
|
||||||
|
},
|
||||||
|
"nativecode": [
|
||||||
|
"x86_64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"name": "/example4.apk",
|
||||||
|
"sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
|
||||||
|
"size": 51
|
||||||
|
},
|
||||||
|
"added": 1700000000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Fixture APK payload, deliberately not installable.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0e87b227cd414d7093fb150fda81f1754900f3abc810e6785d8e0767c6eb798a
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"repo": {"name": {"en-US": "Fixture"}}, "packages": {"org.example.app": {"metadata": {"name": {"en-US": "Example"}, "summary": {"en-US": "Offline fixture"}, "license": "MIT"}, "versions": {"1": {"manifest": {"versionName": "1", "versionCode": 1, "usesSdk": {"minSdkVersion": 21}, "nativecode": []}, "file": {"name": "/example1.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "2": {"manifest": {"versionName": "2", "versionCode": 2, "usesSdk": {"minSdkVersion": 30}, "nativecode": ["arm64-v8a"]}, "file": {"name": "/example2.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "3": {"manifest": {"versionName": "3", "versionCode": 3, "usesSdk": {"minSdkVersion": 31}, "nativecode": []}, "file": {"name": "/example3.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "4": {"manifest": {"versionName": "4", "versionCode": 4, "usesSdk": {"minSdkVersion": 21}, "nativecode": ["x86_64"]}, "file": {"name": "/example4.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}}}}}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Library fixtures
|
||||||
|
|
||||||
|
`icon.png` is a synthetic 2×2 RGBA fixture with opaque, half-transparent and
|
||||||
|
transparent pixels. `steam-responses.json` includes the Open Saber Plus
|
||||||
|
shortcut ID/name and home path read from the Frame's existing metadata on
|
||||||
|
2026-09-28; the `configure` response is synthetic, matching our helper's
|
||||||
|
contract. Tests never contact the network.
|
||||||
|
|
||||||
|
The existing fakeframe CEF shim models artwork and collection methods from
|
||||||
|
SteamTracking's `ClientExtracted/steamui/chunk~2dcc5aaf7.js` and
|
||||||
|
SteamDeckHomebrew/decky-frontend-lib's `src/globals/steam-client/App.ts`, read
|
||||||
|
2026-09-28. These methods were not captured from this headset: Steam's client
|
||||||
|
was unavailable. The Node-based test runs the actual generated JavaScript
|
||||||
|
against that fixture; it is skipped when Node is absent.
|
||||||
|
|
||||||
|
`icon.jpg` is the same synthetic icon converted with macOS `sips` to exercise
|
||||||
|
JPEG SOF parsing. `sips` is not used by the product or tests.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const fs=require('fs'),vm=require('vm'),assert=require('assert');
|
||||||
|
const stub=require(process.cwd()+'/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub');
|
||||||
|
(async()=>{
|
||||||
|
const surface=stub.surface(),ctx=vm.createContext(surface);
|
||||||
|
vm.runInContext(fs.readFileSync('frame/android/library_artwork.js','utf8'),ctx);
|
||||||
|
const result=await ctx.renderLibraryArtwork({label:'Example Game',images:{icon:['png','fixture']}});
|
||||||
|
assert.deepEqual(Object.keys(result.images),['grid','wide','hero','logo','icon']);
|
||||||
|
for(const [slot,size] of Object.entries({grid:[600,900],wide:[920,430],hero:[3840,1240],logo:[1280,480],icon:[256,256]})) {
|
||||||
|
assert.equal(result.images[slot][0],'png');
|
||||||
|
const b=Buffer.from(result.images[slot][1],'base64');assert.equal(b.readUInt32BE(16),size[0]);assert.equal(b.readUInt32BE(20),size[1]);
|
||||||
|
}
|
||||||
|
// A photo scene is JPEG (Steam's 12 MiB limit at hero size); the logo stays transparent PNG.
|
||||||
|
const photo=await ctx.renderLibraryArtwork({label:'Photo',images:{hero:['jpg','fixture'],banner:['jpg','fixture']}});
|
||||||
|
for(const slot of ['wide','hero'])assert.equal(photo.images[slot][0],'jpg');
|
||||||
|
for(const slot of ['grid','logo','icon'])assert.equal(photo.images[slot][0],'png');
|
||||||
|
const hero=surface.canvases.find(c=>c.width===3840);assert.equal(hero.text.length,0);
|
||||||
|
const logo=surface.canvases.find(c=>c.width===1280);assert(logo.text.length);assert.equal(logo.draws,0);
|
||||||
|
const before=surface.canvases.length;await ctx.renderLibraryArtwork({label:'No Icon',images:{}});
|
||||||
|
assert.equal(surface.canvases.slice(before).find(c=>c.width===3840).text.length,0);
|
||||||
|
console.log('five dimensions, textless hero, title logo: OK');
|
||||||
|
})().catch(e=>{console.error(e);process.exit(1)});
|
||||||
|
After Width: | Height: | Size: 834 B |