Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5878c45ddb | ||
|
|
d773babaa3 | ||
|
|
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
|
||||
*.md text eol=lf
|
||||
*.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
|
||||
|
||||
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:
|
||||
needs: draft
|
||||
# Still runs for pull requests and manual runs, where the draft job is skipped.
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -40,15 +73,27 @@ jobs:
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
# The installer, not just the unpacked build: its 7-Zip payload once dropped
|
||||
# the OpenXR layer's arm64 library without any error. Install it silently
|
||||
# and compare every installed file with the unpacked build.
|
||||
- name: Check the Windows installer installs every file
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$setup = Get-ChildItem app/dist/Frame-Control-Setup-*.exe | Select-Object -First 1
|
||||
$p = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "installer exited $($p.ExitCode)" }
|
||||
$installed = Join-Path $env:LOCALAPPDATA 'Programs\Frame Control'
|
||||
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
|
||||
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
- name: Upload to the release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
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
|
||||
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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 |
|
||||
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games may not |
|
||||
| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
@@ -6,11 +6,25 @@ A report: package, version, result (runs | crashes | install_failed |
|
||||
instance_failed, from an automated test), rating (works | issues | broken, from
|
||||
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
|
||||
Newest wins, and a person's rating beats an automated result.
|
||||
|
||||
An install_failed report saying the file had no arm64-v8a build (or needed a
|
||||
newer Android) is about that one APK file, often the wrong per-ABI download of
|
||||
an app that has an arm64 build, so it says nothing about the app and is left out.
|
||||
"""
|
||||
import re
|
||||
|
||||
FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API')
|
||||
|
||||
|
||||
def about_app(r):
|
||||
"""False for reports about one wrong APK file rather than the app itself."""
|
||||
return not (r.get('result') == 'install_failed' and not r.get('rating')
|
||||
and FILE_FAULT.search(r.get('notes') or ''))
|
||||
|
||||
|
||||
def verdict(reports):
|
||||
"""(verdict, summary lines) for one package's reports, or None."""
|
||||
reports = [r for r in reports or () if about_app(r)]
|
||||
if not reports:
|
||||
return None
|
||||
rs = sorted(reports, key=lambda r: r.get('date') or '')
|
||||
|
||||
@@ -0,0 +1,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
|
||||
// a free loopback port and shows it in a native window. The server does all the
|
||||
// work over the `frame` SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
@@ -11,6 +11,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
const { createMetaSignIn } = require("./meta-signin");
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
@@ -149,7 +150,7 @@ async function startServer() {
|
||||
const target = `http://127.0.0.1:${port}/`;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
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));
|
||||
}
|
||||
if (server === child) server = null;
|
||||
@@ -159,51 +160,76 @@ async function startServer() {
|
||||
|
||||
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
||||
// 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) {
|
||||
const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve()
|
||||
: new Promise((resolve) => child.once("exit", resolve));
|
||||
try { child.stdin.end(); } catch {}
|
||||
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() {
|
||||
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 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">
|
||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
|
||||
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
|
||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
|
||||
<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);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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() {
|
||||
const old = server;
|
||||
server = null;
|
||||
url = null;
|
||||
if (old) endServer(old);
|
||||
await load();
|
||||
// Restarts that overlap share one: two could each start a server, and the one
|
||||
// that lost the lock would leave the app pointing at nothing.
|
||||
let restarting = null;
|
||||
function restartServer() {
|
||||
if (!restarting) {
|
||||
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.
|
||||
const CHROME_CSS = IS_MAC && `
|
||||
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
|
||||
// its server; only the newest load may touch the window.
|
||||
let loadGen = 0;
|
||||
let starting = null; // loads that overlap share one server start
|
||||
async function load() {
|
||||
const gen = ++loadGen;
|
||||
try {
|
||||
if (!url) await startServer();
|
||||
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
|
||||
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
||||
} catch (e) {
|
||||
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
||||
@@ -247,13 +273,66 @@ function fromUi(e) {
|
||||
} 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() : "");
|
||||
// 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.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:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
||||
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) => {
|
||||
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
|
||||
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
|
||||
@@ -269,6 +348,47 @@ ipcMain.handle("comfort:notify", (e, message) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Sign in with Meta (app/meta-signin.js, docs/meta-library.md) ----
|
||||
// The token goes from the sign-in window's own session straight to the server's
|
||||
// connect path; the page asks for it and only hears the server's answer.
|
||||
function postApi(apiPath, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url) return reject(new Error("Frame Control's server isn't running."));
|
||||
const data = JSON.stringify(body);
|
||||
const req = http.request(new URL(apiPath, url), {
|
||||
method: "POST", timeout: 60000,
|
||||
headers: { "Content-Type": "application/json", "X-Frame-UI": process.env.FRAME_UI_KEY || "1",
|
||||
"Content-Length": Buffer.byteLength(data) },
|
||||
}, (res) => {
|
||||
let text = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (c) => { text += c; });
|
||||
res.on("end", () => {
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(text); } catch {}
|
||||
resolve({ status: res.statusCode, data: parsed });
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
|
||||
req.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
let metaSignIn = null;
|
||||
const meta = () => (metaSignIn ||= createMetaSignIn({
|
||||
BrowserWindow, session, shell, parent: () => win,
|
||||
// The page only offers sign-in after its "I understand" box is ticked (checked again below).
|
||||
connect: (token, attempt) => postApi("/api/meta", { action: "connect", token, acknowledged: true, attempt }),
|
||||
cancel: (attempt) => postApi("/api/meta", { action: "cancel-signin", attempt }),
|
||||
}));
|
||||
ipcMain.handle("meta:signin", (e, acknowledged) => {
|
||||
if (!fromUi(e)) return null;
|
||||
if (acknowledged !== true) return { ok: false, error: "My Meta library is experimental: tick that you understand the warning first." };
|
||||
return meta().signIn();
|
||||
});
|
||||
ipcMain.handle("meta:signout", (e) => fromUi(e) ? meta().signOut() : false);
|
||||
|
||||
// frame-control://install links from websites (docs/web-install.md). They can
|
||||
// arrive before the window or server exists (macOS open-url on a cold launch),
|
||||
// so they wait here until the page asks for them. The page checks the link with
|
||||
@@ -425,13 +545,14 @@ async function runInTerminal(argv) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setUpConnection() {
|
||||
const alias = `FRAME_ALIAS=${FRAME}`;
|
||||
// Set Up Connection for the headset in use (or another alias, from the Devices tab).
|
||||
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")]);
|
||||
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
||||
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
|
||||
// A new console inherits our environment on Windows; Linux terminals may not.
|
||||
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
|
||||
// --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
|
||||
runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
|
||||
}
|
||||
|
||||
function buildMenu() {
|
||||
@@ -446,8 +567,15 @@ function buildMenu() {
|
||||
{
|
||||
label: "Frame",
|
||||
submenu: [
|
||||
{ label: "Set Up Connection…", click: setUpConnection },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
|
||||
{ label: "Set Up Connection…", click: () => setUpConnection() },
|
||||
{ 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" },
|
||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||
{ 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",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.5",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.5",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -29,6 +29,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"afterPack": "build/check-resources.js",
|
||||
"directories": {
|
||||
"output": "dist",
|
||||
"buildResources": "build"
|
||||
@@ -38,6 +39,7 @@
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
@@ -48,9 +50,18 @@
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.html",
|
||||
"*.js",
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../ui/apk_sources",
|
||||
"to": "ui/apk_sources",
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../scripts",
|
||||
"to": "scripts",
|
||||
@@ -63,7 +74,8 @@
|
||||
"to": "frame/android",
|
||||
"filter": [
|
||||
"*.sh",
|
||||
"*.py"
|
||||
"*.py",
|
||||
"*.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -71,7 +83,7 @@
|
||||
"to": "frame/openxr-compat",
|
||||
"filter": [
|
||||
"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
|
||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||
// as a title without zipping it (the local server reads it from there).
|
||||
// It can 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
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
// And it passes update state both ways: see app/updater.js.
|
||||
// And it passes update state both ways: see app/updater.js. It opens the Sign in
|
||||
// with Meta window and signs out of it (app/meta-signin.js).
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||
// 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.
|
||||
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
|
||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||
@@ -30,6 +40,12 @@ contextBridge.exposeInMainWorld("frameApp", {
|
||||
ipcRenderer.removeAllListeners("report:open");
|
||||
ipcRenderer.on("report:open", () => cb());
|
||||
},
|
||||
// Sign in with Meta (app/meta-signin.js): Meta's login page in its own window. The
|
||||
// answer is the server's (connected, display name), never the token.
|
||||
meta: {
|
||||
signIn: (acknowledged) => ipcRenderer.invoke("meta:signin", acknowledged === true),
|
||||
signOut: () => ipcRenderer.invoke("meta:signout"),
|
||||
},
|
||||
onInstallLink: (cb) => {
|
||||
ipcRenderer.removeAllListeners("install-link");
|
||||
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
// Run: node --test app/test/
|
||||
// "Sign in with Meta" (app/meta-signin.js) with fake Electron windows, sessions and cookies.
|
||||
const nodeTest = require("node:test");
|
||||
// A broken flow can leave a sign-in waiting forever; fail it instead of hanging.
|
||||
const test = (name, fn) => nodeTest(name, { timeout: 5000 }, fn);
|
||||
const assert = require("node:assert");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createMetaSignIn, allowedUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE } = require("../meta-signin");
|
||||
|
||||
const TOKEN = "OCAbcdefghijklmnopQRSTUVwxyz0123456789abcdEFGH";
|
||||
const OTHER = "OCzyxwvutsrqponmlkJIHGFEDcba9876543210zyxwVUTS";
|
||||
|
||||
class FakeCookies extends EventEmitter {
|
||||
constructor() { super(); this.store = []; this.reads = 0; }
|
||||
async get(filter) {
|
||||
this.reads++;
|
||||
return this.store.filter((c) => !filter.name || c.name === filter.name);
|
||||
}
|
||||
set(cookie) { // as the page would: store it, then tell listeners
|
||||
this.store = this.store.filter((c) => !(c.name === cookie.name && c.domain === cookie.domain)).concat(cookie);
|
||||
this.emit("changed", {}, cookie, "explicit", false);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSession {
|
||||
constructor(name, log) { this.name = name; this.log = log; this.cookies = new FakeCookies(); this.cleared = []; this.permission = null; }
|
||||
setPermissionRequestHandler(fn) { this.permission = fn; }
|
||||
setPermissionCheckHandler(fn) { this.permissionCheck = fn; }
|
||||
getUserAgent() { return "Mozilla/5.0 Chrome/140.0 frame-control/0.4.4 Electron/44.4.5 Safari/537.36"; }
|
||||
setUserAgent(ua) { this.ua = ua; }
|
||||
async clearStorageData(opts) { this.log.push("clear"); this.cleared.push(["storage", opts]); }
|
||||
async clearCache() { this.cleared.push(["cache"]); }
|
||||
async clearAuthCache() { this.cleared.push(["auth"]); }
|
||||
}
|
||||
|
||||
function fakes({ timeout } = {}) {
|
||||
const log = []; // the order things happened in
|
||||
const sessions = new Map();
|
||||
const defaultSession = new FakeSession("default", log);
|
||||
const session = {
|
||||
defaultSession,
|
||||
fromPartition(name) {
|
||||
if (!sessions.has(name)) sessions.set(name, new FakeSession(name, log));
|
||||
return sessions.get(name);
|
||||
},
|
||||
};
|
||||
const windows = [];
|
||||
class BrowserWindow extends EventEmitter {
|
||||
constructor(opts) {
|
||||
super();
|
||||
this.opts = opts;
|
||||
this.destroyed = false;
|
||||
this.veto = false; // a page whose beforeunload keeps the window open on close()
|
||||
this.focused = 0;
|
||||
const wc = new EventEmitter();
|
||||
wc.loaded = [];
|
||||
wc.loadURL = async (u) => { wc.loaded.push(u); };
|
||||
wc.setWindowOpenHandler = (fn) => { wc.openHandler = fn; };
|
||||
// Deliberately a different session: the code must read the partition's, not guess.
|
||||
wc.session = defaultSession;
|
||||
this.webContents = wc;
|
||||
windows.push(this);
|
||||
}
|
||||
isDestroyed() { return this.destroyed; }
|
||||
focus() { this.focused++; }
|
||||
close() { if (this.destroyed || this.veto) return; this.destroy(); }
|
||||
destroy() { if (this.destroyed) return; this.destroyed = true; log.push("destroyed"); this.emit("closed"); }
|
||||
}
|
||||
const opened = [];
|
||||
const shell = { openExternal: (u) => { opened.push(u); } };
|
||||
const calls = [], attempts = [], cancels = [];
|
||||
let answer = () => ({ status: 200, data: { connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" } });
|
||||
const connect = async (token, attempt) => { calls.push(token); attempts.push(attempt); log.push("connect"); return answer(token); };
|
||||
const cancel = async (attempt) => {
|
||||
cancels.push(attempt);
|
||||
log.push(`cancel (cookie listeners: ${session.fromPartition(PARTITION).cookies.listenerCount("changed")})`);
|
||||
};
|
||||
const flow = createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent: () => null, ...(timeout ? { timeout } : {}) });
|
||||
return { flow, session, sessions, windows, opened, calls, attempts, cancels, log, setAnswer: (fn) => { answer = fn; },
|
||||
meta: () => session.fromPartition(PARTITION) };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setImmediate(r));
|
||||
async function settle() { for (let i = 0; i < 10; i++) await tick(); }
|
||||
|
||||
function navigate(wc, event, target) {
|
||||
let prevented = false;
|
||||
wc.emit(event, { preventDefault: () => { prevented = true; } }, target);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test("only plain HTTPS addresses on Meta's login sites are allowed", () => {
|
||||
for (const ok of ["https://secure.oculus.com", "https://secure.oculus.com/my/profile", "https://auth.meta.com/login/",
|
||||
"https://www.meta.com/", "https://www.facebook.com/login.php", "https://oculus.com:443/",
|
||||
"https://www.instagram.com/accounts/login/"]) {
|
||||
assert.ok(allowedUrl(ok), ok);
|
||||
}
|
||||
for (const bad of ["http://secure.oculus.com/", "https://oculus.com.evil.example/", "https://evil-oculus.com/",
|
||||
"https://notmeta.com/", "https://example.com/", "javascript:alert(1)", "file:///etc/passwd",
|
||||
"https://secure.oculus.com:8443/", "https://user:pw@secure.oculus.com/", "https://evil.example/?u=https://www.meta.com",
|
||||
"data:text/html,hi", "not a url"]) {
|
||||
assert.ok(!allowedUrl(bad), bad);
|
||||
}
|
||||
});
|
||||
|
||||
test("the window is isolated: its own partition, no preload, no Node, sandboxed, on Meta's login page", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const [w] = f.windows;
|
||||
const wp = w.opts.webPreferences;
|
||||
assert.strictEqual(wp.partition, PARTITION);
|
||||
assert.strictEqual(PARTITION, "persist:meta-signin");
|
||||
assert.strictEqual(wp.contextIsolation, true);
|
||||
assert.strictEqual(wp.nodeIntegration, false);
|
||||
assert.strictEqual(wp.sandbox, true);
|
||||
assert.strictEqual(wp.preload, undefined);
|
||||
assert.strictEqual(wp.devTools, false);
|
||||
assert.deepStrictEqual(w.webContents.loaded, [LOGIN_URL]);
|
||||
assert.strictEqual(LOGIN_URL, "https://secure.oculus.com");
|
||||
// Permission prompts (camera, notifications...) are refused.
|
||||
let granted = null;
|
||||
f.meta().permission(null, "media", (v) => { granted = v; });
|
||||
assert.strictEqual(granted, false);
|
||||
assert.strictEqual(f.meta().permissionCheck(null, "notifications", "https://secure.oculus.com"), false);
|
||||
assert.ok(!/Electron|frame-control/.test(f.meta().ua));
|
||||
w.close();
|
||||
});
|
||||
|
||||
test("navigation stays on Meta's sites; other links open in the system browser", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
assert.strictEqual(navigate(wc, "will-navigate", "https://auth.meta.com/login/"), false);
|
||||
assert.strictEqual(navigate(wc, "will-navigate", "https://www.facebook.com/login.php"), false);
|
||||
assert.strictEqual(navigate(wc, "will-navigate", "https://example.com/help"), true);
|
||||
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
|
||||
// A redirect elsewhere is stopped and not opened anywhere (it may carry sign-in data).
|
||||
assert.strictEqual(navigate(wc, "will-redirect", "https://evil.example/cb?x=1"), true);
|
||||
assert.strictEqual(navigate(wc, "will-redirect", "https://auth.meta.com/next"), false);
|
||||
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
|
||||
// New windows: never created. Meta's own load in this window; others go outside; non-web schemes nowhere.
|
||||
assert.deepStrictEqual(wc.openHandler({ url: "https://www.meta.com/legal/terms/" }), { action: "deny" });
|
||||
assert.deepStrictEqual(wc.openHandler({ url: "https://example.org/" }), { action: "deny" });
|
||||
assert.deepStrictEqual(wc.openHandler({ url: "file:///etc/passwd" }), { action: "deny" });
|
||||
await settle();
|
||||
assert.deepStrictEqual(wc.loaded, [LOGIN_URL, "https://www.meta.com/legal/terms/"]);
|
||||
assert.deepStrictEqual(f.opened, ["https://example.com/help", "https://example.org/"]);
|
||||
f.windows[0].close();
|
||||
});
|
||||
|
||||
test("the cookie is read only from the sign-in partition, only for Meta's Oculus domain", async () => {
|
||||
const f = fakes();
|
||||
// The same cookie elsewhere: the default session and another partition. Never read.
|
||||
f.session.defaultSession.cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
f.session.fromPartition("persist:other").cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
const done = f.flow.signIn();
|
||||
const w = f.windows[0];
|
||||
// A cookie with the right name on another site, and a signed-out value: ignored.
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: OTHER });
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: "OC123456789012345" });
|
||||
w.webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
const r = await done;
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.strictEqual(f.session.defaultSession.cookies.reads, 0);
|
||||
assert.strictEqual(f.session.fromPartition("persist:other").cookies.reads, 0);
|
||||
assert.ok(f.sessions.get(PARTITION).cookies.reads > 0);
|
||||
});
|
||||
|
||||
test("landing on the logged-in page picks up a cookie set earlier", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.store.push({ name: COOKIE, domain: "secure.oculus.com", value: TOKEN }); // no change event
|
||||
f.windows[0].webContents.emit("did-navigate", {}, "https://auth.meta.com/login/"); // not Meta's store site yet
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
|
||||
assert.strictEqual((await done).ok, true);
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
});
|
||||
|
||||
test("after a connect the window closes, and the page gets the answer, never the token", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
const r = await done;
|
||||
assert.ok(f.windows[0].isDestroyed());
|
||||
assert.ok(!f.flow.isOpen());
|
||||
assert.deepStrictEqual(r, { ok: true, connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" });
|
||||
assert.ok(!JSON.stringify(r).includes(TOKEN));
|
||||
// The cookie listener is gone: a later change sends nothing.
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
});
|
||||
|
||||
test("a token the server refuses keeps the window open, is sent once, and the next one connects", async () => {
|
||||
const f = fakes();
|
||||
f.setAnswer((t) => t === TOKEN ? { status: 400, data: { error: "Meta didn't accept this token." } }
|
||||
: { status: 200, data: { connected: true, user: null } });
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile"); // same value again
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
assert.ok(!f.windows[0].isDestroyed());
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
const r = await done;
|
||||
assert.deepStrictEqual(f.calls, [TOKEN, OTHER]);
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.strictEqual(r.user, null);
|
||||
});
|
||||
|
||||
test("the token is never logged, and an error that echoed it is scrubbed", async () => {
|
||||
const seen = [];
|
||||
const names = ["log", "info", "warn", "error", "debug", "trace"];
|
||||
const saved = Object.fromEntries(names.map((n) => [n, console[n]]));
|
||||
const writes = [process.stdout.write, process.stderr.write];
|
||||
for (const n of names) console[n] = (...a) => { seen.push(a.map(String).join(" ")); };
|
||||
process.stdout.write = (c, ...rest) => { seen.push(String(c)); return writes[0].call(process.stdout, c, ...rest); };
|
||||
process.stderr.write = (c, ...rest) => { seen.push(String(c)); return writes[1].call(process.stderr, c, ...rest); };
|
||||
let r;
|
||||
try {
|
||||
const f = fakes();
|
||||
f.setAnswer((t) => ({ status: 500, data: { error: `RuntimeError: bad ${t} and ${t.slice(0, 12)}` } }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close(); // the user gives up
|
||||
r = await done;
|
||||
} finally {
|
||||
for (const n of names) console[n] = saved[n];
|
||||
[process.stdout.write, process.stderr.write] = writes;
|
||||
}
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.strictEqual(r.cancelled, true);
|
||||
assert.ok(!r.error.includes(TOKEN) && !r.error.includes(TOKEN.slice(0, 12)), r.error);
|
||||
assert.match(r.error, /<redacted>/);
|
||||
assert.ok(!seen.some((s) => s.includes(TOKEN.slice(0, 12))), "token in output");
|
||||
assert.strictEqual(scrub(`x FRL${"a".repeat(30)} y`), "x <redacted> y");
|
||||
});
|
||||
|
||||
test("closing the window before signing in reports it, and a second click reuses the open window", async () => {
|
||||
const f = fakes();
|
||||
const a = f.flow.signIn();
|
||||
const b = f.flow.signIn();
|
||||
assert.strictEqual(a, b);
|
||||
assert.strictEqual(f.windows.length, 1);
|
||||
assert.strictEqual(f.windows[0].focused, 1);
|
||||
f.windows[0].close();
|
||||
const r = await a;
|
||||
assert.deepStrictEqual(r, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test("disconnect's sign-out closes the window and clears only the sign-in partition", async () => {
|
||||
const f = fakes();
|
||||
f.session.fromPartition("persist:other");
|
||||
const pending = f.flow.signIn();
|
||||
await f.flow.signOut();
|
||||
const r = await pending;
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.ok(f.windows[0].isDestroyed());
|
||||
assert.deepStrictEqual(f.meta().cleared, [["storage", undefined], ["cache"], ["auth"]]);
|
||||
assert.deepStrictEqual(f.session.defaultSession.cleared, []);
|
||||
assert.deepStrictEqual(f.session.fromPartition("persist:other").cleared, []);
|
||||
// The next sign-in starts with a new window.
|
||||
f.flow.signIn();
|
||||
assert.strictEqual(f.windows.length, 2);
|
||||
f.windows[1].close();
|
||||
});
|
||||
|
||||
test("the first look at a cookie value matches the server's token rules", () => {
|
||||
assert.ok(looksLikeUserToken(TOKEN));
|
||||
assert.ok(looksLikeUserToken("FRL" + "aB3".repeat(10)));
|
||||
for (const bad of [undefined, "", "OC123456789012345", "OC|123|abc", "OCabc%2Fdef" + "x".repeat(20), "OCshort",
|
||||
"XY" + "a".repeat(30), "OC" + "A".repeat(30)]) {
|
||||
assert.ok(!looksLikeUserToken(bad), String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
function frameNav(wc, url, isMainFrame) {
|
||||
let prevented = false;
|
||||
wc.emit("will-frame-navigate", { url, isMainFrame, preventDefault: () => { prevented = true; } });
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test("the window stays on Meta's sites; frames inside it may load any HTTPS page, nothing else", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
// Frames: captcha and CDN frames from other sites load; other schemes are stopped.
|
||||
for (const ok of ["https://client-api.arkoselabs.com/fc/gc/", "https://newassets.hcaptcha.com/captcha/v1/x",
|
||||
"https://www.google.com/recaptcha/api2/anchor", "https://static.xx.fbcdn.net/x",
|
||||
"https://www.facebook.com/plugin", "about:blank", "about:srcdoc"]) {
|
||||
assert.strictEqual(frameNav(wc, ok, false), false, ok);
|
||||
}
|
||||
for (const bad of ["http://www.facebook.com/plugin", "data:text/html,<script>x</script>", "blob:https://evil.example/1",
|
||||
"javascript:alert(1)", "file:///etc/passwd", "ftp://example.com/", "https://u:p@evil.example/",
|
||||
"chrome://settings", "not a url"]) {
|
||||
assert.strictEqual(frameNav(wc, bad, false), true, bad);
|
||||
}
|
||||
// The window itself: Meta's sites only.
|
||||
assert.strictEqual(frameNav(wc, "about:blank", true), true);
|
||||
assert.strictEqual(frameNav(wc, "https://evil.example/", true), true);
|
||||
assert.strictEqual(frameNav(wc, "https://client-api.arkoselabs.com/", true), true);
|
||||
assert.strictEqual(frameNav(wc, "https://auth.meta.com/login/", true), false);
|
||||
assert.deepStrictEqual(f.opened, []); // stopped, never opened outside
|
||||
f.windows[0].destroy();
|
||||
});
|
||||
|
||||
function redirect(wc, url, isMainFrame, style) {
|
||||
let prevented = false;
|
||||
const e = { preventDefault: () => { prevented = true; } };
|
||||
if (style === "details") wc.emit("will-redirect", Object.assign(e, { url, isMainFrame }));
|
||||
else wc.emit("will-redirect", e, url, false, isMainFrame);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test("redirects follow the same rule per frame", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
for (const style of ["details", "positional"]) {
|
||||
// A captcha frame redirected within HTTPS loads; a frame redirected to data: or http: doesn't.
|
||||
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", false, style), false, style);
|
||||
assert.strictEqual(redirect(wc, "https://newassets.hcaptcha.com/x", false, style), false, style);
|
||||
assert.strictEqual(redirect(wc, "data:text/html,<b>x</b>", false, style), true, style);
|
||||
assert.strictEqual(redirect(wc, "http://www.facebook.com/x", false, style), true, style);
|
||||
assert.strictEqual(redirect(wc, "javascript:alert(1)", false, style), true, style);
|
||||
// The window: Meta's sites only.
|
||||
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", true, style), true, style);
|
||||
assert.strictEqual(redirect(wc, "https://auth.meta.com/next", true, style), false, style);
|
||||
}
|
||||
// Without frame information it is treated as the window.
|
||||
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/", undefined, "positional"), true);
|
||||
assert.deepStrictEqual(f.opened, []);
|
||||
f.windows[0].destroy();
|
||||
});
|
||||
|
||||
test("a redirect the window stops is named when the window is closed", async () => {
|
||||
const f = fakes();
|
||||
const p = f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
assert.strictEqual(redirect(wc, "https://www.instagram.com/x?next=1", true, "details"), false); // Meta's login goes here
|
||||
assert.strictEqual(redirect(wc, "https://login.evil.example/cb?code=SECRET", true, "details"), true);
|
||||
f.windows[0].close();
|
||||
const r = await p;
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.match(r.error, /went to login\.evil\.example, which Frame Control doesn't allow/);
|
||||
assert.doesNotMatch(r.error, /SECRET|code=/);
|
||||
});
|
||||
|
||||
test("a stopped redirect is forgotten once the window loads a page again", async () => {
|
||||
const f = fakes();
|
||||
const p = f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
assert.strictEqual(redirect(wc, "https://login.evil.example/cb", true, "details"), true);
|
||||
wc.emit("did-navigate", {}, "https://auth.meta.com/login/"); // the user carried on
|
||||
f.windows[0].close();
|
||||
assert.deepStrictEqual(await p, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
|
||||
});
|
||||
|
||||
test("a page's beforeunload can't keep the window open", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
const w = f.windows[0];
|
||||
let prevented = false;
|
||||
w.webContents.emit("will-prevent-unload", { preventDefault: () => { prevented = true; } });
|
||||
assert.ok(prevented);
|
||||
// Even if close() would be vetoed, finishing destroys the window.
|
||||
w.veto = true;
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
assert.strictEqual((await done).ok, true);
|
||||
assert.ok(w.isDestroyed());
|
||||
});
|
||||
|
||||
test("closing during a connect: the cancel goes to the server only after that connect settles", async () => {
|
||||
for (const outcome of ["saved", "refused", "failed"]) {
|
||||
const f = fakes();
|
||||
let release, fail;
|
||||
f.setAnswer(() => new Promise((r, j) => { release = r; fail = j; }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
f.windows[0].close(); // the user gives up while the server and Meta are answering
|
||||
const r = await done; // the page hears at once
|
||||
assert.strictEqual(r.ok, false);
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.cancels, [], outcome); // not before the connect has settled
|
||||
if (outcome === "saved") release({ status: 200, data: { connected: true } });
|
||||
else if (outcome === "refused") release({ status: 400, data: { error: "no" } });
|
||||
else fail(new Error("socket hang up"));
|
||||
await settle();
|
||||
// The same attempt id the connect carried, after it settled and after cookie intake stopped.
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]], outcome);
|
||||
assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/);
|
||||
assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"], outcome);
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
}
|
||||
});
|
||||
|
||||
test("closing before any connect needs no cancel", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: TOKEN }); // nothing usable
|
||||
await settle();
|
||||
f.windows[0].close();
|
||||
assert.strictEqual((await done).ok, false);
|
||||
await f.flow.signOut();
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
});
|
||||
|
||||
test("a timeout during a connect cancels after it settles", async () => {
|
||||
const f = fakes({ timeout: 50 });
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; })); // Meta is slow
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
const r = await done;
|
||||
assert.match(r.error, /timed out/);
|
||||
assert.ok(f.windows[0].isDestroyed());
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
release({ status: 400, data: { error: "no" } });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
});
|
||||
|
||||
test("disconnect during a connect waits for it, cancels it, then clears, before the page deletes the account", async () => {
|
||||
const f = fakes();
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.log.length = 0;
|
||||
let signedOut = false;
|
||||
const out = f.flow.signOut().then(() => { signedOut = true; }); // the page's Disconnect, step one
|
||||
assert.strictEqual((await done).ok, false);
|
||||
await settle();
|
||||
assert.strictEqual(signedOut, false); // still waiting for the connect
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
release({ status: 200, data: { connected: true } }); // it saved, just now
|
||||
await out;
|
||||
// Only now would the page post its disconnect: the cancel (which deletes what that
|
||||
// connect saved) has reached the server, and the session is cleared.
|
||||
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
});
|
||||
|
||||
test("a later disconnect still waits for a closed window's connect", async () => {
|
||||
const f = fakes();
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close();
|
||||
await done;
|
||||
let signedOut = false;
|
||||
const out = f.flow.signOut().then(() => { signedOut = true; });
|
||||
await settle();
|
||||
assert.strictEqual(signedOut, false);
|
||||
release({ status: 400, data: { error: "no" } });
|
||||
await out;
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
});
|
||||
|
||||
test("nothing sent means nothing to cancel; sign-out destroys the window, cancels, then clears", async () => {
|
||||
const f = fakes();
|
||||
// Nothing sent: closing cancels nothing.
|
||||
const a = f.flow.signIn();
|
||||
f.windows[0].close();
|
||||
await a;
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
// Something sent, then signed out: the attempt is cancelled before the session is cleared.
|
||||
f.setAnswer(() => ({ status: 400, data: { error: "Meta didn't accept this token." } }));
|
||||
const b = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[1].veto = true; // and its page tries to stay open
|
||||
f.log.length = 0;
|
||||
await f.flow.signOut();
|
||||
assert.strictEqual((await b).ok, false);
|
||||
assert.ok(f.windows[1].isDestroyed());
|
||||
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
// After sign-out a new cookie in the partition sends nothing.
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
});
|
||||
|
||||
test("each sign-in has its own attempt id", async () => {
|
||||
const f = fakes();
|
||||
f.setAnswer(() => ({ status: 400, data: { error: "no" } }));
|
||||
const a = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close();
|
||||
await a;
|
||||
const b = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
f.windows[1].close();
|
||||
await b;
|
||||
await settle();
|
||||
assert.strictEqual(f.attempts.length, 2);
|
||||
assert.notStrictEqual(f.attempts[0], f.attempts[1]);
|
||||
assert.deepStrictEqual(f.cancels, f.attempts);
|
||||
});
|
||||
|
||||
test("no sign-in can start while a sign-out waits on an earlier connect", async () => {
|
||||
const f = fakes();
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; }));
|
||||
const first = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
const out = f.flow.signOut(); // Disconnect pressed while that connect is under way
|
||||
assert.strictEqual((await first).ok, false);
|
||||
assert.ok(f.flow.isSigningOut());
|
||||
// The page answered, so the user tries again at once: refused, no window, nothing sent.
|
||||
const second = await f.flow.signIn();
|
||||
assert.deepStrictEqual(second, { ok: false, error: "Frame Control is signing out of Meta; try again in a moment." });
|
||||
assert.strictEqual(f.windows.length, 1);
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
// A second sign-out joins the first.
|
||||
assert.strictEqual(f.flow.signOut(), out);
|
||||
release({ status: 200, data: { connected: true } });
|
||||
await out;
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
assert.strictEqual(f.meta().cleared.filter(([k]) => k === "storage").length, 1);
|
||||
assert.ok(!f.flow.isSigningOut());
|
||||
// Once it is done, signing in works again.
|
||||
f.setAnswer(() => ({ status: 200, data: { connected: true } }));
|
||||
const third = f.flow.signIn();
|
||||
assert.strictEqual(f.windows.length, 2);
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
assert.strictEqual((await third).ok, true);
|
||||
});
|
||||
|
||||
test("sign-out goes round until no window and no connect or cancel is left", async () => {
|
||||
const f = fakes();
|
||||
let releaseA;
|
||||
f.setAnswer(() => new Promise((r) => { releaseA = r; }));
|
||||
const a = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close(); // window A closed; its cancel waits on its connect
|
||||
await a;
|
||||
// Window B is open, with its own connect under way, when sign-out starts.
|
||||
let releaseB;
|
||||
f.setAnswer(() => new Promise((r) => { releaseB = r; }));
|
||||
const b = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
let finished = false;
|
||||
const out = f.flow.signOut().then(() => { finished = true; });
|
||||
await b;
|
||||
releaseB({ status: 400, data: { error: "no" } });
|
||||
await settle();
|
||||
assert.strictEqual(finished, false); // A's connect is still under way
|
||||
releaseA({ status: 200, data: { connected: true } });
|
||||
await out;
|
||||
assert.deepStrictEqual([...f.cancels].sort(), [...f.attempts].sort());
|
||||
assert.strictEqual(f.attempts.length, 2);
|
||||
assert.strictEqual(f.log[f.log.length - 1], "clear");
|
||||
});
|
||||
@@ -51,6 +51,27 @@ test("update.json assets always download from this repository's release", () =>
|
||||
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 () => {
|
||||
const { prepare } = require("../updater");
|
||||
const release = { version: "0.3.1", assets: [] };
|
||||
|
||||
@@ -106,12 +106,24 @@ async function getJson(url, headers) {
|
||||
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 }.
|
||||
function fromManifest(m) {
|
||||
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, "")}/`;
|
||||
return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
|
||||
page: m.page || RELEASES,
|
||||
const version = String(m.version).replace(/^v/i, "");
|
||||
const base = `https://github.com/${REPO}/releases/download/v${version}/`;
|
||||
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: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
|
||||
size: a.size, digest: a.digest || null })) };
|
||||
@@ -119,8 +131,9 @@ function fromManifest(m) {
|
||||
|
||||
function fromApi(r) {
|
||||
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),
|
||||
page: r.html_url || RELEASES,
|
||||
const version = String(r.tag_name || "").replace(/^v/i, "");
|
||||
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,
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
(`lepton-steamlaunch-<id>`, ADB on 5556, 5557, …).
|
||||
|
||||
Split APKs (a base plus `split_*.apk` files) install as one app:
|
||||
`python3 ui/frame_android.py install base.apk split1.apk …`. Lepton runs one
|
||||
APK, so splits that add native libraries or assets are merged into the base
|
||||
and re-signed; density and language splits are refused. See
|
||||
[quest-import.md](quest-import.md#split-apks). Not yet tried on a real Frame.
|
||||
|
||||
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
|
||||
from the shortcut, stopped, and relaunched with their data intact. ADB and
|
||||
Lepton Development aren't involved.
|
||||
@@ -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.
|
||||
Lepton Development rebuilds its Android data on exit, so there they probably
|
||||
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,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has four tabs: **Home** (headset view, status, screenshots),
|
||||
The window has five tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports) and **Tools** (sending files and text,
|
||||
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
|
||||
anywhere in the window. When the Frame can't be reached, one banner says why in
|
||||
plain words and the app retries every few seconds, filling everything in once it
|
||||
answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset, which network this
|
||||
computer is on, the address in use or being tried, and each step of connecting
|
||||
as it happens; click it for the whole timeline. When the Frame can't be
|
||||
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.
|
||||
|
||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||
@@ -78,10 +81,20 @@ counts them while they run.
|
||||
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
|
||||
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
||||
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
|
||||
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
|
||||
asks for the sudo password over SSH.
|
||||
Linux). Remote desktop first checks that the Frame's xrdp answers on port
|
||||
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.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -101,7 +114,9 @@ Frame for the keyboard and trackpad.
|
||||
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
|
||||
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.
|
||||
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
|
||||
|
||||
|
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.
|
||||
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
|
||||
|
||||
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
# 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. **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`). 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.
|
||||
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
||||
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
||||
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
||||
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
|
||||
- **Optional overlay tools:** Desktop+, OVR Toolkit and similar software are
|
||||
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
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ computer, exactly as they were sent.
|
||||
yours.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
- `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
|
||||
|
||||
@@ -41,15 +47,25 @@ computer, exactly as they were sent.
|
||||
| `app_installed` | First run | |
|
||||
| `app_updated` | First run of a new version | `from_version` |
|
||||
| `app_opened` | At most once a day | |
|
||||
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
|
||||
| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` |
|
||||
| `tab_viewed` | The first click on each tab in a session | `tab` |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category`, `installer_code`, and see below |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
|
||||
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
|
||||
|
||||
`install_finished` never includes a file name, path or error message. An
|
||||
error becomes one category from a fixed list (for example `apk_wrong_abi` or
|
||||
`frame_unreachable`), plus Android's own `INSTALL_FAILED_…` code when there
|
||||
is one. It names what was installed only when that's already public:
|
||||
error becomes one category from this fixed list, plus Android's own
|
||||
`INSTALL_FAILED_…` code (`installer_code`) when there is one:
|
||||
`android_installer`, `apk_needs_newer_android`, `apk_wrong_abi`,
|
||||
`layer_missing`, `apk_repack_failed`, `tool_missing`, `apk_unreadable`,
|
||||
`cant_run_on_frame`, `steam_shortcut`, `steam_not_running`,
|
||||
`steam_not_responding`, `steam_js_error`, `artwork_failed`, `copy_timeout`,
|
||||
`frame_not_set_up`, `frame_auth`, `frame_unreachable`, `frame_disk_full`,
|
||||
`copy_failed`, `download_failed`, `flatpak`, `cancelled`, `lepton`,
|
||||
`internal_error` (a mistake in Frame Control's own code) or `other`. A VR
|
||||
APK that installed without Frame Control's OpenXR compatibility layer,
|
||||
because this copy of the app is missing it, carries `xr_layer_missing: true`;
|
||||
otherwise that field is left out. It names what was installed only when
|
||||
that's already public:
|
||||
|
||||
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
|
||||
catalogue app's package name
|
||||
@@ -67,13 +83,21 @@ SteamOS and Lepton builds. Before sending:
|
||||
|
||||
- the notes, app name and version are scrubbed like error messages (see
|
||||
below)
|
||||
- the APK's source is kept only if it's `F-Droid` or the public host name of
|
||||
a download link (`https://example.com/…`). File names, user names,
|
||||
- the APK's source is kept only if it's `F-Droid`, `meta-store` (a test
|
||||
from [My Meta library](meta-library.md)) or the public host name of a
|
||||
download link (`https://example.com/…`). File names, user names,
|
||||
passwords, ports, paths, IP addresses and local host names are dropped
|
||||
|
||||
When you turn this on, reports you made earlier on this computer are shared
|
||||
too.
|
||||
|
||||
My Meta library (Experimental) keeps your Meta access token in
|
||||
`meta-account.json` in this data folder, owner-only, and sends it only to
|
||||
Meta's API and download hosts. It is never in analytics, reports, error
|
||||
details or logs; its test reports carry the package, version, version code,
|
||||
title, result and the reason, nothing from your account. See
|
||||
[meta-library.md](meta-library.md#your-token-and-privacy).
|
||||
|
||||
The maintainer's `python3 ui/frame_compat_db.py sync` copies these events
|
||||
into the compatibility database, marked `via=community…`. It takes at most
|
||||
30 per reporter per day.
|
||||
@@ -93,7 +117,11 @@ scrubbed:
|
||||
names, passwords, ports, paths and queries are dropped
|
||||
- `token=`, `key=`, `password=` and similar values are replaced
|
||||
|
||||
The same error is sent at most once every 10 minutes.
|
||||
The same error is sent at most once every 10 minutes. When ssh reports that
|
||||
it couldn't reach the Frame (asleep, away, or not set up yet), or that ssh and
|
||||
the Frame refused each other's keys (a changed host key, or this computer's
|
||||
key not accepted), that is sent at most once per kind each time Frame Control
|
||||
runs.
|
||||
|
||||
## Report a problem
|
||||
|
||||
@@ -103,9 +131,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
|
||||
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
|
||||
wrote, how to reach you if you gave it, a short reference shown after sending,
|
||||
and the diagnostics below. It has its own random id, so it isn't linked to
|
||||
your analytics events.
|
||||
wrote, a short reference shown after sending, and the diagnostics below. Your
|
||||
email address goes with it only if you tick **The maintainer may contact me
|
||||
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:
|
||||
|
||||
@@ -113,10 +150,56 @@ With **Include diagnostics** ticked (the default), the report adds:
|
||||
- the OS, its release and CPU, and the Python version
|
||||
- the Frame's SteamOS build, if it has connected since the app started
|
||||
- which analytics levels are on
|
||||
- a short connection summary, so "the app can't find the headset" can be
|
||||
diagnosed. It is made of fixed words and counts only; no text from an
|
||||
error message or from ssh, and no name you chose, goes in it:
|
||||
- the connector's state (idle, connecting, connected or failed), the stage
|
||||
it failed at (network, find, ssh, identity or login), the attempt number
|
||||
and why it started (such as "Starting up" or "Trying again")
|
||||
- how many headsets are saved; whether the active one's ssh alias is the
|
||||
default `frame` or a custom one (a custom alias itself is never named);
|
||||
whether it's saved or a bare ssh alias; and its number of addresses
|
||||
- for the current error and the last failure: the stage and an error
|
||||
category (the same fixed names as error details, such as
|
||||
`frame_unreachable`, `frame_not_set_up`, `frame_auth` or `other`), and
|
||||
how long ago the last failure was. The category is decided when the
|
||||
failure happens
|
||||
- for each address tried, only its kind (`.local`, `ipv4`, `ipv6`,
|
||||
`ipv6 link-local`, `tailscale`, `hostname`, `alias` for one read from
|
||||
`~/.ssh/config`, and what a name resolved to, such as
|
||||
`.local->ipv6 link-local`) and how the try went (answered, unresolved,
|
||||
timeout, refused, unreachable, sshfailed). Never the address itself
|
||||
- when connected, the kind of address used and its round trip in ms
|
||||
- whether this computer has a network gateway, and whether Tailscale is on,
|
||||
off or not installed
|
||||
- which kind of `ssh` the app runs (Windows OpenSSH in System32, OpenSSH in
|
||||
Program Files, Git for Windows, MSYS2/Cygwin, Homebrew or /usr/local,
|
||||
Nix, the system one, or "other"), never its path; its version, rebuilt
|
||||
from the numbers in the banner `ssh -V` prints (such as
|
||||
`OpenSSH for Windows 9.5p1, LibreSSL 3.8.2`; anything that isn't a
|
||||
plain OpenSSH banner is reported as `unknown`); and the kinds of any
|
||||
other `ssh` on the PATH.
|
||||
This is looked up once in the background after the app starts, so a
|
||||
report sent straight away may say "still being checked"
|
||||
- whether `~/.ssh/config` exists, whether it has Set Up Connection's
|
||||
managed block for the active alias, how many managed blocks it has, and
|
||||
whether a hand-written `Host` line also names the alias (yes or no; the
|
||||
alias isn't named)
|
||||
|
||||
**Also include recent activity and the server log** is off by default,
|
||||
because those lines can name files and apps. When ticked, it adds the newest
|
||||
Activity lines and server log lines, without the request lines.
|
||||
Activity lines and server log lines, without the request lines. The server
|
||||
log has a line for each failed connection attempt: its stage, the message
|
||||
shown on the connection pill, ssh's last line about it, and the address kinds
|
||||
above. That free text is scrubbed when the line is written: the addresses, ssh alias
|
||||
and display name of the headset being tried (whole, whatever their case), and
|
||||
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
|
||||
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
|
||||
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
|
||||
included) becomes `<user>@<host>:`; then the scrubbing below. A host name ssh mentions
|
||||
in some other wording can still get through, so check the log lines in **Show
|
||||
exactly what's included** before sending. A failure that repeats on every
|
||||
retry is written at most once every 5 minutes.
|
||||
|
||||
Everything is scrubbed like error details and limited to what fits in the
|
||||
report. Environment details are kept first, then the newest lines. **Show
|
||||
@@ -128,11 +211,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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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:
|
||||
|
||||
```sh
|
||||
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
|
||||
scripts/publish-release.sh v0.4.0
|
||||
```
|
||||
|
||||
The script checks that all eight installers are attached, each with the
|
||||
SHA-256 digest GitHub records. It attaches `update.json` (the version, the
|
||||
notes and each installer's digest), then publishes the release and marks it
|
||||
latest. From then on, running copies see the update. They check about 8
|
||||
The script checks that the tag is on GitHub and that all eight installers
|
||||
are attached, each with the SHA-256 digest GitHub records. It attaches
|
||||
`update.json` (the version, the notes, the release page and each
|
||||
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
|
||||
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.
|
||||
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
|
||||
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
|
||||
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
|
||||
|
||||
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,
|
||||
while grabbing the root gives black. It streams as H.264 like the headset view
|
||||
(about 30 fps at 720p).
|
||||
- **When no panel is found**, `frame_touch.py panels` says why (`"why"`), and the
|
||||
Desktop view shows that instead of a bare "no app panel": X tools missing,
|
||||
gamescope listing windows that aren't X11 windows on any display, the desktop
|
||||
running but not listed, or nothing open. It reads gamescope's properties from
|
||||
`:0` first (one `xprop` call) and probes the other displays, 2 s each, only if `:0`
|
||||
has none. A whole run stops after 12 s, inside the server's 20 s, and says the headset
|
||||
was too slow. Added for
|
||||
[#73](https://github.com/saphid/frame-control/issues/73) (SteamOS 0.4.5, desktop
|
||||
open but not found). The likely cause there was the page itself: a second
|
||||
`loadPanels` (the panel switcher's) replaced the Desktop view's, fixed in #84.
|
||||
- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
|
||||
text box in the panel and the text field typed into it; a trackpad move went
|
||||
exactly (+40, +25).
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
||||
(`libovrplatformloader.so`) to check the Quest store licence need Meta's
|
||||
services. Frame Control won't work around that.
|
||||
services. Frame Control won't work around that: no patching, no stub
|
||||
libraries, no OVRPort or FramePort. Linking the SDK doesn't mean an app
|
||||
enforces the check (Meta's VRC.Quest.Security.1 has only been
|
||||
*Recommended* since 2021, and a failed check does nothing by itself; the app
|
||||
decides), so [Import from my Quest](quest-import.md) and [My Meta
|
||||
library](meta-library.md) install these with a warning: they may quit,
|
||||
hang, or run without online features.
|
||||
- **VrApi-era apps** (`libvrapi.so`, before OpenXR) need an API translator,
|
||||
not a patch.
|
||||
not a patch. They install with a warning that they may not run: some
|
||||
OpenXR games ship `libvrapi.so` without depending on it.
|
||||
|
||||
## Frame Control does this for you
|
||||
|
||||
@@ -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.
|
||||
`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
|
||||
`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`.
|
||||
If this copy of Frame Control is missing the layer's library (an incomplete
|
||||
install, or a file removed after installing), VR apps install without it and
|
||||
the install message says so; OpenXR 1.0 apps still run. Release builds fail
|
||||
to package without it (`app/build/check-resources.js`).
|
||||
|
||||
Verified on the headset (2026-09-28):
|
||||
|
||||
|
||||
@@ -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
|
||||
`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.
|
||||
|
||||
@@ -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.
|
||||
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"
|
||||
# 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"
|
||||
@@ -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
|
||||
# and SSH don't have; give it its own session instead.
|
||||
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 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
|
||||
"""
|
||||
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'
|
||||
|
||||
@@ -22,10 +24,10 @@ def target_ws():
|
||||
class WS:
|
||||
"""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 = 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()
|
||||
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'
|
||||
@@ -69,8 +71,8 @@ class WS:
|
||||
return msg.decode()
|
||||
|
||||
|
||||
def evaluate(js):
|
||||
ws = WS(target_ws())
|
||||
def evaluate(js, timeout=20):
|
||||
ws = WS(target_ws(), timeout)
|
||||
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
|
||||
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
|
||||
while True:
|
||||
@@ -83,6 +85,206 @@ def evaluate(js):
|
||||
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():
|
||||
cmd, args = sys.argv[1], sys.argv[2:]
|
||||
if cmd == 'add':
|
||||
@@ -97,12 +299,30 @@ def main():
|
||||
}})()'''
|
||||
print(evaluate(js))
|
||||
elif cmd == 'list':
|
||||
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
|
||||
.map(a => ({appid: a.appid, name: a.display_name})))()'''
|
||||
# Overviews carry no exe or devkit id (checked 2026-09-28); app details do, once registered.
|
||||
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)))
|
||||
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':
|
||||
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
|
||||
print('removed')
|
||||
print(json.dumps(remove(int(args[0]))))
|
||||
else:
|
||||
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
|
||||
internal symbols, stripped output, and `-Wl,-z,max-page-size=16384`.
|
||||
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so`.
|
||||
Its SHA-256 is recorded below with the APK checks.
|
||||
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz`,
|
||||
gzipped (`gzip -9 -n`) so the Windows installer carries it intact: electron-builder's
|
||||
7-Zip compresses a bare arm64 ELF with its ARM64 filter, which the installer's
|
||||
extractor skips. Frame Control decompresses it when injecting the layer. The
|
||||
SHA-256 of the uncompressed library is recorded below with the APK checks.
|
||||
|
||||
[Host test output](evidence/ctest.txt): two tests passed, covering pure logic
|
||||
and the actual layer with a fake next layer and host-only log/JNI shims.
|
||||
|
||||
@@ -8,5 +8,8 @@ cmake -S "$here" -B "$here/build-android" -G Ninja \
|
||||
-DANDROID_STL=c++_static -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build "$here/build-android"
|
||||
mkdir -p "$here/prebuilt/arm64-v8a"
|
||||
cp "$here/build-android/libXrApiLayer_FRAME_compat.so" "$here/prebuilt/arm64-v8a/"
|
||||
shasum -a 256 "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so"
|
||||
# Committed and shipped gzipped: electron-builder's 7-Zip applies its ARM64
|
||||
# branch filter to a bare arm64 ELF, which the Windows installer's extractor
|
||||
# can't decode, so the installed app silently lacked the library.
|
||||
shasum -a 256 "$here/build-android/libXrApiLayer_FRAME_compat.so"
|
||||
gzip -9 -n -c "$here/build-android/libXrApiLayer_FRAME_compat.so" > "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"
|
||||
@@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args]
|
||||
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() {
|
||||
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
|
||||
local tmp
|
||||
local tmp new="$CONFIG.frame-control.$$"
|
||||
tmp=$(mktemp) || return 1
|
||||
# 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"
|
||||
@@ -126,7 +138,8 @@ write_config() {
|
||||
print -r -- "Host *"
|
||||
print -r -- "$END_MARK"
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
set -euo pipefail
|
||||
|
||||
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}
|
||||
cmd=${1:-status}
|
||||
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"
|
||||
|
||||
# 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
|
||||
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
|
||||
# ("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
|
||||
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
||||
from frame_steam import Page
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
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"
|
||||
id="" name=""
|
||||
|
||||
@@ -109,4 +111,4 @@ EOF
|
||||
)
|
||||
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
|
||||
# digest first, since the app's updater refuses assets without one, then
|
||||
# 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
|
||||
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
|
||||
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-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")
|
||||
[ "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=""
|
||||
for name in $expected; do
|
||||
digest=$(printf '%s' "$info" | python3 -c 'import json,sys
|
||||
d=json.load(sys.stdin); n=sys.argv[1]
|
||||
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name")
|
||||
digest=$(python3 -c 'import json,sys
|
||||
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"))' "$tmp/release.json" "$name")
|
||||
case "$digest" in
|
||||
sha256:*) echo "ok $name" ;;
|
||||
absent) echo "MISSING $name"; missing=1 ;;
|
||||
@@ -30,16 +72,32 @@ done
|
||||
|
||||
# update.json: what running copies read (app/updater.js), from github.com's
|
||||
# latest/download link rather than the rate-limited REST API.
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
names=set(sys.argv[1].split())
|
||||
print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000],
|
||||
python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
tag, page, names = sys.argv[2], sys.argv[3], set(sys.argv[4].split())
|
||||
print(json.dumps({"version": tag.lstrip("v"), "page": page, "notes": (d.get("body") or "")[:4000],
|
||||
"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"
|
||||
gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber
|
||||
for a in d["assets"] if a["name"] in names]}, indent=1))
|
||||
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"
|
||||
|
||||
gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest
|
||||
echo "published $tag; running copies will offer it at their next check"
|
||||
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \
|
||||
-f make_latest=true --jq '"published " + .tag_name + " at " + .html_url'
|
||||
echo "running copies will offer $tag at their next check"
|
||||
@@ -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()
|
||||
@@ -10,6 +10,7 @@ import atexit
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -22,7 +23,22 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
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('/')
|
||||
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
|
||||
HOME = '/home/steamos'
|
||||
@@ -30,9 +46,14 @@ SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log')
|
||||
|
||||
|
||||
def require():
|
||||
"""Call at module level: skips the module unless the fake Frame is up."""
|
||||
if not ENABLED:
|
||||
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
|
||||
"""Skips (the module, at module level; the class, in setUpClass) unless this is
|
||||
scripts/e2e.sh's container with the fake Frame up."""
|
||||
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 --------------------------------------------
|
||||
@@ -97,6 +118,7 @@ class Server:
|
||||
|
||||
@classmethod
|
||||
def start(cls):
|
||||
require() # never a server on this computer's real data
|
||||
if cls.proc and cls.proc.poll() is None:
|
||||
return
|
||||
with socket.socket() as s:
|
||||
@@ -193,6 +215,7 @@ class FrameTestCase(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
require()
|
||||
Server.start()
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@@ -6,6 +6,8 @@ import sys
|
||||
import harness
|
||||
from harness import api, ok, finished, ssh
|
||||
|
||||
harness.require()
|
||||
|
||||
sys.path.insert(0, str(harness.ROOT / 'ui'))
|
||||
import frame_mcp
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import unittest
|
||||
|
||||
import harness
|
||||
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()
|
||||
|
||||
@@ -33,8 +33,8 @@ class AndroidApps(harness.FrameTestCase):
|
||||
self.assertEqual(shortcut['name'], 'App label')
|
||||
self.assertEqual(shortcut['exe'], f'{APP_DIR}/launch.sh')
|
||||
self.assertEqual(shortcut['start_dir'], APP_DIR)
|
||||
self.assertEqual(shortcut['icon'], f'{APP_DIR}/icon.png')
|
||||
for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'icon.png', 'lepton-show-flatscreen'):
|
||||
self.assertEqual(shortcut['icon'], f'{APP_DIR}/artwork/icon.png')
|
||||
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.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')
|
||||
|
||||
|
||||
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__':
|
||||
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_HOST: fakeframe
|
||||
FRAME_E2E: "1"
|
||||
FRAME_E2E_CONTAINER: "1" # tests/e2e/harness.py refuses to run anywhere else
|
||||
healthcheck:
|
||||
test: ["CMD", "test", "-f", "/home/tester/.ready"]
|
||||
interval: 1s
|
||||
|
||||
@@ -25,6 +25,10 @@ containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)}
|
||||
if cmd == 'ps':
|
||||
for name, c in sorted(containers.items()):
|
||||
print(f"{name} {c['port']}")
|
||||
elif cmd == 'inspect':
|
||||
if args[-1] not in containers:
|
||||
sys.exit(1)
|
||||
print('true')
|
||||
elif cmd == 'stop':
|
||||
name = args[-1]
|
||||
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) {
|
||||
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
|
||||
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,
|
||||
size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0,
|
||||
rt_last_time_played: a.last_played || 0,
|
||||
@@ -37,7 +37,8 @@ function build(steam) {
|
||||
},
|
||||
});
|
||||
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 },
|
||||
});
|
||||
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 {
|
||||
...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: {
|
||||
get allApps() { return allApps(); },
|
||||
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); },
|
||||
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); },
|
||||
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) {
|
||||
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(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 |
|
After Width: | Height: | Size: 77 B |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"home": "/home/steamos",
|
||||
"shortcuts": [
|
||||
{
|
||||
"appid": 3346865537,
|
||||
"name": "Open Saber Plus"
|
||||
}
|
||||
],
|
||||
"configure": {
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
Recorded 2026-09-28 from public publisher endpoints using FrameControl/0.1 or
|
||||
`gh api`. JSON fixtures are reduced to fields consumed by the adapters; API
|
||||
values are unchanged. No token, cookies or signed download URL is included.
|
||||
|
||||
- `*-releases.json`: `/repos/{repo}/releases?per_page=10`, first two releases,
|
||||
for KhronosGroup/OpenXR-SDK-Source, icosa-foundation/open-brush and
|
||||
SgtBilko76/SuperTux-3D (one release).
|
||||
- `topic.json`: `/search/repositories?q=topic:openxr+archived:false&sort=stars&per_page=3`.
|
||||
- `itch-feed.txt`: `https://itch.io/games/free/platform-android/tag-openxr.xml`.
|
||||
- `itch-robots.txt`: `https://itch.io/robots.txt`.
|
||||
- `itch-author-robots.txt`: `https://godotvr.itch.io/robots.txt`.
|
||||
|
||||
The synthetic ZIP in tests is only a transport/integrity fixture, not an
|
||||
installable APK. Actual APK parsing was verified separately on the downloaded
|
||||
Khronos Vulkan sample; see docs/apk-sources.md.
|
||||
|
||||
Artwork follow-up: `topic.json` now retains `owner.avatar_url` from authenticated
|
||||
repository API reads. `artwork-check.json` records HTTPS response status,
|
||||
Content-Type and image magic checks for all curated URLs and three topic
|
||||
results. All curated URLs returned real images; LWJGL's social preview returned
|
||||
HTTP 429. This fixture is evidence of a point-in-time check, not an uptime test.
|
||||
Open Brush screenshots came from the Steam appdetails response for app 1634870,
|
||||
linked by its README. SuperTux's README links the recorded upstream screenshot.
|
||||
@@ -0,0 +1,120 @@
|
||||
[
|
||||
{
|
||||
"url": "https://avatars.githubusercontent.com/u/2757344?v=4",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://avatars.githubusercontent.com/u/784805?v=4",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://avatars.githubusercontent.com/u/94376830?v=4",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://opengraph.githubassets.com/1/KhronosGroup/OpenXR-SDK-Source",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://opengraph.githubassets.com/1/LWJGL/lwjgl3",
|
||||
"error": "HTTP Error 429: Too Many Requests"
|
||||
},
|
||||
{
|
||||
"url": "https://opengraph.githubassets.com/1/bjornbytes/lovr",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://opengraph.githubassets.com/1/sahibzada-allahyar/YC-Killer",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/KhronosGroup/OpenXR-SDK-Source/3ed64d0f9bb680f24b80a085091e5c8fab38f7b7/src/tests/hello_xr/android_resources/vulkan/mipmap-xxxhdpi/ic_helloxr_launcher.png",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/SgtBilko76/SuperTux-3D/1955493ee6f1000e048c58db40d4904df827210e/data/images/engine/icons/supertux-256x256.png",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/Assets/Resources/DefaultImages/OpenBrushLogo.png",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/open-brush.png",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_0a9c208e26a43cf34879c2ca361d4c8f18af8cba.1920x1080.jpg",
|
||||
"status": 200,
|
||||
"content_type": "image/jpeg",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_19b25b86ef55c0d8769a65135d60eaae8fa40553.1920x1080.jpg",
|
||||
"status": 200,
|
||||
"content_type": "image/jpeg",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_785ea37d63378146dfe0f0ffa3f1d5c155ca978f.1920x1080.jpg",
|
||||
"status": 200,
|
||||
"content_type": "image/jpeg",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://www.supertux.org/images/0_7_0/github_preview.png",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/icon.png",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://opengraph.githubassets.com/1/arpruss/OpenSaberPlus",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_1.gif",
|
||||
"status": 200,
|
||||
"content_type": "image/gif",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_2.gif",
|
||||
"status": 200,
|
||||
"content_type": "image/gif",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_3.gif",
|
||||
"status": 200,
|
||||
"content_type": "image/gif",
|
||||
"image": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
[
|
||||
{
|
||||
"tag_name": "2.32.29",
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"published_at": "2026-09-26T17:49:28Z",
|
||||
"assets": [
|
||||
{
|
||||
"id": 591143285,
|
||||
"name": "OpenBrush_Android_2.32.29.apk",
|
||||
"size": 314602794,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Android_2.32.29.apk",
|
||||
"digest": "sha256:f20361b830803a2bf53e2af648bba59ee17bc4615bde534dbf6c4f0012bbd291"
|
||||
},
|
||||
{
|
||||
"id": 591143287,
|
||||
"name": "OpenBrush_Desktop_2.32.29.zip",
|
||||
"size": 380735034,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Desktop_2.32.29.zip",
|
||||
"digest": "sha256:3b680b07ca0b8def579fe194916aa7db4d007c3094c4dea818124776098c9b9a"
|
||||
},
|
||||
{
|
||||
"id": 591143282,
|
||||
"name": "OpenBrush_Linux_2.32.29.zip",
|
||||
"size": 364906490,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Linux_2.32.29.zip",
|
||||
"digest": "sha256:e380934f77d2b1441179424193a75f7b4b5793b34761c04cbf2d87bad9f8fc16"
|
||||
},
|
||||
{
|
||||
"id": 591143283,
|
||||
"name": "OpenBrush_Mac_2.32.29.dmg",
|
||||
"size": 373196471,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Mac_2.32.29.dmg",
|
||||
"digest": "sha256:5d544d0a64bed1cae65173fcfa7ada069d4f81b42385e806e99cc4427ef3513c"
|
||||
},
|
||||
{
|
||||
"id": 591143286,
|
||||
"name": "OpenBrush_Quest_2.32.29.apk",
|
||||
"size": 314603546,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Quest_2.32.29.apk",
|
||||
"digest": "sha256:57cd7b9067689060451494e55dc06276f934a992f5cbbd44d965069903937ba6"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "2.32.28",
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"published_at": "2026-09-26T14:42:27Z",
|
||||
"assets": [
|
||||
{
|
||||
"id": 590837298,
|
||||
"name": "OpenBrush_Android_2.32.28.apk",
|
||||
"size": 314603450,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Android_2.32.28.apk",
|
||||
"digest": "sha256:1a3af1a194c4348ef67c8ea61d9a8258e2490cdfe1f760cbc3c69f2978a6a082"
|
||||
},
|
||||
{
|
||||
"id": 590837301,
|
||||
"name": "OpenBrush_Desktop_2.32.28.zip",
|
||||
"size": 380738236,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Desktop_2.32.28.zip",
|
||||
"digest": "sha256:c2de5424bc022951f0e0bdbd652ede549a1e60d9cfbf41c730ea43d16d97262f"
|
||||
},
|
||||
{
|
||||
"id": 590837297,
|
||||
"name": "OpenBrush_Linux_2.32.28.zip",
|
||||
"size": 364907046,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Linux_2.32.28.zip",
|
||||
"digest": "sha256:29daf410347b3ca36e47808e31172270df8040ba7decc83be2bdcd51de895e06"
|
||||
},
|
||||
{
|
||||
"id": 590837296,
|
||||
"name": "OpenBrush_Mac_2.32.28.dmg",
|
||||
"size": 373195966,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Mac_2.32.28.dmg",
|
||||
"digest": "sha256:2f352d766450c993fdcae8a8552878a6a976d32d675246b63c81bb1b326fd20d"
|
||||
},
|
||||
{
|
||||
"id": 590837295,
|
||||
"name": "OpenBrush_Quest_2.32.28.apk",
|
||||
"size": 314604234,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Quest_2.32.28.apk",
|
||||
"digest": "sha256:9a3af6a6e838dd8bd201e549c5570f67db794df940e960390aeeae93235d702e"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
User-agent: Mediapartners-Google
|
||||
Disallow:
|
||||
|
||||
User-agent: *
|
||||
Disallow: /*/download/
|
||||
Disallow: /*/rh/
|
||||
Disallow: /*/rp/
|
||||
Disallow: /-/
|
||||
|
||||
Sitemap: https://itch.io/sitemap.xml
|
||||
|
||||
# vim: set ft=robots:
|
||||