Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6ed6c9ea1 | ||
|
|
6d03317970 | ||
|
|
976008065f | ||
|
|
8b5ada1272 | ||
|
|
48a9914124 | ||
|
|
002c859572 | ||
|
|
b5cf8253e6 | ||
|
|
6a8e3fadbf | ||
|
|
643cb65c79 | ||
|
|
33a92a2e1d | ||
|
|
f076527722 | ||
|
|
e67802f15d | ||
|
|
73eef14ecd | ||
|
|
c3ceea9bcd | ||
|
|
dcf9689f64 | ||
|
|
97d70d0c80 | ||
|
|
9eeca79b5d | ||
|
|
224340edc9 | ||
|
|
b393e90854 | ||
|
|
45f720883a | ||
|
|
c814cb95d0 | ||
|
|
ff2c4ebfe0 | ||
|
|
03322d3166 | ||
|
|
2d08486278 | ||
|
|
f780ab2c6a | ||
|
|
396f2a830a | ||
|
|
59761ae015 | ||
|
|
48eedbc2eb | ||
|
|
2b89eeba1d | ||
|
|
75e92db2fa | ||
|
|
10f96656e3 | ||
|
|
a1fa4ce140 | ||
|
|
50405ccf88 | ||
|
|
32196b4260 | ||
|
|
fbe7ba9575 | ||
|
|
5e12245932 | ||
|
|
df1810bae3 | ||
|
|
3f0e7b198a | ||
|
|
0016d9200c | ||
|
|
fe87a9d826 | ||
|
|
1b26c4f92c | ||
|
|
3db311da13 | ||
|
|
e1d138470f | ||
|
|
c711e136d4 | ||
|
|
19b9bc2dbe | ||
|
|
b768162139 | ||
|
|
56bbac4ddb | ||
|
|
93aebb5019 | ||
|
|
0181071b83 | ||
|
|
ca2a991f03 | ||
|
|
10bf18fd50 | ||
|
|
d65f7130d5 | ||
|
|
b1fa3afd40 | ||
|
|
aafd2dbda8 | ||
|
|
db75d1ca71 | ||
|
|
fd3a25434d | ||
|
|
14790ac768 | ||
|
|
a910f83ac1 | ||
|
|
13187e8f6a | ||
|
|
0f770dc88a |
No files matched your search
@@ -27,6 +27,9 @@ desktop or panels.
|
||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||
| Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` |
|
||||
| Mac GUI over all of this | `README.md` → Frame Control | `scripts/frame-ui.sh` |
|
||||
| iPhone/iPad app (server runs on the Frame, `FRAME_LOCAL=1`) | `docs/iphone.md` | `ios/`, `ui/local-bin/ssh` |
|
||||
| Recovery images, factory reset, boot loops | `docs/recovery-and-images.md`, `docs/how-the-frame-works.md` | `~/Downloads/steam-frame-recovery/` |
|
||||
| Test without the headset (the Frame OS image's own sshd) | `tests/frame-container/README.md` | `tests/frame-container/frame-image.sh` |
|
||||
| What's still unverified | `docs/open-questions.md` | — |
|
||||
|
||||
Each script's usage is in its header comment. Read the header rather than
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ko_fi: alexsouthwell
|
||||
@@ -20,6 +20,7 @@ jobs:
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
- name: Script syntax
|
||||
run: |
|
||||
sh -n ui/local-bin/ssh
|
||||
for f in scripts/*.sh frame/*/*.sh; do
|
||||
case "$(head -n 1 "$f")" in
|
||||
*zsh*) zsh -n "$f" ;;
|
||||
@@ -28,13 +29,15 @@ jobs:
|
||||
done
|
||||
- name: Python compiles
|
||||
run: |
|
||||
python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py
|
||||
python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py ios/scripts/*.py
|
||||
# Valve's devkit-utils (vendored; run by the Frame's python3). Most have no .py suffix.
|
||||
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
- name: Updater tests
|
||||
run: node --test app/test/updater.test.js
|
||||
- name: Website
|
||||
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
|
||||
|
||||
@@ -59,3 +62,28 @@ jobs:
|
||||
python-version: ${{ matrix.python }}
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
# The iPhone app: builds for the Simulator and runs its unit tests.
|
||||
ios:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Generate the project
|
||||
run: brew install xcodegen && cd ios && xcodegen generate
|
||||
- name: Build and test
|
||||
run: |
|
||||
cd ios
|
||||
udid=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for r in d for x in d[r] if x["name"].startswith("iPhone")))')
|
||||
xcodebuild -project FrameControl.xcodeproj -scheme FrameControl -destination "platform=iOS Simulator,id=$udid" CODE_SIGNING_ALLOWED=NO test
|
||||
|
||||
# End-to-end tests against the fake Frame (tests/fakeframe): Arch Linux ARM
|
||||
# in Docker, on a native arm64 runner like the headset. See docs/testing.md.
|
||||
e2e:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
- name: End-to-end tests
|
||||
run: scripts/e2e.sh
|
||||
@@ -1,6 +1,7 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
apk-catalog/data/cache/
|
||||
apk-catalog/data/index-v2.json*
|
||||
apk-catalog/data/index-v2*.json*
|
||||
compat-db/.env.lakebed.server
|
||||
compat-db/.lakebed/
|
||||
tests/smoke/results/
|
||||
@@ -16,7 +16,7 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
|
||||
<br>
|
||||
|
||||
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900">
|
||||
<img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
|
||||
|
||||
<a id="trailer"></a>
|
||||
<a href="https://github.com/saphid/steam-frame/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
|
||||
@@ -103,7 +103,14 @@ already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
|
||||
**iPhone and iPad:** the same features from your phone, with nothing to install on
|
||||
a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md).
|
||||
|
||||
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
|
||||
From 0.4 it updates itself: when a new version is published, a banner offers
|
||||
**Update and restart**. It sends anonymous usage statistics, which you can turn
|
||||
off. Sharing compatibility results and error details is opt-in. See
|
||||
[docs/privacy.md](docs/privacy.md).
|
||||
Google doesn't publish `adb` for arm64 Linux, so that build uses your
|
||||
distribution's. If you already have `adb`, the app uses yours.
|
||||
|
||||
@@ -172,9 +179,11 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
||||
## Feedback
|
||||
|
||||
This is a first public test, so reports are really useful, especially from
|
||||
Windows and Linux. The quickest way is the
|
||||
[feedback form](https://frame-control.pages.dev/feedback/): no GitHub account
|
||||
needed, and it opens an issue here. Please include:
|
||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
||||
diagnostics with personal details removed, shows you exactly what's included,
|
||||
and sends it privately to the maintainer; nothing is published. Without the app,
|
||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||
|
||||
- what you tried and what happened
|
||||
- your computer's OS and your SteamOS build (Steam Settings → System)
|
||||
@@ -199,6 +208,10 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [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 |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
|
||||
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||
|
||||
<details>
|
||||
@@ -225,12 +238,14 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests # server tests; no headset needed
|
||||
scripts/e2e.sh # end-to-end against a fake Frame (Linux with Docker)
|
||||
cd app && npm install && npm start # run the app from the checkout
|
||||
```
|
||||
|
||||
The server is Python stdlib only; the app is Electron. GitHub Actions runs the
|
||||
tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
|
||||
into the release. See [building](docs/frame-control.md#building).
|
||||
into a draft release, which reaches users once published. See
|
||||
[building](docs/frame-control.md#building) and [releasing](docs/releasing.md).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const net = require("net");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
@@ -114,7 +115,11 @@ function ping(target) {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
||||
// sends nothing from a source checkout.
|
||||
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
||||
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
||||
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||
python = await findPython(env);
|
||||
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
|
||||
@@ -243,6 +248,10 @@ function fromUi(e) {
|
||||
}
|
||||
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||
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(); });
|
||||
|
||||
// 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),
|
||||
@@ -275,6 +284,81 @@ ipcMain.on("install-link:ready", (e) => {
|
||||
deliverLinks();
|
||||
});
|
||||
|
||||
// ---- updates (app/updater.js, docs/releasing.md) ----
|
||||
// Checked shortly after launch and every few hours; the page shows a banner and
|
||||
// the Update button calls installUpdate.
|
||||
const UPDATE_EVERY = 6 * 3600 * 1000;
|
||||
const update = { status: "idle", current: app.getVersion(), latest: null, error: null, progress: 0, how: null };
|
||||
|
||||
function publicUpdate() {
|
||||
const r = update.latest;
|
||||
return { status: update.status, current: update.current, error: update.error, progress: update.progress,
|
||||
latest: r && { version: r.version, notes: r.notes, page: r.page },
|
||||
canInstall: !!update.how && update.how.method !== "manual", why: update.how && update.how.why };
|
||||
}
|
||||
|
||||
function setUpdate(fields) {
|
||||
Object.assign(update, fields);
|
||||
if (win && linkPage === win.webContents) win.webContents.send("update:state", publicUpdate());
|
||||
}
|
||||
|
||||
async function checkForUpdate({ manual = false } = {}) {
|
||||
if (["checking", "downloading", "ready"].includes(update.status)) return;
|
||||
setUpdate({ status: "checking", error: null });
|
||||
try {
|
||||
const latest = await updater.latestRelease();
|
||||
const how = updater.updateMethod({ platform: process.platform, isPackaged: app.isPackaged,
|
||||
execPath: process.execPath, env: process.env,
|
||||
exists: fs.existsSync, writable: updater.writable });
|
||||
if (updater.isNewer(latest.version, update.current)) {
|
||||
setUpdate({ status: "available", latest, how });
|
||||
if (manual) offerUpdateDialog();
|
||||
} else {
|
||||
setUpdate({ status: "none", latest, how });
|
||||
if (manual) dialog.showMessageBox(win, { type: "info", message: "Frame Control is up to date",
|
||||
detail: `You have ${update.current}, the newest version.` });
|
||||
}
|
||||
} catch (e) {
|
||||
// A failed check: nothing to install, and never an older release kept from before.
|
||||
setUpdate({ status: "check-failed", error: e.message, latest: null });
|
||||
if (manual) dialog.showMessageBox(win, { type: "warning", message: "Couldn't check for updates", detail: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function offerUpdateDialog() {
|
||||
const r = update.latest;
|
||||
const { response } = await dialog.showMessageBox(win, {
|
||||
type: "info", message: `Frame Control ${r.version} is available`,
|
||||
detail: `You have ${update.current}.` + (update.how.method === "manual" ? ` Download it from the release page (${update.how.why}).` : ""),
|
||||
buttons: [update.how.method === "manual" ? "Open Release Page" : "Update and Restart", "Later"], defaultId: 0, cancelId: 1,
|
||||
});
|
||||
if (response === 0) installUpdate();
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
// "error" here only ever means an install failed, so trying again is safe.
|
||||
if (update.status !== "available" && update.status !== "error") return;
|
||||
if (!update.latest || !updater.isNewer(update.latest.version, update.current)) return;
|
||||
if (!update.how || update.how.method === "manual") { shell.openExternal(update.latest.page); return; }
|
||||
setUpdate({ status: "downloading", progress: 0, error: null });
|
||||
try {
|
||||
const start = await updater.prepare(update.latest, update.how,
|
||||
(done, total) => { if (total) setUpdate({ progress: done / total }); }, update.current);
|
||||
setUpdate({ status: "ready", progress: 1 });
|
||||
start();
|
||||
quitting = true;
|
||||
app.quit();
|
||||
} catch (e) {
|
||||
setUpdate({ status: "error", error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks() {
|
||||
if (process.env.FRAME_CONTROL_NO_UPDATE_CHECK === "1") return;
|
||||
setTimeout(checkForUpdate, 8000);
|
||||
setInterval(checkForUpdate, UPDATE_EVERY).unref();
|
||||
}
|
||||
|
||||
function registerScheme() {
|
||||
// A checkout runs as `electron .`, so the OS must be told the script too.
|
||||
// (macOS takes the scheme from Info.plist, which only the built app has.)
|
||||
@@ -336,7 +420,11 @@ async function setUpConnection() {
|
||||
|
||||
function buildMenu() {
|
||||
const template = [
|
||||
...(IS_MAC ? [{ role: "appMenu" }] : []),
|
||||
...(IS_MAC ? [{ label: app.name, submenu: [
|
||||
{ role: "about" }, { label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) },
|
||||
{ type: "separator" }, { role: "services" }, { type: "separator" },
|
||||
{ role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" },
|
||||
] }] : []),
|
||||
{ role: "fileMenu" },
|
||||
{ role: "editMenu" },
|
||||
{
|
||||
@@ -363,7 +451,15 @@ function buildMenu() {
|
||||
...(IS_MAC ? [{ role: "windowMenu" }] : []),
|
||||
{
|
||||
role: "help",
|
||||
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
|
||||
submenu: [
|
||||
...(IS_MAC ? [] : [{ label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) }]),
|
||||
{ label: "Report a Problem…", click: () => {
|
||||
if (win && url && linkPage === win.webContents) win.webContents.send("report:open");
|
||||
else shell.openExternal("https://frame-control.pages.dev/feedback/"); // the page isn't up
|
||||
} },
|
||||
{ label: "Release Notes", click: () => shell.openExternal(updater.RELEASES) },
|
||||
{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") },
|
||||
],
|
||||
},
|
||||
];
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
@@ -386,6 +482,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
registerScheme();
|
||||
buildMenu();
|
||||
createWindow();
|
||||
scheduleUpdateChecks();
|
||||
});
|
||||
app.on("activate", () => { if (!win) createWindow(); });
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -37,6 +37,7 @@
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
@@ -46,7 +47,8 @@
|
||||
"to": "ui",
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.html"
|
||||
"*.html",
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,13 +2,31 @@
|
||||
// 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 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.
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||
// Updates (app/updater.js): the page shows a banner and an Update button.
|
||||
update: {
|
||||
get: () => ipcRenderer.invoke("update:get"),
|
||||
check: () => ipcRenderer.invoke("update:check"),
|
||||
install: () => ipcRenderer.invoke("update:install"),
|
||||
onState: (cb) => {
|
||||
ipcRenderer.removeAllListeners("update:state");
|
||||
ipcRenderer.on("update:state", (_e, s) => cb(s));
|
||||
},
|
||||
},
|
||||
// Help → Report a Problem… opens the page's report dialog (ui/frame_report.py).
|
||||
onReportProblem: (cb) => {
|
||||
ipcRenderer.removeAllListeners("report:open");
|
||||
ipcRenderer.on("report:open", () => cb());
|
||||
},
|
||||
onInstallLink: (cb) => {
|
||||
ipcRenderer.removeAllListeners("install-link");
|
||||
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Run: node --test app/test/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { isNewer, assetName, updateMethod, macBundle } = require("../updater");
|
||||
|
||||
test("versions compare numerically, and a release beats its pre-releases", () => {
|
||||
assert.ok(isNewer("0.3.10", "0.3.9"));
|
||||
assert.ok(isNewer("v1.0.0", "0.9.9"));
|
||||
assert.ok(!isNewer("0.3.1", "0.3.1"));
|
||||
assert.ok(!isNewer("0.3.0", "0.3.1"));
|
||||
assert.ok(isNewer("1.0.0", "1.0.0-beta.1"));
|
||||
assert.ok(!isNewer("1.0.0-beta.1", "1.0.0"));
|
||||
assert.ok(!isNewer("garbage", "0.1.0"));
|
||||
});
|
||||
|
||||
test("asset names match what electron-builder publishes", () => {
|
||||
assert.strictEqual(assetName("darwin", "arm64", "mac-zip"), "Frame-Control-mac-arm64.zip");
|
||||
assert.strictEqual(assetName("win32", "x64", "nsis"), "Frame-Control-Setup-x64.exe");
|
||||
assert.strictEqual(assetName("linux", "x64", "appimage"), "Frame-Control-linux-x86_64.AppImage");
|
||||
assert.strictEqual(assetName("linux", "arm64", "appimage"), "Frame-Control-linux-arm64.AppImage");
|
||||
});
|
||||
|
||||
const base = { isPackaged: true, env: {}, exists: () => false, writable: () => true };
|
||||
|
||||
test("macOS updates in place only from a writable, non-translocated location", () => {
|
||||
const exe = "/Applications/Frame Control.app/Contents/MacOS/Frame Control";
|
||||
assert.strictEqual(macBundle(exe), "/Applications/Frame Control.app");
|
||||
assert.deepStrictEqual(updateMethod({ ...base, platform: "darwin", execPath: exe }),
|
||||
{ method: "mac-zip", bundle: "/Applications/Frame Control.app" });
|
||||
const dmg = "/Volumes/Frame Control 0.3.1/Frame Control.app/Contents/MacOS/Frame Control";
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: dmg }).method, "manual");
|
||||
const trans = "/private/var/folders/x/AppTranslocation/ABC/d/Frame Control.app/Contents/MacOS/Frame Control";
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: trans }).method, "manual");
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: exe, writable: () => false }).method, "manual");
|
||||
});
|
||||
|
||||
test("Windows needs the installer's copy; Linux needs an AppImage", () => {
|
||||
const exe = "C:\\Users\\a\\AppData\\Local\\Programs\\Frame Control\\Frame Control.exe";
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "win32", execPath: exe, exists: () => true }).method, "nsis");
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "win32", execPath: exe }).method, "manual");
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "linux", execPath: "/opt/x", env: { APPIMAGE: "/home/a/F.AppImage" } }).method,
|
||||
"appimage");
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "linux", execPath: "/opt/Frame Control/frame-control" }).method, "manual");
|
||||
assert.strictEqual(updateMethod({ ...base, isPackaged: false, platform: "darwin", execPath: "x" }).method, "manual");
|
||||
});
|
||||
|
||||
test("update.json assets always download from this repository's release", () => {
|
||||
const r = require("../updater").fromManifest({ version: "0.4.0", notes: "n", assets: [
|
||||
{ name: "Frame-Control-mac-arm64.zip", url: "https://evil.example/x.zip", digest: "sha256:" + "a".repeat(64) }] });
|
||||
assert.strictEqual(r.assets[0].url, "https://github.com/saphid/frame-control/releases/download/v0.4.0/Frame-Control-mac-arm64.zip");
|
||||
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
|
||||
});
|
||||
|
||||
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
|
||||
const { prepare } = require("../updater");
|
||||
const release = { version: "0.3.1", assets: [] };
|
||||
await assert.rejects(prepare(release, { method: "appimage", appImage: "/nonexistent/x" }, null, "0.4.0"), /isn't newer/);
|
||||
await assert.rejects(prepare(release, { method: "appimage", appImage: "/nonexistent/x" }, null, "0.3.1"), /isn't newer/);
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
// Update checks and self-update for the desktop app (docs/releasing.md).
|
||||
//
|
||||
// The newest version is GitHub's "latest" release of saphid/frame-control. Drafts
|
||||
// and pre-releases never count, so a build reaches people only when the
|
||||
// maintainer publishes it after testing (scripts/publish-release.sh). That
|
||||
// script attaches update.json (version, notes, each asset's SHA-256), read
|
||||
// through github.com's latest/download link: the REST API allows only 60
|
||||
// unauthenticated requests an hour per IP address, shared by everyone behind
|
||||
// the same router, so it's only the fallback.
|
||||
//
|
||||
// Every download is checked against the SHA-256 digest GitHub records for the
|
||||
// asset before anything is replaced. How the update is applied:
|
||||
// macOS the .zip: unpacked next to the running app, swapped in by a small
|
||||
// script once the app has quit, then reopened.
|
||||
// Windows the NSIS installer, run silently over the current install; it
|
||||
// reopens the app. A copy unpacked from the .zip is updated by hand.
|
||||
// Linux the AppImage replaces itself; .deb installs are updated by hand.
|
||||
// When the app can't update itself it opens the release page instead.
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const REPO = "saphid/frame-control"; // renamed from saphid/steam-frame; GitHub redirects the old name
|
||||
const LATEST = `https://api.github.com/repos/${REPO}/releases/latest`;
|
||||
const MANIFEST = `https://github.com/${REPO}/releases/latest/download/update.json`;
|
||||
const RELEASES = `https://github.com/${REPO}/releases`;
|
||||
|
||||
// "0.3.1" or "v0.3.1" -> [0, 3, 1]; pre-release suffixes sort before the release.
|
||||
function parseVersion(v) {
|
||||
const m = String(v || "").trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
||||
return m ? { nums: [+m[1], +m[2], +m[3]], pre: m[4] || null } : null;
|
||||
}
|
||||
|
||||
function isNewer(candidate, current) {
|
||||
const a = parseVersion(candidate), b = parseVersion(current);
|
||||
if (!a || !b) return false;
|
||||
for (let i = 0; i < 3; i++) if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
|
||||
if (a.pre === b.pre) return false;
|
||||
if (!a.pre) return true; // 1.0.0 is newer than 1.0.0-beta
|
||||
if (!b.pre) return false;
|
||||
return a.pre > b.pre;
|
||||
}
|
||||
|
||||
// The asset this copy of the app updates from, by the names electron-builder gives them.
|
||||
function assetName(platform, arch, method) {
|
||||
if (method === "mac-zip") return `Frame-Control-mac-${arch}.zip`;
|
||||
if (method === "nsis") return `Frame-Control-Setup-${arch}.exe`;
|
||||
if (method === "appimage") return `Frame-Control-linux-${arch === "x64" ? "x86_64" : arch}.AppImage`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// How this copy can update itself: mac-zip, nsis, appimage, or manual (with why).
|
||||
function updateMethod({ platform, isPackaged, execPath, env, exists, writable }) {
|
||||
if (!isPackaged) return { method: "manual", why: "running from a source checkout" };
|
||||
if (platform === "darwin") {
|
||||
const bundle = macBundle(execPath);
|
||||
if (!bundle) return { method: "manual", why: "can't find the app bundle" };
|
||||
if (bundle.includes("/AppTranslocation/") || bundle.startsWith("/Volumes/")) {
|
||||
return { method: "manual", why: "move Frame Control to Applications first" };
|
||||
}
|
||||
if (!writable(path.dirname(bundle))) return { method: "manual", why: `${path.dirname(bundle)} isn't writable` };
|
||||
return { method: "mac-zip", bundle };
|
||||
}
|
||||
if (platform === "win32") {
|
||||
// electron-builder's NSIS install puts its uninstaller next to the app.
|
||||
const dir = path.dirname(execPath);
|
||||
if (exists(path.join(dir, "Uninstall Frame Control.exe"))) return { method: "nsis" };
|
||||
return { method: "manual", why: "not installed with the installer" };
|
||||
}
|
||||
if (platform === "linux" && env.APPIMAGE) {
|
||||
if (!writable(path.dirname(env.APPIMAGE))) return { method: "manual", why: "the AppImage's folder isn't writable" };
|
||||
return { method: "appimage", appImage: env.APPIMAGE };
|
||||
}
|
||||
return { method: "manual", why: "installed from a package" };
|
||||
}
|
||||
|
||||
function macBundle(execPath) {
|
||||
const i = execPath.indexOf(".app/Contents/MacOS/");
|
||||
return i < 0 ? null : execPath.slice(0, i + 4);
|
||||
}
|
||||
|
||||
function get(url, { headers = {}, timeout = 20000, redirects = 5 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { headers: { "user-agent": "FrameControl-updater", ...headers }, timeout }, (res) => {
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && redirects > 0) {
|
||||
res.resume();
|
||||
const next = new URL(res.headers.location, url);
|
||||
if (next.protocol !== "https:") return reject(new Error("refusing a non-HTTPS redirect"));
|
||||
return resolve(get(next.href, { headers, timeout, redirects: redirects - 1 }));
|
||||
}
|
||||
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode} from ${new URL(url).host}`)); }
|
||||
resolve(res);
|
||||
});
|
||||
req.on("timeout", () => req.destroy(new Error("timed out")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function getJson(url, headers) {
|
||||
const res = await get(url, { headers });
|
||||
let body = "";
|
||||
for await (const chunk of res) body += chunk;
|
||||
return JSON.parse(body);
|
||||
}
|
||||
|
||||
// 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,
|
||||
// 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 })) };
|
||||
}
|
||||
|
||||
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,
|
||||
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
|
||||
digest: a.digest || null })) };
|
||||
}
|
||||
|
||||
async function latestRelease() {
|
||||
try {
|
||||
return fromManifest(await getJson(MANIFEST));
|
||||
} catch (e) {
|
||||
if (!/HTTP 404/.test(e.message)) throw e; // releases before update.json existed
|
||||
}
|
||||
return fromApi(await getJson(LATEST, { accept: "application/vnd.github+json" }));
|
||||
}
|
||||
|
||||
async function download(asset, dest, onProgress) {
|
||||
const m = /^sha256:([0-9a-f]{64})$/.exec(asset.digest || "");
|
||||
if (!m) throw new Error(`GitHub has no SHA-256 for ${asset.name}, so it can't be checked`);
|
||||
const res = await get(asset.url, { timeout: 60000 });
|
||||
const total = +res.headers["content-length"] || asset.size || 0;
|
||||
const hash = crypto.createHash("sha256");
|
||||
// "wx": a new file only, never through an existing file or symlink at that path.
|
||||
const out = fs.createWriteStream(dest, { mode: 0o755, flags: "wx" });
|
||||
let done = 0;
|
||||
await new Promise((resolve, reject) => {
|
||||
res.on("data", (chunk) => { hash.update(chunk); done += chunk.length; onProgress && onProgress(done, total); });
|
||||
res.on("error", reject);
|
||||
out.on("error", reject);
|
||||
out.on("finish", resolve);
|
||||
res.pipe(out);
|
||||
});
|
||||
if (hash.digest("hex") !== m[1]) {
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw new Error(`${asset.name} didn't match its SHA-256; nothing was changed`);
|
||||
}
|
||||
}
|
||||
|
||||
const run = (cmd, args) => new Promise((resolve, reject) =>
|
||||
execFile(cmd, args, { timeout: 120000 }, (err, stdout, stderr) => err ? reject(new Error((stderr || err.message).trim())) : resolve(stdout)));
|
||||
|
||||
// Waits for this process to exit, swaps the new bundle in (putting the old one
|
||||
// back if that fails), and reopens the app.
|
||||
const MAC_SWAP = `set -u
|
||||
pid="$1"; app="$2"; new="$3"; stage="$4"
|
||||
while kill -0 "$pid" 2>/dev/null; do sleep 0.2; done
|
||||
old="$stage/old.app"
|
||||
if mv "$app" "$old"; then
|
||||
if mv "$new" "$app"; then rm -rf "$old"; else mv "$old" "$app"; fi
|
||||
fi
|
||||
xattr -dr com.apple.quarantine "$app" 2>/dev/null
|
||||
rm -rf "$stage"
|
||||
open "$app"
|
||||
`;
|
||||
|
||||
async function applyMac(release, bundle, onProgress) {
|
||||
const asset = release.assets.find((a) => a.name === assetName("darwin", process.arch, "mac-zip"));
|
||||
if (!asset) throw new Error(`the release has no ${assetName("darwin", process.arch, "mac-zip")}`);
|
||||
// Staged beside the app, so the final move stays on one volume.
|
||||
const stage = fs.mkdtempSync(path.join(path.dirname(bundle), ".frame-control-update-"));
|
||||
try {
|
||||
const zip = path.join(stage, asset.name);
|
||||
await download(asset, zip, onProgress);
|
||||
await run("/usr/bin/ditto", ["-x", "-k", zip, stage]);
|
||||
fs.rmSync(zip, { force: true });
|
||||
const name = fs.readdirSync(stage).find((n) => n.endsWith(".app"));
|
||||
if (!name) throw new Error("the download has no app in it");
|
||||
const fresh = path.join(stage, name);
|
||||
const version = (await run("/usr/bin/plutil", ["-extract", "CFBundleShortVersionString", "raw",
|
||||
path.join(fresh, "Contents", "Info.plist")])).trim();
|
||||
if (version !== release.version) throw new Error(`the download is version ${version}, not ${release.version}`);
|
||||
const script = path.join(stage, "swap.sh");
|
||||
fs.writeFileSync(script, MAC_SWAP);
|
||||
return () => spawn("/bin/sh", [script, String(process.pid), bundle, fresh, stage],
|
||||
{ detached: true, stdio: "ignore" }).unref();
|
||||
} catch (e) {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyNsis(release, onProgress) {
|
||||
const asset = release.assets.find((a) => a.name === assetName("win32", process.arch, "nsis"));
|
||||
if (!asset) throw new Error(`the release has no ${assetName("win32", process.arch, "nsis")}`);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-control-update-"));
|
||||
const exe = path.join(dir, asset.name);
|
||||
await download(asset, exe, onProgress);
|
||||
// /S: silent, into the existing install. --force-run: open the app afterwards.
|
||||
return () => spawn(exe, ["--updated", "/S", "--force-run"], { detached: true, stdio: "ignore" }).unref();
|
||||
}
|
||||
|
||||
async function applyAppImage(release, appImage, onProgress) {
|
||||
const asset = release.assets.find((a) => a.name === assetName("linux", process.arch, "appimage"));
|
||||
if (!asset) throw new Error(`the release has no ${assetName("linux", process.arch, "appimage")}`);
|
||||
// A private folder beside the AppImage, so the final rename stays on one filesystem.
|
||||
const stage = fs.mkdtempSync(path.join(path.dirname(appImage), ".frame-control-update-"));
|
||||
try {
|
||||
const next = path.join(stage, asset.name);
|
||||
await download(asset, next, onProgress);
|
||||
fs.chmodSync(next, 0o755);
|
||||
fs.renameSync(next, appImage); // the running copy keeps its open file
|
||||
} finally {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
// Without FUSE the AppImage runs extracted (--appimage-extract-and-run, which isn't passed
|
||||
// on to the app); a FUSE mount lives under /tmp/.mount_*. Keep the same mode on restart.
|
||||
const extracted = process.env.APPIMAGE_EXTRACT_AND_RUN === "1" || !process.execPath.includes("/.mount_");
|
||||
const env = { ...process.env, APPIMAGE: appImage, ...(extracted ? { APPIMAGE_EXTRACT_AND_RUN: "1" } : {}) };
|
||||
// Started only once this process has exited, or the new copy would lose the single-instance lock.
|
||||
return () => spawn("/bin/sh", ["-c", 'while kill -0 "$1" 2>/dev/null; do sleep 0.2; done; exec "$2"',
|
||||
"sh", String(process.pid), appImage], { detached: true, stdio: "ignore", env }).unref();
|
||||
}
|
||||
|
||||
// Downloads and prepares the update; returns a function that starts the swap,
|
||||
// to be called just before the app quits.
|
||||
async function prepare(release, how, onProgress, current) {
|
||||
if (!isNewer(release.version, current)) throw new Error(`${release.version} isn't newer than ${current}`);
|
||||
if (how.method === "mac-zip") return applyMac(release, how.bundle, onProgress);
|
||||
if (how.method === "nsis") return applyNsis(release, onProgress);
|
||||
if (how.method === "appimage") return applyAppImage(release, how.appImage, onProgress);
|
||||
throw new Error(how.why || "this copy can't update itself");
|
||||
}
|
||||
|
||||
function writable(dir) {
|
||||
try { fs.accessSync(dir, fs.constants.W_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
module.exports = { REPO, RELEASES, parseVersion, isNewer, assetName, updateMethod, macBundle, latestRelease,
|
||||
fromManifest, fromApi,
|
||||
download, prepare, writable };
|
||||
@@ -1,9 +1,32 @@
|
||||
# compat-db: Frame Control's compatibility database
|
||||
|
||||
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
|
||||
reports for Android apps on the Steam Frame. For now only the maintainer's
|
||||
copy of Frame Control has the key to read or write it. Everyone else's reports
|
||||
stay on their own Mac (see `shared()` in `ui/frame_compat_db.py`).
|
||||
reports for Android apps on the Steam Frame. Only the maintainer's copy of
|
||||
Frame Control has the key to read or write it (see `shared()` in
|
||||
`ui/frame_compat_db.py`). Everyone else's reports stay on their computer
|
||||
unless they turn on **Share compatibility results**. Then the reports also go
|
||||
to PostHog as `compat_report` events, and the maintainer syncs them in (below).
|
||||
|
||||
## Community reports
|
||||
|
||||
```sh
|
||||
python3 ui/frame_compat_db.py sync --dry-run # what would be added
|
||||
python3 ui/frame_compat_db.py sync # add them
|
||||
```
|
||||
|
||||
`sync` reads `compat_report` events through PostHog's query API and adds
|
||||
them with `via` set to `community`, `community-probe` or `community-install`.
|
||||
It skips invalid reports and anything over 30 per reporter per day. Each run
|
||||
re-reads the last 30 days, because an offline copy sends its reports late,
|
||||
with the time they were made. `posthog-sync.json`, next to the outbox,
|
||||
remembers which reports it has handled and each reporter's daily count, so
|
||||
nothing is added twice and the cap holds across runs.
|
||||
|
||||
It needs:
|
||||
|
||||
- the PostHog project id: `"project"` in `ui/telemetry.json`
|
||||
- a personal API key with `query:read`: `POSTHOG_PERSONAL_API_KEY`, or in the
|
||||
Keychain (service `frame-control-posthog`, account `personal-api-key`)
|
||||
|
||||
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
|
||||
claimed, so it doesn't expire). The browser page only says it's private.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Frame Control for AI agents
|
||||
|
||||
**Documented interface:** Frame Control's own stdlib Python MCP adapter wraps
|
||||
its loopback HTTP API. No API key, hosted service, model SDK or third-party
|
||||
helper app is needed. The assistant is our HTML/Python implementation hosted
|
||||
in the platform Chromium browser. Its optional LLM endpoint is user configuration.
|
||||
Installing other apps is an optional management action, never a prerequisite.
|
||||
|
||||
## Connect an MCP client
|
||||
|
||||
The default MCP command starts a private HTTP backend on a free loopback port,
|
||||
with a fresh local access key. It stops that backend when the MCP client closes
|
||||
stdin or sends SIGTERM. It uses its own SSH control socket, so closing it does
|
||||
not close the desktop app's connection. No manually started server is needed.
|
||||
|
||||
Add this stdio server to your MCP client (use absolute paths):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"frame-control": {
|
||||
"command": "python3",
|
||||
"args": ["/absolute/path/frame-control/ui/frame_mcp.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Codex, the equivalent registration is:
|
||||
|
||||
```sh
|
||||
codex mcp add frame-control -- python3 /absolute/path/frame-control/ui/frame_mcp.py
|
||||
```
|
||||
|
||||
New agent sessions load the entry. An already running session may need its MCP
|
||||
connections reloaded; registration does not retroactively add tools to its
|
||||
initial tool inventory. Keep the checkout at that path while it is registered.
|
||||
Use `codex mcp remove frame-control` to remove only this registration.
|
||||
|
||||
To reuse a running server instead, pass `--url http://127.0.0.1:47810`.
|
||||
The desktop app uses a random port; use that port with `--url`, or run the
|
||||
checkout server above. If the HTTP server uses `FRAME_UI_KEY`, pass the same
|
||||
value in the MCP process environment. This is local access control, not an LLM
|
||||
API key. The adapter only accepts loopback HTTP servers, refuses redirects and
|
||||
ignores environment proxies. Stdout contains newline-delimited JSON-RPC only.
|
||||
It supports MCP initialization, ping, tool listing and tool calls; no sampling,
|
||||
resources, prompts or streaming transport.
|
||||
|
||||
| Tool | Arguments | Effect |
|
||||
|---|---|---|
|
||||
| `computer_state` | none | Read-only gamescope window IDs/focus and bounded AT-SPI tree; reports incomplete observations |
|
||||
| `status` | none | Battery, services, installed games and Flatpaks |
|
||||
| `screenshot` | `view`: `headset` (default) or `desktop` | Returns PNG image content to the MCP client |
|
||||
| `job` | `id` | Background install status; poll until `done`, inspect `error` |
|
||||
| `launch` | `appid` | Launch an installed Steam app |
|
||||
| `install` / `uninstall` | `id` | Install from Flathub / remove a user Flatpak |
|
||||
| `send_text` | `text` | Frame desktop clipboard; desktop must be open |
|
||||
| `send_file` | `path` | File on the HTTP server computer, up to 16 MiB, copied to Frame `~/Downloads` |
|
||||
| `panel` | `id` | Launch an installed Flatpak as a panel using the existing launcher |
|
||||
| `power` | `action`: `suspend`, `reboot`, `poweroff` | Open a terminal for the user to enter the sudo password |
|
||||
| `keep_awake` | `action`: `on`, `off`, `status` | Optional keep-awake script interface |
|
||||
|
||||
Only install free software with its developer's consent. There is no purchase,
|
||||
entitlement bypass or arbitrary shell tool. `install` returns a background job
|
||||
ID; it does not claim the installation has finished. APK and sideloaded title
|
||||
installs remain in the main UI for now.
|
||||
|
||||
### Approval is a separate human action
|
||||
|
||||
Every mutation first returns an `approvalUrl`, exact action and `confirmation`
|
||||
token. Ask the user to open that URL and choose **Approve this action** or
|
||||
**Reject**. Then repeat the same tool and arguments with the token in
|
||||
`confirmation`. The server refuses execution before approval, changed arguments,
|
||||
expired tokens and reuse. A file approval binds the content hash as well as the
|
||||
path. Approvals last five minutes and disappear when the HTTP server restarts.
|
||||
A failed execution also consumes the approval; review a fresh request to retry.
|
||||
The panel does not execute an action merely because it was approved.
|
||||
|
||||
MCP has no approval tool. This is protection against accidental model tool
|
||||
calls, not a sandbox against a client with independent shell/HTTP access to your
|
||||
computer. Grant the MCP client only the access you intend. Status, captures and computer-state observations
|
||||
are returned directly to that client, which may forward them to its configured
|
||||
model. The assistant's separate opt-in does not govern an external MCP client.
|
||||
|
||||
Power still requires the existing password prompt in a local terminal. MCP
|
||||
never receives passwords. Power via `FRAME_LOCAL=1` is unsupported: use the main
|
||||
UI. The panel launcher and keep-awake adapter require zsh on the computer.
|
||||
|
||||
[PR #16](https://github.com/saphid/frame-control/pull/16) owns
|
||||
`scripts/keep-awake.sh on|off|status`. This branch does not copy or change it.
|
||||
Until that script is present, the tool reports it unavailable. Keep-awake is
|
||||
never automatic: `on` changes the shared idle timers; explicitly approve `off`
|
||||
to restore them after work. It is not a per-agent lease; coordinate with other
|
||||
users. No changes are made to the analytics/update interfaces in
|
||||
[PR #17](https://github.com/saphid/frame-control/pull/17). Prompts, keys, model
|
||||
replies, screenshots and approval payloads are not sent to analytics.
|
||||
|
||||
## Assistant panel
|
||||
|
||||
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
|
||||
To put the same page in the headset, with the HTTP server still running:
|
||||
|
||||
```sh
|
||||
python3 scripts/assistant-on-frame.py --port 47810
|
||||
```
|
||||
|
||||
This starts an SSH reverse forward bound to Frame loopback (port 47812 by
|
||||
default), then a dedicated Chromium profile tagged as a SteamVR panel. Keep the
|
||||
command running. Ctrl-C closes this browser profile and the tunnel; it leaves
|
||||
other Chromium windows and the existing HTTP server alone. A failed cleanup
|
||||
prints the temporary profile path so it can be removed when the Frame returns.
|
||||
Use `--frame-port` if the default is busy. Chromium must already be available as
|
||||
`org.chromium.Chromium`; the launcher never installs anything automatically.
|
||||
Place the panel with SteamVR's normal docking controls.
|
||||
|
||||
Enter your full **chat-completions endpoint**, model name and optional key.
|
||||
An OpenAI-compatible local server works without a key; no OpenAI account is
|
||||
required. HTTP is allowed only on loopback; other endpoints require HTTPS.
|
||||
Loopback refers to the computer running the HTTP server, even in the headset.
|
||||
Endpoints with embedded credentials, query strings or redirects are refused.
|
||||
|
||||
Check the message consent box and press **Send message**. Screenshot context is
|
||||
a separate unchecked box and sends one fresh capture with that request. Both
|
||||
boxes reset after sending, and changing endpoint/model revokes consent. Nothing
|
||||
is sent when opening the page or entering configuration. There is no model
|
||||
list fetch, saved history, automatic screenshot capture or assistant telemetry.
|
||||
Each send is independent: previous messages and replies are not included.
|
||||
|
||||
Configuration, credentials and chat remain in page memory; close/reload the page
|
||||
or choose **Clear everything** to clear them. A request already sent cannot be
|
||||
recalled. Only the chosen endpoint gets the request; proxy environment variables
|
||||
and redirects are disabled. Its privacy and retention policy still applies.
|
||||
Replies are plain text and cannot call tools or operate the Frame. A model must
|
||||
support image inputs to accept screenshot context.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
**Verified 2026-09-28, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** loopback HTTP
|
||||
status through an SSH reverse tunnel; platform Chromium created a separate
|
||||
SteamVR panel (confirmed in `GAMESCOPE_FOCUSABLE_APPS`); headset capture returned
|
||||
a PNG. These checks preceded the UI implementation. No power or global settings
|
||||
were changed.
|
||||
|
||||
**Inferred:** visual comfort and controller keyboard usability while wearing
|
||||
the headset; panel creation in gamescope alone does not establish these.
|
||||
Windows/Linux launcher support, live third-party model endpoints, installs,
|
||||
uninstalls, power and keep-awake changes are not covered by that feasibility
|
||||
check. See the PR for the final unit and end-to-end results.
|
||||
|
||||
**Verified end to end on the same Frame/build (2026-09-28):** a stdio MCP client
|
||||
initialized, read status, retrieved a headset PNG, and transferred a test file
|
||||
only after approval through the Chromium page. Remote file bytes matched;
|
||||
reusing the confirmation was rejected. The actual headset Chromium page sent
|
||||
text and then separately opted-in image context to a local test endpoint and
|
||||
displayed its replies. Without consent there were zero endpoint requests.
|
||||
The test endpoint returned canned replies: model inference and a live external
|
||||
provider remain **unverified**. The launcher’s Ctrl-C cleanup was checked;
|
||||
profiles, SSH tunnels and the test file were removed. No installs, removals,
|
||||
launches of user games, power operations or keep-awake changes were performed.
|
||||
|
||||
**Verified locally:** unit coverage includes the stdio subprocess, approval
|
||||
binding/expiry/replay/concurrency, file-change rejection, and a real local HTTP
|
||||
endpoint for opt-in, text/image payloads and redirect refusal. Fake-Frame
|
||||
regressions are in `tests/e2e/test_agents.py`; local Docker execution was blocked
|
||||
because the Docker daemon was unavailable. The ARM64 fake-Frame CI job passed
|
||||
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.
|
||||
|
||||

|
||||
|
||||
## Computer-use coverage
|
||||
|
||||
MCP is the tool transport, not a limit on what an agent can do. A screenshot,
|
||||
accessibility snapshot, click or keystroke can all be MCP tools when we have a
|
||||
reliable underlying implementation. See [the investigation](computer-use.md)
|
||||
for the verified boundaries. `computer_state` adds observation, not an input
|
||||
channel: it cannot click an approval button or send keyboard/mouse events.
|
||||
|
||||
**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** the command saved
|
||||
by `codex mcp add` launched without a prestarted server, negotiated MCP, listed
|
||||
12 tools, read live Frame status and returned X11 window state plus AT-SPI
|
||||
observations. It exited 0 at EOF. Steam's accessibility tree had inaccessible
|
||||
children, reported as `incomplete: true`; this is not a complete actionable UI.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Announcing changes
|
||||
|
||||
How we tell people about Frame Control features and fixes as they merge. The
|
||||
same few sentences feed the X post, the release notes and the website, so they
|
||||
are written once, in the pull request, while the change is fresh.
|
||||
|
||||
## What gets announced
|
||||
|
||||
| Kind | Announce? | Example |
|
||||
|---|---|---|
|
||||
| **New** — something you can now do | Yes, its own post | Stream Mac windows into the Frame as panels |
|
||||
| **Better** — something existing got noticeably easier, faster or wider | Yes, its own post or a roundup | APKs install without the Android SDK |
|
||||
| **Fixed** — something broken that users hit | Yes if people reported it or it blocked a flow; otherwise the next roundup | Mac mirror showed a zoomed-in corner |
|
||||
| **Release** — a tagged build | Always, one post linking the release | Frame Control 0.3.1 |
|
||||
| Tests, refactors, CI, docs-only, website polish | No | Fake Frame tests, screenshot crop |
|
||||
|
||||
If a change isn't worth a sentence to someone who owns a Frame, it isn't
|
||||
announced.
|
||||
|
||||
## The voice
|
||||
|
||||
Write it the way the README and release notes already read.
|
||||
|
||||
- **Lead with what the person can now do**, in their words: "Install older
|
||||
versions of an app when the newest won't run on the Frame", not "Add APK
|
||||
version fallback resolver".
|
||||
- **Plain and specific.** Name the thing, give the number: "about 30 fps",
|
||||
"4,500 apps", "up to 8 older versions". No "blazing", "game-changing",
|
||||
"excited to announce", "huge", or exclamation marks.
|
||||
- **Say where it works.** Platforms and what it was tested on, briefly:
|
||||
"Tested on a real Frame from macOS 27." Don't claim what wasn't tested.
|
||||
- **Say the catch.** If it needs a setup step, an unsigned build, or only works
|
||||
on one OS, say so in the same post.
|
||||
- **Sentence case**, full sentences, British spelling to match the docs.
|
||||
Contractions are fine.
|
||||
- **No emoji in the text.** One image, GIF or short clip carries the tone
|
||||
instead. The only symbol is the kind label below.
|
||||
- **Unofficial, always.** Never imply Valve made or endorses it. Say "Steam
|
||||
Frame" for the headset and "Frame Control" for the app.
|
||||
- **Credit people.** If a user reported the bug or suggested the feature and is
|
||||
happy to be named, thank them by handle.
|
||||
|
||||
## The formats
|
||||
|
||||
Every announceable PR ends with an `## Announcement` section holding these.
|
||||
The reviewer checks it like code.
|
||||
|
||||
### 1. The post (X, and any other social account)
|
||||
|
||||
```
|
||||
<Kind>: <what you can do now, one sentence>
|
||||
|
||||
<one or two sentences: how it works, the catch, or what it was tested on>
|
||||
|
||||
<link>
|
||||
```
|
||||
|
||||
- `<Kind>` is `New`, `Better` or `Fixed`.
|
||||
- 280 characters maximum including the link (X counts any link as 23).
|
||||
- One link: the release if it has shipped, otherwise the PR.
|
||||
- One visual when the change is visible: a screenshot from the app, a GIF, or a
|
||||
short clip from the headset. Alt text describes what it shows.
|
||||
- No hashtags, except `#SteamFrame` on releases and on posts about something
|
||||
new, because people search for it.
|
||||
|
||||
### 2. The release-note line
|
||||
|
||||
One bullet under **New in x.y.z**, same as the current release notes: the
|
||||
first half of the post's first sentence, no kind label, no link.
|
||||
|
||||
### 3. Release post
|
||||
|
||||
```
|
||||
Frame Control <version>: <the headline change>
|
||||
|
||||
<one sentence on the headline change>. Also: <two or three short items>.
|
||||
|
||||
Windows, macOS and Linux: <release link>
|
||||
#SteamFrame
|
||||
```
|
||||
|
||||
The release title on GitHub uses the same `Frame Control <version>: <headline>`
|
||||
line, as 0.3.0 and 0.3.1 already do.
|
||||
|
||||
### Roundups
|
||||
|
||||
Small fixes that don't earn their own post wait for a roundup, posted with the
|
||||
next release or when three or more have piled up:
|
||||
|
||||
```
|
||||
Fixed in Frame Control this week:
|
||||
- <fix>
|
||||
- <fix>
|
||||
- <fix>
|
||||
|
||||
<link>
|
||||
```
|
||||
|
||||
## Examples from what has already merged
|
||||
|
||||
**#11, older APK versions**
|
||||
|
||||
```
|
||||
New: when an Android app is too new for the Frame, Frame Control now offers
|
||||
older versions that will install.
|
||||
|
||||
It checks F-Droid, its archive and IzzyOnDroid, and verifies each download
|
||||
before it goes on the headset.
|
||||
|
||||
https://github.com/saphid/steam-frame/pull/11
|
||||
```
|
||||
|
||||
**#8, Mac mirror fixes**
|
||||
|
||||
```
|
||||
Fixed: mirroring your Mac into the Steam Frame now fits the whole desktop in
|
||||
the panel, asks for the right password, and shows the cursor.
|
||||
|
||||
Tested end to end on a real Frame from macOS 27.
|
||||
|
||||
https://github.com/saphid/steam-frame/pull/8
|
||||
```
|
||||
|
||||
**v0.3.1**
|
||||
|
||||
```
|
||||
Frame Control 0.3.1: install APKs without the Android SDK
|
||||
|
||||
Frame Control now reads APK files itself, so there's nothing extra to install.
|
||||
Also: Linux and Windows game sideloading, and one-click install links.
|
||||
|
||||
Windows, macOS and Linux: https://github.com/saphid/steam-frame/releases/tag/v0.3.1
|
||||
#SteamFrame
|
||||
```
|
||||
|
||||
## Posting
|
||||
|
||||
Nothing is posted without a person approving it. The flow is:
|
||||
|
||||
1. The PR carries its `## Announcement` section.
|
||||
2. On merge, the post is drafted from that section (manually for now).
|
||||
3. Alex approves or edits it, then it's posted from the project account.
|
||||
4. Replies and questions that turn out to be bugs become GitHub issues labelled
|
||||
`feedback`, same as the website form.
|
||||
@@ -46,6 +46,33 @@ Lepton Development must be installed once. Over SSH,
|
||||
`ssh frame 'steam steam://install/3056000'` queues it, but the install still
|
||||
needs to be confirmed or started in the headset.
|
||||
|
||||
## When an app needs a newer Android
|
||||
|
||||
Lepton is Android 11 (API 30), with arm64-v8a only. If Frame Control refuses
|
||||
an APK, it shows compatible versions from F-Droid's main and archive repos and
|
||||
IzzyOnDroid. It shows at most eight version names, newest first, preferring an
|
||||
arm64-only build, and says how many compatible builds it found in total.
|
||||
Each index is reduced to its compatible builds once a day and cached (about
|
||||
16 MB). The first lookup takes about 30 s and 100 MB of memory; later ones are
|
||||
instant.
|
||||
Choose **Install** to download a listed version, verify its SHA-256 against
|
||||
the index, and install it as its own app.
|
||||
|
||||
You can also inspect a file or look up a package from the command line:
|
||||
|
||||
```sh
|
||||
python3 ui/frame_android.py info some-app.apk
|
||||
python3 ui/frame_android.py versions some-app.apk
|
||||
python3 ui/frame_android.py versions org.example.app
|
||||
```
|
||||
|
||||
The search links open APKMirror, APKPure, Uptodown, F-Droid and GitHub. Pick a
|
||||
version whose minimum is Android 11 or lower and that has an arm64-v8a build
|
||||
(or no native code). Frame Control does not fetch APKs from those search sites.
|
||||
Older versions may lack fixes, and being installable does not guarantee an
|
||||
app will run: see the missing services below. Android may refuse a downgrade
|
||||
or an update signed by a different publisher; removing the app deletes its data.
|
||||
|
||||
## Installed apps disappear when Lepton Development closes (verified 2026-09-25)
|
||||
|
||||
Lepton Development runs in a throwaway "dev" context. When it exits for any
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Computer use through Frame Control MCP
|
||||
|
||||
The MCP transport can carry semantic actions or visual computer-use actions.
|
||||
The limits are the Frame's underlying interfaces, permissions and whether an
|
||||
action can be targeted and verified. A stereoscopic headset screenshot alone
|
||||
is not a reliable coordinate system for clicking a particular app window.
|
||||
|
||||
## What exists, and the right route
|
||||
|
||||
| Surface | Evidence and route | Remaining work or boundary |
|
||||
|---|---|---|
|
||||
| Frame management | **Verified:** existing SSH/HTTP operations for status, capture and file transfer work through MCP. Typed install/launch/power tools wrap the existing API. | Extend typed operations before adding generic mouse automation. Preserve explicit approval for consequential changes. |
|
||||
| App/window observation | **Verified 2026-09-29:** `computer_state` reads gamescope X11 window/app/process triples, focused app and the installed AT-SPI library. | Bounded to 96 accessible nodes and six levels. Trees may be truncated, stale, hidden or incomplete. Snapshot paths and XIDs are observations, never durable action permissions. |
|
||||
| Chromium page content | **Verified previously:** the assistant rendered and could be exercised through CDP in an isolated Frame Chromium profile. | A shipped click/type surface needs exact owned browser/target binding, fresh element references, lifecycle cleanup, consent and post-action readback. Do not expose unrestricted JavaScript or attach to arbitrary existing profiles automatically. |
|
||||
| Steam UI | **Verified 2026-09-29:** the AT-SPI service listed the Steam client's Chromium process and frame nodes, but child traversal was incomplete. Existing `frame_steam.py` uses Steam's loopback CDP endpoint for specific operations. | Prefer those narrow Steam interfaces. Presence of AT-SPI does not prove controls are actionable, and generic pointer injection is not proved for VR menus. |
|
||||
| Other Linux apps | **Verified 2026-09-29:** Frame ships libX11, libXtst and libatspi; `/dev/uinput` is writable by the current user. | Library presence and access permissions do not prove that a game accepts input. Global virtual input can affect whichever app has focus. Do not ship a blind keyboard/mouse tool on this evidence alone. |
|
||||
| Panel focus and layouts | **Documented in [#41](https://github.com/saphid/frame-control/pull/41):** `POST /api/panels` accepts `list`, `focus` and `open`. Focus was verified there. | Reuse that owned interface after integration. Its tested gamescope-owned overlay transform setters return `PermissionDenied`; no reliable saved spatial-layout interface was established. Do not duplicate its implementation here. |
|
||||
| Shared keyboard/trackpad | **Documented in [#19](https://github.com/saphid/frame-control/pull/19):** `/api/input` supplies state/start and event submission, implemented with a bundled KDE Connect daemon. | This branch does not import, launch or depend on that daemon. The user's own-implementation rule remains authoritative. A first-party input implementation or permitted bundled-library route needs its own delivery evidence before MCP integration. |
|
||||
| Physical/device boundaries | **Documented:** an asleep Frame may be off the network; power authorization can require the user's password; physical pairing and headset fit/comfort require the user. | MCP cannot bypass offline hardware, consent, compositor permissions or physical verification. Keep explicit human handoffs. |
|
||||
|
||||
## Reusing the existing computer-use work
|
||||
|
||||
**Documented:** the installed `cua-driver` skill has the right control pattern:
|
||||
observe an exact window, use a semantic target if available, fall back to pixels
|
||||
from that same snapshot, then read back the result. Its browser route requires
|
||||
an exact process/window/target binding and session-scoped element references.
|
||||
Those are useful design rules for Frame tools.
|
||||
|
||||
**Verified locally 2026-09-29:** `cua-driver describe get_window_state` describes
|
||||
host-local process/window IDs and macOS AX inspection. It does not establish an
|
||||
SSH Frame target. The installed skill's advertised Linux companion file is
|
||||
missing. A native ARM64 Frame backend, its dependencies and remote transport
|
||||
have not been verified. We therefore do not claim that the existing Mac driver
|
||||
can control the Frame by passing it a Frame PID or screenshot, and we do not
|
||||
make the feature depend on installing that application.
|
||||
|
||||
Frame Control's `computer_state` is our own Python implementation over installed
|
||||
platform libraries. It sends the probe over SSH stdin, writes no helper to disk,
|
||||
and exits after one observation. Missing displays/libraries return explicit
|
||||
errors; a 15-second process deadline prevents a stalled accessibility call from
|
||||
leaving a probe behind. Window names and accessibility text are untrusted app
|
||||
content, never instructions to an agent.
|
||||
|
||||
**Recommended next implementation:** an isolated Chromium session with typed
|
||||
snapshot/click/type/scroll tools and exact fresh target binding, then individually
|
||||
verified native app actions. Use the headset capture to judge appearance, not to
|
||||
invent a screen-to-window coordinate transform. Direct tool calls must retain
|
||||
approval rules; a generic computer-use tool must not become a route around the
|
||||
MCP approval panel, install confirmation or power confirmation.
|
||||
|
||||
## Isolated browser input proof
|
||||
|
||||
**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** a temporary
|
||||
Frame Chromium profile loaded a local test page through an SSH reverse tunnel.
|
||||
CDP `Input.insertText` entered the test string in its own input. A CDP
|
||||
`Input.dispatchMouseEvent` press/release on its own button copied that string
|
||||
to the page's result; DOM readback matched exactly. The browser profile,
|
||||
loopback forwards and panel log were removed afterward. No user app was typed
|
||||
into, no global settings were changed and no third-party helper app was used.
|
||||
|
||||
AT-SPI did **not** expose the test page's controls in that same probe, even with
|
||||
Chromium's renderer-accessibility flag. It returned the partial Steam-client
|
||||
tree instead. The reason remains **unverified**; this is an evidence gap, not
|
||||
proof that Frame accessibility cannot work. For a first implementation,
|
||||
Chromium's proven page-specific CDP route is stronger than assuming complete
|
||||
AT-SPI coverage. This proof does not ship unrestricted click/type tools or
|
||||
establish input delivery to SteamVR's menus.
|
||||
@@ -17,6 +17,15 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has four 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
|
||||
counts them while they run.
|
||||
|
||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||
floating panels, dashboard and controllers). Shows the left eye, like pointing
|
||||
a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
|
||||
@@ -47,9 +56,11 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
Steam library), then launch, stop, test or remove it. **Report an APK** records
|
||||
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
|
||||
installed app). Your reports are saved on your computer and change the verdicts
|
||||
you see. They aren't uploaded anywhere: the shared database is maintainer-only
|
||||
for now (see [compat-db/README.md](../compat-db/README.md)). Uses the app's bundled
|
||||
`adb`, or yours if you have one.
|
||||
you see. With **Share compatibility results** on (Privacy & updates), they also
|
||||
go to the shared database ([privacy.md](privacy.md),
|
||||
[compat-db/README.md](../compat-db/README.md)). A failed install records
|
||||
itself when the APK was the problem, and after an install the app offers a
|
||||
20-second test. Uses the app's bundled `adb`, or yours if you have one.
|
||||
- **Android display**: pick a running Lepton instance (by the app in it) and set
|
||||
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
|
||||
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
|
||||
@@ -139,5 +150,16 @@ npm run dist:win # Windows: installer and .zip
|
||||
npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
|
||||
```
|
||||
|
||||
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to the
|
||||
release (`.github/workflows/release.yml`).
|
||||
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to a
|
||||
draft release (`.github/workflows/release.yml`). Running copies are offered it
|
||||
once you publish it: see [releasing.md](releasing.md).
|
||||
|
||||
## AI agents and assistant
|
||||
|
||||
**Documented:** [the MCP adapter and assistant panel](agents.md) are Frame
|
||||
Control implementations. MCP wraps this HTTP API without API keys. Changes
|
||||
require a separate user approval; power also retains its password prompt. The
|
||||
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
||||
for a message. Screenshot context is separately opt-in. Model replies cannot
|
||||
operate the headset. Tools → Open assistant opens the page; the linked guide
|
||||
covers putting it in a Chromium panel on the Frame.
|
||||
@@ -51,7 +51,14 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
||||
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
|
||||
| Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons |
|
||||
| **Boot / recovery menu.** Hold Power ~10 s until the LED goes off, then power on while holding the **AUX button on top of the Power button** (not the volume keys) until a text menu appears. Entries: `Current` (SteamOS-A/B + build), `Previous` (the other A/B slot), `Boot from USB`, `Repair Steam Installation`, `Erase User Data` (factory reset), `ADB mode`, `Battery Ship Mode`. It auto-boots `Current` after a ~15 s countdown. **Volume Up/Down (left side) move, AUX (right side) selects.** For a boot loop, Valve says pick `Previous` (keeps user data); then `Repair Steam Installation`; `Erase User Data` wipes `~` (SSH keys, Tailscale, Flatpaks, T3 setup). Last resort is a full re-image, two ways: (1) USB: write `steamframe-repair-latest.img.bz2` to an 8 GB+ USB-C stick (Balena Etcher on the Mac), pick `Boot from USB`, then use "Wipe Device & Install SteamOS" / "Repair SteamOS" (keeps games and personal content) from the recovery desktop; (2) cable/EDL: `steamframe-repair-qdl-latest.tar.gz`, run `flash.sh` (Linux) or `flash.cmd` (Windows), then with the Frame off for 10 s hold Power + Vol Up + Vol Down for 10 s and plug it in; it reflashes and reboots. Both images: `https://steamdeck-images.steamos.cloud/recovery/` (build 20260922.5153644, 0.3.0, ~4 GB each, no published checksums); local copies in `~/Downloads/steam-frame-recovery/`. Source: Valve's [SteamOS Recovery FAQ](https://help.steampowered.com/en/faqs/view/1B71-EDF2-EB6D-2BB3) and [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227), plus a menu photo in [EloiStree/HelloSteamFrame#9](https://github.com/EloiStree/HelloSteamFrame/issues/9). **Inferred** (Valve docs, 2026-09-26); not yet tried on our Frame. | Recovering from a boot loop |
|
||||
| **SSH server:** OpenSSH 9.7p1. It offers `publickey,password` (keyboard-interactive is off, PAM on) and also asks `userdbctl ssh-authorized-keys` for keys. OpenSSH ≥ 8.8 rejects SHA-1 `ssh-rsa` signatures by default, so a client whose RSA support is SHA-1 only (the Swift library Citadel, for one) can't log in with the RSA key that devkit pairing installs; use ed25519 (**inferred** from OpenSSH defaults). **Verified 2026-09-27**, BUILD_ID 20260922.6101926. | [iphone.md](iphone.md), `ui/frame_connect.py` |
|
||||
| **Tools on the image:** Python 3.12.3, `ffmpeg`, `openssl`, `curl`, `rsync`, `zip`/`unzip`, `flatpak`, `wpctl`, `podman`. **No `adb`.** `steamos` is uid 1000, in `wheel`, and sudoers has `%wheel ALL=(ALL) ALL`, so `sudo -S` takes the Developer Mode password on stdin. **Verified 2026-09-27.** | Running Frame Control's server on the Frame (`FRAME_LOCAL=1`, [iphone.md](iphone.md)) |
|
||||
| **Each Lepton instance is a podman container** named `lepton-steamlaunch-<instance id>`, labelled with its ADB port (`podman ps --format '{{.Names}} {{.Labels.adb_port}}'`). `podman exec <container> /system/bin/sh -c '…'` runs Android's shell inside it with no adb at all (used for `pidof` and `logcat` by the app tester). Running `wm size`/`wm density` that way is untested. **Verified 2026-09-27.** | `ui/frame_android.py`, the iPhone app's display settings |
|
||||
| **Asleep means off the network.** In standby the Frame stops answering on its LAN address, `frame.local` and Tailscale alike (`Host is down`, `No route to host`, timeouts), and ping fails. It was unreachable for about 2.5 hours until woken. Nothing over SSH can wake it. **Verified 2026-09-27.** | Frame Control's offline banner and retries |
|
||||
| **What puts it to sleep is Steam's idle timer**, not logind. The journal shows `steamui_system: Switching to power state: [ k_ESystemPowerState_Sleep ] reason: 'ComputeNextPowerState: active: 3600 < 3600 (k_EACState_Connected)'`, then Steam suspends. SSH work doesn't count as activity. The timers are the client settings `system_idle_suspend_ac_sec` (3600) and `system_idle_suspend_battery_sec` (900); 0 means Never (Settings → Power → Sleep after inactivity). They can be written over DevTools the way the settings page does. logind refuses a `systemd-inhibit --mode=block` sleep lock from an SSH session (`Interactive authentication required`) but accepts one started with `systemd-run --user`. `scripts/keep-awake.sh on|off|status` does both and restores the old timers on `off`. **Verified 2026-09-28**, BUILD_ID 20260925.6191901. Whether Steam's suspend honours the inhibitor on its own is **inferred** (polkit gives `steamos` no `suspend-ignore-inhibit`), not tested. | Keeping the Frame awake for agent work |
|
||||
| **Battery at full on a charger** can read `Discharging` at about 0 W (for example 99 %, 0.0 W, USB-C PD 18 W). Treat under 0.5 W on a charger as "not charging", not "draining". **Verified 2026-09-27.** | Frame Control's battery card |
|
||||
| **The OS image is downloadable.** Valve's recovery images for the Frame are at `https://steamdeck-images.steamos.cloud/recovery/`. The root filesystem inside is btrfs, and it runs as an SSH test target on ARM64 Linux without the headset (`tests/frame-container/frame-image.sh`). **Verified 2026-09-27.** | [recovery-and-images.md](recovery-and-images.md) |
|
||||
| **Boot / recovery menu.** Hold Power ~10 s until the LED goes off, then power on while holding the **AUX button on top of the Power button** (not the volume keys) until a text menu appears. Entries: `Current` (SteamOS-A/B + build), `Previous` (the other A/B slot), `Boot from USB`, `Repair Steam Installation`, `Erase User Data` (factory reset), `ADB mode`, `Battery Ship Mode`. It auto-boots `Current` after a ~15 s countdown. **Volume Up/Down (left side) move, AUX (right side) selects.** For a boot loop, Valve says pick `Previous` (keeps user data); then `Repair Steam Installation`; `Erase User Data` wipes `~` (SSH keys, Tailscale, Flatpaks, T3 setup). Last resort is a full re-image, two ways: (1) USB: write `steamframe-oobe-repair-<build>.img.bz2` to an 8 GB+ USB-C stick (Balena Etcher on the Mac), pick `Boot from USB`, then use "Wipe Device & Install SteamOS" / "Repair SteamOS" (keeps games and personal content) from the recovery desktop; (2) cable/EDL: `steamframe-oobe-repair-qdl-<build>.tar.gz`, run `flash.sh` (Linux) or `flash.cmd` (Windows), then with the Frame off for 10 s hold Power + Vol Up + Vol Down for 10 s and plug it in; it reflashes and reboots. Both images: `https://steamdeck-images.steamos.cloud/recovery/` (build 20260922.5153644, 0.3.0, 3.8 GiB each, no published checksums); local copies in `~/Downloads/steam-frame-recovery/`. File names, checksums and what's inside: [recovery-and-images.md](recovery-and-images.md). Source: Valve's [SteamOS Recovery FAQ](https://help.steampowered.com/en/faqs/view/1B71-EDF2-EB6D-2BB3) and [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227), plus a menu photo in [EloiStree/HelloSteamFrame#9](https://github.com/EloiStree/HelloSteamFrame/issues/9). **Inferred** (Valve docs, 2026-09-26); not yet tried on our Frame. | Recovering from a boot loop |
|
||||
| **Boot loop cause: the SteamVR health check.** `steamvr.service` runs `/usr/share/deckard/steamvr-health-check`, which appends `frog:glasses:` to `$XDG_RUNTIME_DIR/steamvr-short-session-tracker` on every failed or <10 s SteamVR run. At 3 it runs `steam-health-check --repair-now`, which **deletes all of `~/.local/share/Steam` (games, login, Developer Mode) and `~/.steam`**, keeping only `registry.vdf`. At 4 it also tries `steamos-bootconf set-mode reboot-other` (fails as the user: `bootenv: Permission denied`). SteamVR normally fails 1–2 times per boot while it waits for the Steam client (`SteamAPI_InitEx failed … Steam is probably not running`, then `fatal stalled cross-thread pipe`). Once Steam has been wiped, it has to re-download a ~210 MB client on every boot, so SteamVR keeps failing, Steam keeps getting wiped and the Frame reboots, in a loop. Also, the Steam updater can deadlock at `Installing update...` (main process blocked writing to the `-child-update-ui` process, which is stuck in `drm_syncobj_array_wait_timeout`). Killing only the `-child-update-ui` process lets the install finish (`package/*.installed` appears). **Fix without sudo:** over USB-C ADB (`adb -s frame shell` works as `steamos` while the Frame is looping; SSH is refused once Developer Mode is lost), truncate both `/run/user/1000/steam{,vr}-short-session-tracker` files and `chmod 444` them (the health check then logs `Permission denied` and does nothing; this is tmpfs, so it resets on reboot). Unstick the updater if needed, let Steam finish installing, then hold Power 10 s and start the Frame normally. `systemctl reboot` over ADB needs interactive auth. After the fix, sign in to Steam and turn Developer Mode back on. **Verified 2026-09-26**, BUILD_ID 20260922.6101926, slot B (clean boot: 0 SteamVR failures, SSH and Tailscale back). | Diagnosing a boot loop |
|
||||
|
||||
## Debug recipes
|
||||
@@ -79,4 +86,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
|
||||
- Installing and buying Steam games: [steam-games.md](steam-games.md)
|
||||
- Remote access from anywhere: [tailscale.md](tailscale.md)
|
||||
- Floating windows in space: [panels.md](panels.md)
|
||||
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
|
||||
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
|
||||
- What's still unverified: [open-questions.md](open-questions.md)
|
||||
|
After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 892 KiB After Width: | Height: | Size: 824 KiB |
|
After Width: | Height: | Size: 305 KiB |
@@ -0,0 +1,109 @@
|
||||
# Frame Control for iPhone
|
||||
|
||||
The iPhone (and iPad) app does what the desktop app does, from the phone:
|
||||
headset view and live video, battery and status, screenshots, Steam games,
|
||||
Android apps and their display settings, sideloading, files, clipboard,
|
||||
Flatpaks, and power. Source: [`ios/`](../ios).
|
||||
|
||||
## How it works
|
||||
|
||||
An iPhone can't run Python or `ssh`, but the Frame can. So the app:
|
||||
|
||||
1. connects to the Frame over SSH itself (the [Citadel](https://github.com/orlandos-nl/Citadel)
|
||||
Swift SSH library), with its own ed25519 key from the Keychain;
|
||||
2. copies Frame Control's server and helpers (`ios/scripts/make_frame_bundle.py`,
|
||||
under 1 MB) to `~/.cache/frame-control/<version>` on the Frame, once per version;
|
||||
3. starts `ui/server.py` there with `FRAME_LOCAL=1`. It listens only on the
|
||||
Frame's own 127.0.0.1, and it stops when the phone disconnects (`--exit-on-eof`);
|
||||
4. tunnels to it through the SSH session and shows the same page as the desktop
|
||||
app, in a web view. The page carries a fresh key each session, which the
|
||||
server requires on every request.
|
||||
|
||||
With `FRAME_LOCAL=1`, every `ssh frame COMMAND` the server runs goes to
|
||||
`ui/local-bin/ssh`, which runs the command on the Frame directly (rsync uses it
|
||||
as its transport too), so the desktop and phone share one code path. Android
|
||||
display settings use `podman exec` into each Lepton container instead of adb,
|
||||
which the Frame doesn't have.
|
||||
|
||||
Nothing is left running on the Frame after the phone disconnects; the copied
|
||||
files stay in `~/.cache/frame-control` (delete it any time).
|
||||
|
||||
## Pairing
|
||||
|
||||
On the Frame, turn on Developer Mode and set a user password (Steam Settings →
|
||||
System, then Developer → Set User Password). In the app, enter the headset's
|
||||
address (`frame.local`, its IP, or its Tailscale name) and that password once.
|
||||
The app adds its own key to `~/.ssh/authorized_keys` and remembers the Frame's
|
||||
host key; the password isn't saved. If you already reach the Frame over SSH,
|
||||
**Or add the key yourself** shows the phone's key to paste into
|
||||
`authorized_keys`, and connects without a password.
|
||||
|
||||
Valve's tap-to-approve devkit pairing isn't used: it only takes RSA keys, and
|
||||
the Frame's OpenSSH 9.7 rejects the SHA-1 RSA signatures the Swift SSH library
|
||||
makes.
|
||||
|
||||
## What's different on the phone
|
||||
|
||||
| Desktop | iPhone |
|
||||
|---|---|
|
||||
| Drop files anywhere | Tap **Send to Frame** (or Add a game) and pick files; folders need zipping |
|
||||
| Screenshots save to `~/Pictures/SteamFrame` | Save opens the share sheet: Save Image puts it in Photos |
|
||||
| SSH and SFTP open a terminal | They open an app that handles `ssh://` / `sftp://` (Blink Shell, Termius) |
|
||||
| Steam Link, remote desktop | Open the Steam Link and Windows App apps |
|
||||
| Sleep, restart, shut down ask in a terminal | The page asks for the Developer Mode password |
|
||||
| Compatibility reports kept on the computer | Kept on the Frame (`~/.local/share/Frame Control`) |
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
cd ios
|
||||
xcodegen generate # after changing project.yml
|
||||
open FrameControl.xcodeproj
|
||||
```
|
||||
|
||||
The build packs the Frame bundle from the checkout, so the phone always runs
|
||||
the page and server from the same commit. Running on a phone needs your own
|
||||
signing team in Xcode (Signing & Capabilities).
|
||||
|
||||
## Verified
|
||||
|
||||
<img src="img/iphone-tabs.jpg" alt="The four tabs in the iPhone app, connected to a Frame" width="900">
|
||||
|
||||
In the iOS Simulator (iOS 26.5) against a real Frame, 2026-09-27: the app connected
|
||||
with its key, copied the bundle over SFTP, started the server on the Frame and
|
||||
showed all four tabs with live data. In the app's web view, Capture returned a
|
||||
headset still and Live played H.264 video at 31 fps (WebCodecs works in
|
||||
WKWebView). Through the app's tunnel: status, games, Steam library, Android apps,
|
||||
screenshots, a file upload (checked on the Frame), a background install job, and
|
||||
the power password check (a wrong password is refused). The server on the Frame
|
||||
exits within seconds of the app closing.
|
||||
|
||||
Against Valve's own Steam Frame OS (SteamOS 0.3.0 build 20260922.5152327, the
|
||||
`rootfs-A` partition of the Frame recovery image, run with its own sshd; see
|
||||
[tests/frame-container](../tests/frame-container)), and a Holo Core stand-in:
|
||||
pairing with the password (key added with the right
|
||||
permissions, host key pinned, password stored nowhere), the power password
|
||||
check (a wrong or missing password refused; the right one reaches `systemctl`),
|
||||
a changed host key refused with "Pair with the Frame again", and a wrong
|
||||
pairing password reported the same way.
|
||||
|
||||
Also verified in the Simulator against the Frame (2026-09-27): the setup screen
|
||||
found the Frame by itself over Bonjour (`frame · 192.168.1.237`); a paired app
|
||||
waiting for a sleeping Frame connected 4 s after it answered; an upload from the
|
||||
app's web view landed in `~/Downloads`; the share sheet offers Save Image
|
||||
(needs `NSPhotoLibraryAddUsageDescription`, now declared); an install link opens
|
||||
the confirm dialog and downloads nothing until Install; Steam Link without the
|
||||
app installed opens its App Store page.
|
||||
|
||||
Things iOS asks the first time: **Local Network** (tap Allow, or the app can't
|
||||
see the Frame), and **Paste** when you send the iPhone's clipboard (tap Allow
|
||||
Paste, or set Settings → Apps → Frame Control → Paste from Other Apps → Allow).
|
||||
Sending text to the Frame's clipboard needs the desktop panel open in the
|
||||
headset, as on the desktop app.
|
||||
|
||||
Not yet exercised: Android display changes through podman (no Android app was
|
||||
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
|
||||
|
||||
Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`,
|
||||
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
|
||||
don't.
|
||||
@@ -33,14 +33,19 @@ build 20260922.6101926, kernel 6.18, aarch64):
|
||||
- **10.** `install-apps.sh remmina --vnc-host <mac>.local` installed Remmina as
|
||||
a `--user` Flatpak over SSH and wrote the profile. The desktop's
|
||||
`XDG_DATA_DIRS` includes the user Flatpak exports, so it shows up in the menu.
|
||||
The Frame can reach the Mac's Screen Sharing port (5900). The Remmina
|
||||
connection itself hasn't been tried in the headset yet (part of 11).
|
||||
The Frame can reach the Mac's Screen Sharing port (5900).
|
||||
- **11.** Answered 2026-09-27 (BUILD_ID 20260925.6191901, macOS 27.0): the
|
||||
pre-seeded profile connects and shows the Mac in its own panel. It asks for
|
||||
the Mac account login rather than the VNC password, needs scale-to-fit at
|
||||
Retina resolutions, and doesn't show the Mac cursor without
|
||||
`scripts/mac-cursor-ring.lua`. It's usable but noticeably laggy. See
|
||||
[streaming.md](streaming.md).
|
||||
|
||||
- **Panels.** An X11 window on gamescope's `:0` with its own `STEAM_GAME` id
|
||||
gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were
|
||||
created side by side with `panel-on-frame.sh`. See [panels.md](panels.md).
|
||||
|
||||
Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a reboot), 17–21.
|
||||
Still open: 4, 6, 7, 12–15, 16 (off-LAN and after a reboot), 17–21.
|
||||
|
||||
## Check on the headset (in order)
|
||||
|
||||
@@ -70,12 +75,10 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
|
||||
`ssh frame 'command -v wl-copy xclip rsync flatpak'`.
|
||||
10. **Can Flatpaks be installed `--user` over SSH, and do they appear in the
|
||||
headset's desktop?** Test with `./scripts/install-apps.sh remmina`.
|
||||
11. **Remmina → macOS Screen Sharing:** does it connect, and is it usable at
|
||||
Retina resolutions? Is the pre-seeded profile path
|
||||
(`~/.var/app/org.remmina.Remmina/data/remmina/`) the one Remmina
|
||||
actually reads?
|
||||
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** worth trying only if
|
||||
VNC is too slow.
|
||||
11. ~~**Remmina → macOS Screen Sharing**~~: answered 2026-09-27; see above
|
||||
and [streaming.md](streaming.md).
|
||||
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** VNC works but is
|
||||
noticeably laggy, so this is worth trying.
|
||||
13. **KDE Connect**: is it preinstalled or installable on the Frame, and does
|
||||
it pair with KDE Connect for macOS?
|
||||
14. **Bluetooth keyboard pairing** on the Frame, for the rare times you do need
|
||||
@@ -86,8 +89,9 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
|
||||
runs as a lingering user service with no sudo; see [tailscale.md](tailscale.md).
|
||||
Still open: reaching the Frame from outside the home network, and the service
|
||||
starting after a reboot.
|
||||
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the
|
||||
panels from `panel-on-frame.sh` show up, take input, and offer **Float in
|
||||
17. **Floating panels in the headset** (see [panels.md](panels.md)): panels
|
||||
from `panel-on-frame.sh` show up and take controller input (verified
|
||||
2026-09-27 with `mac-screen`). Still open: do they offer **Float in
|
||||
World** / **Move** / **Size**? Do floating positions survive closing and
|
||||
reopening the app, or a reboot?
|
||||
18. **`LEPTON_NO_CLEANUP=1 %command%`** as Lepton Development's launch
|
||||
@@ -103,12 +107,32 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
|
||||
the colour-coded test clips play in 3D (red left eye, cyan right) for both
|
||||
H.264 and H.265? Does the DLNA browser find a server on the Mac?
|
||||
|
||||
## Verified 2026-09-27
|
||||
|
||||
- **Recovery images exist** for the Frame at
|
||||
`https://steamdeck-images.steamos.cloud/recovery/`; the root filesystem inside
|
||||
is btrfs and runs, as a userland, on ARM64 Linux. See
|
||||
[recovery-and-images.md](recovery-and-images.md).
|
||||
- **Frame Control's server runs on the Frame itself** (the iPhone app does
|
||||
this), including headset capture, 31 fps live video and file uploads. See
|
||||
[iphone.md](iphone.md).
|
||||
- **Password pairing and `sudo -S`** work against the recovery image's own
|
||||
sshd and sudo (not yet against the headset, whose password we don't hold).
|
||||
|
||||
## Still open (2026-09-27)
|
||||
|
||||
- Does `podman exec <lepton container> /system/bin/sh -c 'wm size'` change an
|
||||
instance's display the way `adb shell wm size` does?
|
||||
- Can the recovery image, or its kernel, boot in a VM at all?
|
||||
- Does a real sleep, restart or shut down from the iPhone app work (via
|
||||
`sudo -S systemctl`)?
|
||||
- The Mac EDL flashing script in `~/Downloads/steam-frame-recovery/` hasn't
|
||||
been run against a Frame.
|
||||
|
||||
## Unconfirmed claims made in these docs
|
||||
|
||||
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
|
||||
Steam Deck behaviour.
|
||||
- The whole Mac → Frame desktop path (VNC → Remmina). Each part is documented
|
||||
separately, but the combination is untested.
|
||||
- Steam Remote Play with a Mac as host is broken. That's based on community
|
||||
reports, not tested with the Frame.
|
||||
- `connect.sh --harden`, `serve-bootstrap.sh` and
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Privacy and analytics
|
||||
|
||||
Frame Control sends anonymous analytics to [PostHog](https://posthog.com)
|
||||
(US cloud) so the maintainer can see how many people use it, which features
|
||||
matter and where installs fail. You choose how much in **Privacy & updates**,
|
||||
the last panel on the page. `ui/frame_telemetry.py` is the whole
|
||||
implementation.
|
||||
|
||||
## The three levels
|
||||
|
||||
| Level | Default | What it sends |
|
||||
|---|---|---|
|
||||
| Anonymous usage statistics | On, after a notice on first run | The events in the table below |
|
||||
| Share compatibility results | Off | Your Android compatibility reports and tests |
|
||||
| Send error details | Off | Scrubbed error messages and tracebacks |
|
||||
|
||||
Nothing is sent until the first-run notice has been shown. The notice's
|
||||
**Share more to help fix problems** button turns on the second and third
|
||||
levels together. Either can be turned off later. Turning a level off
|
||||
deletes that level's events that haven't been sent yet.
|
||||
|
||||
**Show what's been sent** in the panel lists the last 50 events that left your
|
||||
computer, exactly as they were sent.
|
||||
|
||||
## Anonymous
|
||||
|
||||
- Events carry a random id, made when Frame Control first runs and kept in
|
||||
its data folder (`telemetry/settings.json`). It isn't derived from your
|
||||
computer, account or network. To get a new one, delete that file.
|
||||
- Events are sent without person profiles (`$process_person_profile: false`)
|
||||
and without location lookup (`$geoip_disable: true`). Each carries a
|
||||
placeholder address (`$ip: 0.0.0.0`), so PostHog stores that instead of
|
||||
yours.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
|
||||
## Usage events
|
||||
|
||||
| Event | When | Properties besides the common ones |
|
||||
|---|---|---|
|
||||
| `app_installed` | First run | |
|
||||
| `app_updated` | First run of a new version | `from_version` |
|
||||
| `app_opened` | At most once a day | |
|
||||
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
|
||||
| `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 |
|
||||
| `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:
|
||||
|
||||
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
|
||||
catalogue app's package name
|
||||
- Flathub apps: `flatpak_id`
|
||||
- Steam games: `steam_appid`
|
||||
- A sideloaded title: only its runtime (Proton or Linux)
|
||||
|
||||
Any other APK is sent as `catalog: false`, with no name.
|
||||
|
||||
## Compatibility results (opt-in)
|
||||
|
||||
Each report becomes a `compat_report` event with the fields the Report dialog
|
||||
shows: package, version, result or rating, your notes, how it was run, and the
|
||||
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,
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Error details (opt-in)
|
||||
|
||||
`$exception` events carry an error message, the Frame Control file, line and
|
||||
function it came from, and the request that failed (for example
|
||||
`POST /api/android install`). Before anything is sent, the message is
|
||||
scrubbed:
|
||||
|
||||
- your home folder becomes `~`, and any user name becomes `<user>`
|
||||
- IP and MAC addresses, email addresses, `.local`, `.lan` and Tailscale host
|
||||
names, Steam ids, SSH and PEM keys, API tokens and long hex strings are
|
||||
replaced
|
||||
- URLs are cut down to their scheme and a public host name, or `<url>`. User
|
||||
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.
|
||||
|
||||
## Report a problem
|
||||
|
||||
**Report a problem** is the warning-sign button in the header, also in the
|
||||
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
||||
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.
|
||||
|
||||
With **Include diagnostics** ticked (the default), the report adds:
|
||||
|
||||
- the app version and whether it's a built app
|
||||
- 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
|
||||
|
||||
**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.
|
||||
|
||||
Everything is scrubbed like error details and limited to what fits in the
|
||||
report. Environment details are kept first, then the newest lines. **Show
|
||||
exactly what's included** shows the snapshot that will be sent, and later
|
||||
activity isn't added to it. If PostHog can't be reached, **Copy report** puts
|
||||
the whole report on the clipboard.
|
||||
|
||||
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`.
|
||||
|
||||
## 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.
|
||||
|
||||
## Update checks
|
||||
|
||||
The desktop app asks GitHub for the latest release shortly after starting,
|
||||
then every 6 hours: the latest release's `update.json` on GitHub, or
|
||||
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
|
||||
Those requests carry no id. To stop it, set
|
||||
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
|
||||
@@ -0,0 +1,109 @@
|
||||
# Recovery images and OS images for the Frame
|
||||
|
||||
Where to get the Steam Frame's operating system, what's inside it, and how to
|
||||
run it for testing without the headset. For recovering a Frame that won't boot,
|
||||
see the boot menu and boot-loop entries in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing).
|
||||
|
||||
## Downloads
|
||||
|
||||
Valve's SteamOS download page (`store.steampowered.com/steamos/download`)
|
||||
redirects to the [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227),
|
||||
which offers the Steam Deck image. The **Steam Frame images are on the same
|
||||
server** but aren't linked from that page:
|
||||
**https://steamdeck-images.steamos.cloud/recovery/** (a plain directory
|
||||
listing, checked 2026-09-27).
|
||||
|
||||
| File | Size | Use |
|
||||
|---|---|---|
|
||||
| `steamframe-oobe-repair-20260922.5153644-0.3.0.img.bz2` (or `.img.zip`) | 3.8 GiB | Write to an 8 GB+ USB-C stick, then **Boot from USB** in the Frame's boot menu |
|
||||
| `steamframe-oobe-repair-qdl-20260922.5153644-0.3.0.tar.gz` (or `.zip`) | 3.8 GiB | Flash over a USB-C cable in Qualcomm EDL mode with `flash.sh` (Linux) or `flash.cmd` (Windows), which use [qdl](https://github.com/linux-msm/qdl). **Wipes everything** |
|
||||
|
||||
All four are dated 2026-09-22. Everything else there is for the Steam Deck
|
||||
(`steamdeck-…`, x86-64), which won't run on the Frame. Valve publishes **no
|
||||
checksums**. These are the SHA-256s of our downloads (2026-09-26), which passed
|
||||
`bzip2 -t` and `tar -t`:
|
||||
|
||||
```
|
||||
3a4a077f1b1f40688ab3279affcb56776bd97c54db1573e7c65fc52a97106676 steamframe-oobe-repair-20260922.5153644-0.3.0.img.bz2
|
||||
d3323bfa8efe9ece1954948421cdf5f705e8942eb50c960e2916d935d1b850ab steamframe-oobe-repair-qdl-20260922.5153644-0.3.0.tar.gz
|
||||
```
|
||||
|
||||
Our copies, with a Mac EDL flashing script built on qdl (untested), are in
|
||||
`~/Downloads/steam-frame-recovery/` on the Mac.
|
||||
|
||||
## What's inside the USB image
|
||||
|
||||
A GPT disk with 512-byte sectors and one A slot (a Frame has A and B slots;
|
||||
the installer makes the rest). **Verified 2026-09-27** from
|
||||
`steamframe-oobe-repair-20260922.5153644-0.3.0.img.bz2`:
|
||||
|
||||
| # | Name | Start sector | Size | Type GUID |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `esp` | 34 | 256 MiB | `c12a7328-f81f-11d2-ba4b-00a0c93ec93b` (EFI system) |
|
||||
| 2 | `efi-A` | 524322 | 64 MiB | `ebd0a0a2-b9e5-4433-87c0-68b6b72699c7` |
|
||||
| 3 | `rootfs-A` | 655394 | 5120 MiB | `4f68bce3-e8cd-4db1-96e7-fbcaf984b709` |
|
||||
| 4 | `var-A` | 11141154 | 256 MiB | `4d21b016-b534-45c2-a9fb-5c16e091fd2d` |
|
||||
| 5 | `home` | 11665442 | 100 MiB | `933ac7e1-2eb4-4f13-b844-0e14e2aef915` |
|
||||
|
||||
The partitions start at sector 34, not on MiB boundaries, so compute offsets
|
||||
from the table (sector × 512), not from rounded sizes. `rootfs-A` is **btrfs**
|
||||
(label `rootfs-A`, 9.2 GB of files), mounted read-only on the Frame.
|
||||
Its `/etc/os-release` says `NAME="SteamOS"`, `ID=steamos`, `ID_LIKE=arch`,
|
||||
`VERSION_CODENAME=holo`; the running system reports version 0.3.0, variant
|
||||
`vr`, build **20260922.5152327**, which is a different number from the
|
||||
`5153644` in the file name. Our headset reports build 20260922.6101926.
|
||||
|
||||
Inside, it matches a real Frame:
|
||||
|
||||
- User `steamos` (uid 1000) is in `wheel` (gid 998), and sudoers has
|
||||
`%wheel ALL=(ALL) ALL`, so sudo asks for the Developer Mode password.
|
||||
- `sshd_config` includes `sshd_config.d/*.conf`, uses `.ssh/authorized_keys`
|
||||
plus `AuthorizedKeysCommand /usr/bin/userdbctl ssh-authorized-keys %u`,
|
||||
and sets `KbdInteractiveAuthentication no` and `UsePAM yes`. So sshd offers
|
||||
`publickey,password`, the same as the headset.
|
||||
- `/usr/bin` has `sshd`, `sudo`, `python3` and `podman`.
|
||||
|
||||
Get just the root filesystem without unpacking the whole 5.8 GB image (the
|
||||
partition's start and size, in sectors, come from the table above):
|
||||
|
||||
```sh
|
||||
bzcat steamframe-oobe-repair-*.img.bz2 | tail -c +$((655394 * 512 + 1)) | head -c $((10485760 * 512)) > rootfs-A.img
|
||||
```
|
||||
|
||||
A Mac can't mount btrfs; a Linux machine or VM can (`mount -o ro -t btrfs`).
|
||||
|
||||
## Running it without the headset
|
||||
|
||||
The image can't boot in a generic virtual machine: its kernel and bootloader
|
||||
are built for the Frame's Qualcomm Snapdragon 8 Gen 3 (**inferred**; not
|
||||
attempted). Its **userland** runs fine on any ARM64 Linux, which covers
|
||||
anything that talks to the Frame over SSH.
|
||||
|
||||
[`tests/frame-container/frame-image.sh`](../tests/frame-container/frame-image.sh)
|
||||
extracts `rootfs-A`, mounts it read-only with a throwaway writable layer, and
|
||||
starts the image's own `sshd` on port 2223 (user `steamos`, a test password;
|
||||
`systemctl` only records requests). On a Mac, run it in Colima's ARM64 VM (see
|
||||
[tests/frame-container/README.md](../tests/frame-container/README.md)).
|
||||
**Verified 2026-09-27:** the iPhone app paired with it by password (the image's
|
||||
sshd logged `Accepted password`, then `Accepted publickey … ED25519`), ran
|
||||
Frame Control's server on the image's Python, and the image's sudo rejected a
|
||||
wrong power password and passed the right one to `systemctl`. Without the
|
||||
Frame's hardware there's no SteamVR, Steam client, battery or Lepton, so those
|
||||
parts stay untested this way.
|
||||
|
||||
## Holo Core aarch64 (Valve and Collabora)
|
||||
|
||||
The ARM64 port of Arch Linux that the Frame's SteamOS is built on, published as
|
||||
a preview in July 2026 ([Collabora's announcement](https://www.collabora.com/news-and-blog/news-and-events/building-an-arch-linux-aarch64-port-for-holo-core.html)).
|
||||
It's a base system and build environment, not the Frame's OS:
|
||||
|
||||
- Source: `https://gitlab.steamos.cloud/holo/holo-core-aarch64-preview`
|
||||
- Packages: `https://holo-packages.steamos.cloud/holo-core-aarch64-preview/mash-20251118`
|
||||
- Container: `registry.gitlab.steamos.cloud/holo/holo-core-aarch64-preview/base-devel:latest`
|
||||
(1.7 GB; `/etc/os-release` says "Holo core Aarch64 port (preview)"; `pacman`
|
||||
installs OpenSSH 10.2, Python 3.13 and sudo from its repositories. Checked 2026-09-27.)
|
||||
|
||||
[`tests/frame-container/Dockerfile`](../tests/frame-container/Dockerfile) builds a
|
||||
lighter Frame stand-in on it (a `steamos` user with a password and sudo, sshd
|
||||
with keys and passwords), handy when you don't have the 4 GB image.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Releasing and updates
|
||||
|
||||
Frame Control checks for updates itself. The desktop app offers a new version
|
||||
only once it's GitHub's **latest release**, and drafts and pre-releases never
|
||||
count. So a build reaches people only when you publish it, after testing it.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Bump `version` in `app/package.json`, commit, and push a tag:
|
||||
|
||||
```sh
|
||||
git tag v0.4.0 && git push origin v0.4.0
|
||||
```
|
||||
|
||||
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
|
||||
attaches everything to a **draft** release for that tag. Nobody is
|
||||
offered a draft.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
|
||||
3. Write the release notes on the draft. The update banner links to them.
|
||||
|
||||
4. Publish:
|
||||
|
||||
```sh
|
||||
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
|
||||
seconds after starting, then every 6 hours, and anyone can use **Check for
|
||||
Updates…** (the app menu on macOS, the Help menu elsewhere).
|
||||
|
||||
To pull a bad release, mark the previous one as latest
|
||||
(`gh release edit v0.3.9 --latest`) or turn the bad one back into a draft.
|
||||
Copies that already updated stay on it. Nothing downgrades them.
|
||||
|
||||
## How a copy updates itself
|
||||
|
||||
`app/updater.js` reads `update.json` from
|
||||
`github.com/saphid/frame-control/releases/latest/download/`. It falls back to the
|
||||
REST API only when a release has no manifest, because the API allows just 60
|
||||
unauthenticated requests an hour per IP address, shared by a whole household.
|
||||
Then it downloads the installer for its platform and checks it
|
||||
against the SHA-256 digest GitHub publishes for the asset. It refuses if the
|
||||
digest is missing or doesn't match. Then:
|
||||
|
||||
| Installed from | Update |
|
||||
|---|---|
|
||||
| macOS `.dmg`, app in a writable folder such as Applications | The `.zip` is unpacked next to the app and its version checked. After the app quits, a small script swaps the new app in, putting the old one back if that fails, and reopens it. Updates don't get the download quarantine, so there's no `xattr` step. |
|
||||
| Windows installer | The new `Setup` runs silently over the install (`/S --force-run`) and reopens the app. |
|
||||
| Linux AppImage | The new AppImage replaces the old file and is started. |
|
||||
| macOS app still on the disk image or translocated, Windows `.zip`, Linux `.deb` | The banner opens the release page instead. |
|
||||
|
||||
Version 0.3.1 and earlier have no updater, so people on them have to download
|
||||
the new version once by hand.
|
||||
@@ -93,7 +93,8 @@ controls to place each panel. See [docs/panels.md](panels.md).
|
||||
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
|
||||
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
|
||||
| `scripts/install-apk.sh` | Mac → Frame | Install APKs, each as its own persistent Lepton instance with a Steam library shortcut (`--dev`: old ADB path into Lepton Development) (**verified**; see [docs/apks.md](apks.md)) |
|
||||
| `scripts/panel-on-frame.sh` | Mac → Frame | Start an app as its own floating VR panel, outside the desktop (**verified**: overlays created; in-headset placement not yet checked) |
|
||||
| `scripts/panel-on-frame.sh` | Mac → Frame | Start an app as its own floating VR panel, outside the desktop (**verified**, including `mac-screen` in the headset) |
|
||||
| `scripts/mac-cursor-ring.lua` | Mac | Hammerspoon script: a ring around the Mac pointer so it shows in the VNC mirror (**verified**) |
|
||||
| `scripts/run-on-frame.sh` | Mac → Frame | Start an app on the headset desktop, e.g. `mac-screen` opens Remmina straight into the Mac (**verified**) |
|
||||
| `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) |
|
||||
| `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) |
|
||||
|
||||
@@ -21,7 +21,8 @@ desktop app, which knows where a dropped folder lives; in a plain browser, zip
|
||||
it.) A dialog shows:
|
||||
|
||||
- **Name**: what Steam shows. Steam uses the title id as the name, so it's
|
||||
limited to letters, digits, `_` and `-`; the dialog shows the result.
|
||||
limited to letters, digits and `_`, and can't start with a digit; the
|
||||
dialog shows the result.
|
||||
- **Launches**: the program picked to start the game, with the other
|
||||
candidates in the list.
|
||||
- **Runtime**: picked from the program, see below. Windows programs can switch
|
||||
@@ -133,10 +134,15 @@ splits that string is **not checked**.
|
||||
with the same rule, because `scp -r` would follow a link out of the folder
|
||||
and upload whatever it points at.
|
||||
- Installs run one at a time, and Remove is refused while one runs.
|
||||
- The title id is limited to `[A-Za-z0-9_-]`, at most 64 characters. Valve's
|
||||
scripts pass it to a shell (`steamos-delete` runs `rm -r` on it). Valve's
|
||||
- The title id is limited to letters, digits and `_`, doesn't start with a
|
||||
digit (one that would gets `_` in front), and is 2 to 64 characters. That's
|
||||
what Steam's `create-shortcut` accepts: on the Frame it refused
|
||||
`fc-smoke-exe` with `missing/invalid arguments` and registered the same
|
||||
program as `FCSmokeProbe` (2026-09-27, BUILD_ID 20260922.6101926), and
|
||||
Valve's client only allows `^[A-Za-z_][A-Za-z0-9_.]+$`. Valve's scripts
|
||||
also pass the id to a shell (`steamos-delete` runs `rm -r` on it). Valve's
|
||||
reserved sideload names (`steam`, `steamvr`, and their `deckard` forms,
|
||||
which would replace the Steam client itself) get `-game` added.
|
||||
which would replace the Steam client itself) get `_game` added.
|
||||
- Nothing needs `sudo`; everything goes to your home folder on the Frame.
|
||||
- In the app, a dropped folder is read from its local path by the app's own
|
||||
server, which only accepts requests from its own page (see
|
||||
|
||||
@@ -102,6 +102,18 @@ started. `curl http://<frame-ip>:32000/properties.json` shows whether the servic
|
||||
`~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS
|
||||
updates (inferred from Deck; the Frame uses the same A/B image scheme).
|
||||
|
||||
## From an iPhone or iPad
|
||||
|
||||
The iPhone app ([iphone.md](iphone.md)) makes its own ed25519 key and adds it
|
||||
with the Developer Mode password, once, over a password login; the Frame's sshd
|
||||
offers `publickey,password` (OpenSSH 9.7p1, keyboard-interactive off). It can't
|
||||
use the devkit pairing above: that installs an RSA key, and the Swift SSH
|
||||
library signs RSA only with SHA-1, which OpenSSH 8.8 and later refuse by default.
|
||||
The app pins the Frame's host key on first use and asks you to pair again if it
|
||||
changes. **Verified 2026-09-27** against the Frame's recovery image
|
||||
([recovery-and-images.md](recovery-and-images.md)); on the headset, the add-the-key-yourself
|
||||
route was used.
|
||||
|
||||
## Keeping `sshd` enabled across updates
|
||||
|
||||
- **Frame**: SSH is tied to the Developer Mode toggle, so it should survive
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Screen and desktop streaming
|
||||
|
||||
This covers two directions:
|
||||
This covers three directions, plus input:
|
||||
|
||||
- **A. Frame → Mac**: see and control the headset from the Mac.
|
||||
- **B. Mac → Frame**: use the Mac's desktop inside the headset.
|
||||
- **C. iPhone → Frame**: mirror the phone inside the headset.
|
||||
- **Input**: type and point in the Frame from the Mac or iPhone.
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
@@ -32,7 +34,7 @@ flat 2D desktop streaming into a window on the Frame's Linux desktop.
|
||||
|
||||
| Option | Setup | Confidence | Verdict |
|
||||
|---|---|---|---|
|
||||
| **macOS Screen Sharing (VNC) → Remmina on the Frame** | **Mac:** System Settings → General → Sharing → Screen Sharing on → (i) → enable "VNC viewers may control screen with password". **Frame:** `./scripts/install-apps.sh remmina` from the Mac, then open Remmina in the headset and connect to `vnc://<mac>.local` | **Inferred.** Remmina is on Flathub for **aarch64** with VNC and RDP ([Flathub](https://flathub.org/apps/org.remmina.Remmina)). The Frame desktop runs Flatpaks ([UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/)). macOS VNC is built in. | **Recommended.** Nothing to install on the Mac, and it's easy to set up. Latency is fine for productivity but not for games. You'll type the Mac's hostname once in Remmina on the headset, then save the profile. To avoid even that, the script can pre-seed a Remmina profile over SSH (see below). |
|
||||
| **macOS Screen Sharing (VNC) → Remmina on the Frame** | **Mac:** System Settings → General → Sharing → Screen Sharing on → (i) → enable "VNC viewers may control screen with password". **Frame:** `./scripts/install-apps.sh remmina` from the Mac, then open Remmina in the headset and connect to `vnc://<mac>.local` | **Verified 2026-09-27** (Frame BUILD_ID 20260925.6191901, macOS 27.0), in its own panel via `panel-on-frame.sh mac-screen`. Remmina is on Flathub for **aarch64** with VNC and RDP ([Flathub](https://flathub.org/apps/org.remmina.Remmina)). The Frame desktop runs Flatpaks ([UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/)). macOS VNC is built in. | **Recommended.** Nothing to install on the Mac, and it's easy to set up. Noticeable lag, even at lower Remmina quality settings on a good 5 GHz link, where neither Wi-Fi nor the Frame's CPU was the bottleneck. Usable for reading and coding, but not for games. You'll type the Mac's hostname once in Remmina on the headset, then save the profile. To avoid even that, the script can pre-seed a Remmina profile over SSH (see below). |
|
||||
| Sunshine (Mac) → Moonlight (Frame Flatpak) | `brew install` Sunshine on the Mac, then `./scripts/install-apps.sh moonlight` | Moonlight Flatpak supports **aarch64** ([Flathub](https://flathub.org/apps/com.moonlight_stream.Moonlight)). **Sunshine on macOS is poorly supported**: install problems on Apple Silicon/Sequoia, and no virtual gamepads ([LizardByte discussion #777](https://github.com/orgs/LizardByte/discussions/777)). | Try it if VNC is too laggy. Expect some friction. |
|
||||
| Steam Remote Play with the Mac as host | Steam on the Mac, Steam Link/Remote Play on the Frame | macOS-hosted Remote Play is reported broken or flaky in 2024–2026 ([Steam discussion](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/)) | Not recommended. It's only for games, if it works at all. |
|
||||
| Immersed / Virtual Desktop | Vendor apps | Immersed has a Mac agent but no known Frame client. Virtual Desktop's developer said he'd "try" to port it ([NewsBreak](https://www.newsbreak.com/news/4892834783961-virtual-desktop-dev-says-he-ll-try-to-bring-the-app-to-steam-frame)). | Not available as of 2026-09-25. Check again later. |
|
||||
@@ -45,20 +47,70 @@ them on the Frame in DeoVR instead: see [vr-video.md](vr-video.md).
|
||||
|
||||
`scripts/install-apps.sh remmina --vnc-host <your-mac>.local` writes
|
||||
`~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina` on the Frame over
|
||||
SSH. The profile then appears in Remmina's list, and you just click it. You'll
|
||||
still be asked for the VNC password in the headset the first time, unless you
|
||||
choose to save it. Remmina stores passwords encrypted with a per-install key,
|
||||
so the script doesn't try to write the password. (The Remmina file format is
|
||||
standard; the Flatpak data path is inferred.)
|
||||
SSH. The profile then appears in Remmina's list, and you just click it. It
|
||||
scales the Mac's desktop to fit the window (`scale=1`, `viewmode=1`). Without
|
||||
that, Remmina shows a Retina Mac's native pixels 1:1, so you see a zoomed-in
|
||||
corner. (Verified 2026-09-27.)
|
||||
|
||||
## Input and text entry without the virtual keyboard
|
||||
**Expect a Mac login prompt, not the VNC password.** macOS offers Apple's own
|
||||
authentication (RFB security type 30) ahead of plain VNC auth (type 2), and
|
||||
Remmina picks it. So Remmina asks for your **Mac account name and login
|
||||
password**; the "VNC viewers may control screen" password isn't used. To store
|
||||
the password without typing it in the headset, run on the Frame:
|
||||
|
||||
- **A Bluetooth keyboard and mouse** paired to the Frame is the obvious way to
|
||||
avoid the virtual keyboard. Road to VR says there are "only a few things
|
||||
you'd actually want to do" on the Linux desktop unless you connect a
|
||||
keyboard and mouse.
|
||||
(Pairing a BT keyboard on the Frame is inferred from SteamOS; not verified.)
|
||||
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh` (see
|
||||
[file-transfer.md](file-transfer.md#clipboard)).
|
||||
```sh
|
||||
printf '%s' "$PASSWORD" | flatpak run org.remmina.Remmina \
|
||||
--update-profile ~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina \
|
||||
--set-option password
|
||||
```
|
||||
|
||||
Remmina encrypts it into the profile with its own key, because there's no
|
||||
secret service in the SSH session. (Verified 2026-09-27.)
|
||||
|
||||
### The Mac's cursor
|
||||
|
||||
The mirror doesn't show the Mac's pointer, with either `showcursor` value.
|
||||
macOS keeps the pointer out of the picture it sends, and Remmina's cursor mode
|
||||
draws the cursor shape only at the Frame's own pointer, which doesn't follow
|
||||
the Mac trackpad. `scripts/mac-cursor-ring.lua` works around this: a
|
||||
[Hammerspoon](https://www.hammerspoon.org/) script that draws a ring around the
|
||||
Mac pointer as a real window, so it's part of the mirrored picture. Setup is in
|
||||
its header. (Verified 2026-09-27.)
|
||||
|
||||
Going the other way, pointing a controller at the panel moves the Mac's mouse,
|
||||
because Remmina forwards input (`viewonly=0`).
|
||||
|
||||
## C. Show the iPhone's screen inside the Frame
|
||||
|
||||
iOS only shares its screen two ways: **AirPlay** (Screen Mirroring in Control
|
||||
Centre) or a **ReplayKit broadcast extension** in an app. Nothing else can
|
||||
capture it.
|
||||
|
||||
| Option | What it takes | Confidence | Verdict |
|
||||
|---|---|---|---|
|
||||
| **UxPlay** (an open-source AirPlay receiver) on the Frame | Build it for aarch64 (no Flathub package; there's a Snap and distro packages), run it in `~` or a podman container, and advertise it over mDNS. The iPhone *and* the Mac then see "Frame" in Screen Mirroring, with nothing to install on either | **Inferred.** It runs on ARM64 Linux such as the Raspberry Pi ([UxPlay](https://github.com/FDH2/UxPlay)). Not tried on the Frame: needs mDNS registration and its ports (7000, 7001, 7100 and a UDP range) reachable | **Recommended to try first.** It's the only receiver-side option, and it covers the Mac too. The window shows in the Frame's Linux desktop panel |
|
||||
| A broadcast extension in Frame Control | ReplayKit sends the screen to a small extension (50 MB memory limit), which encodes H.264 and sends it through the app's SSH tunnel to the page, shown the same way as the Frame's live view in reverse | **Inferred** from Apple's ReplayKit docs | Full control and no network setup, but several days' work, and the picture only shows where Frame Control's page is open in the headset |
|
||||
|
||||
## Input: type and point in the Frame from the Mac or iPhone
|
||||
|
||||
**Verified 2026-09-27** on the headset: `steamos` is in the `input` group and
|
||||
`/dev/uinput` is `crw-rw-r-- root input`, so **our own code can create a
|
||||
virtual keyboard and mouse without sudo**. The Frame has no `python-evdev`,
|
||||
`ydotool`, `wtype` or KDE Connect; `kwin_wayland` and `plasmashell` run only
|
||||
while the desktop panel is open in the headset.
|
||||
|
||||
| Option | Mac | iPhone | Notes |
|
||||
|---|---|---|---|
|
||||
| **A uinput keyboard and mouse in Frame Control's server** | ✓ | ✓ | **Recommended.** The server opens `/dev/uinput` with `ctypes` (standard library only) and the page sends key and pointer events through the tunnel it already has. On the phone: a trackpad area (drag to move, tap to click, two fingers to scroll) and the iOS keyboard for typing. On the Mac: a "control the Frame" mode that captures the keyboard and pointer (Esc to release). Uinput devices look like real hardware to the kernel, so libinput, KWin and gamescope should take them; [frame-voice](https://github.com/DeeJanuz/frame-voice) already types into a Frame through a uinput keyboard. **Untested**: which surfaces in VR (desktop panel, SteamVR dashboard, games, Android apps in Lepton) accept the pointer. About a day or two of work |
|
||||
| **Bluetooth keyboard and mouse** | – | – | Real hardware paired in SteamOS settings. The iPhone can't pretend to be a Bluetooth keyboard: iOS won't advertise the HID service ([Apple forums](https://developer.apple.com/forums/thread/733916)) |
|
||||
| **Deskflow** (formerly Input Leap / Barrier) | ✓ | – | Moves the Mac's own mouse and keyboard onto the Frame's screen edge. Flathub has an aarch64 build ([Flathub](https://flathub.org/apps/org.deskflow.deskflow)); on Wayland it needs the InputCapture/libei portal, and only works while Plasma is running. No iPhone client |
|
||||
| **KDE Connect** | ~ | ✓ | Its iOS app has a remote touchpad and keyboard, but the Frame would need KDE Connect installed (not on Flathub; `pacman` on a read-only root). More moving parts than the uinput route |
|
||||
| **Remmina / Steam Link / RDP** | ✓ | – | Input only reaches the streamed session, not the headset's own apps |
|
||||
|
||||
Other ways to get text in:
|
||||
|
||||
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh`, or Frame Control's
|
||||
clipboard box (see [file-transfer.md](file-transfer.md#clipboard)). Needs
|
||||
the desktop panel open.
|
||||
- **RDP session**: Windows App syncs the clipboard with xrdp, but only inside
|
||||
that RDP session.
|
||||
@@ -0,0 +1,190 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frame Control 0.3.1: features by OS</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1b2838; --panel: #16202d; --line: #2a3f5a; --text: #c7d5e0; --dim: #8f98a0;
|
||||
--tested: #5ba32b; --partial: #d9a33a; --auto: #4b8bbe; --built: #3d4f63; --no: #6b2b2b;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: linear-gradient(#171a21, var(--bg) 320px); color: var(--text);
|
||||
font: 15px/1.5 "Motiva Sans", -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
main { max-width: 1180px; margin: 0 auto; padding: 40px 24px 80px; }
|
||||
h1 { color: #fff; font-size: 30px; margin: 0 0 4px; font-weight: 600; }
|
||||
h2 { color: #fff; font-size: 18px; margin: 40px 0 12px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: .06em; }
|
||||
.sub { color: var(--dim); margin: 0 0 28px; }
|
||||
a { color: #66c0f4; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 16px 18px; }
|
||||
.card h3 { margin: 0 0 8px; color: #fff; font-size: 16px; }
|
||||
.card dl { margin: 0; display: grid; grid-template-columns: 76px 1fr; gap: 3px 10px; font-size: 13.5px; }
|
||||
.card dt { color: var(--dim); }
|
||||
.card dd { margin: 0; }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 10px 20px; margin: 0 0 14px; font-size: 13.5px; }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 7px; }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--panel);
|
||||
border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
||||
th, td { padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; }
|
||||
thead th { background: #0e141b; color: #fff; font-weight: 600; position: sticky; top: 0; z-index: 1; }
|
||||
thead th.os { width: 150px; text-align: center; }
|
||||
tr.group td { background: #203044; color: #fff; font-weight: 600; font-size: 13px;
|
||||
text-transform: uppercase; letter-spacing: .05em; }
|
||||
td.os { text-align: center; }
|
||||
td .feat { color: #fff; }
|
||||
td .note { color: var(--dim); font-size: 13px; }
|
||||
.pill { display: inline-block; min-width: 92px; padding: 2px 9px; border-radius: 999px;
|
||||
font-size: 12.5px; font-weight: 600; color: #fff; white-space: nowrap; }
|
||||
.t { background: var(--tested); }
|
||||
.p { background: var(--partial); color: #1b1b1b; }
|
||||
.a { background: var(--auto); }
|
||||
.b { background: var(--built); color: #c7d5e0; }
|
||||
.n { background: var(--no); }
|
||||
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
|
||||
ul { margin: 6px 0 0; padding-left: 20px; }
|
||||
li { margin: 3px 0; }
|
||||
footer { color: var(--dim); font-size: 13px; margin-top: 36px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frame Control 0.3.1: features by OS</h1>
|
||||
<p class="sub">Which features are built for each OS, and which were tested against a real Steam Frame
|
||||
(SteamOS 0.3.0, build 20260922.6101926). Status as of 26 September 2026, for
|
||||
<a href="https://github.com/saphid/steam-frame/pull/2">PR #2</a> (0.3.1). Every build now bundles its own
|
||||
Python 3.12, <code>adb</code> and CA certificates, so nothing else needs installing (only <code>ssh</code> on Linux,
|
||||
plus the system <code>adb</code> on arm64 Linux).</p>
|
||||
|
||||
<h2>Builds and test machines</h2>
|
||||
<div class="cards">
|
||||
<div class="card"><h3>macOS</h3><dl>
|
||||
<dt>Built</dt><dd>Apple Silicon (arm64): <code>.dmg</code>, <code>.zip</code>. No Intel build.</dd>
|
||||
<dt>Signing</dt><dd>Ad-hoc signed, not notarised</dd>
|
||||
<dt>Tested on</dt><dd>Apple Silicon Mac, macOS 26, using a local 0.3.1 build with the bundled Python and <code>adb</code>. All 15 calls it made to the Frame at startup returned OK.</dd>
|
||||
</dl></div>
|
||||
<div class="card"><h3>Windows</h3><dl>
|
||||
<dt>Built</dt><dd>x64: NSIS installer <code>.exe</code> and <code>.zip</code></dd>
|
||||
<dt>Signing</dt><dd>Unsigned. SmartScreen shows a warning.</dd>
|
||||
<dt>Tested on</dt><dd>Windows 11 x64 VM. Real-Frame results below are from 0.3.0. The 0.3.1 installer from CI installs cleanly (31 s) and reinstalls over itself (38 s). The server starts on the bundled Python, and HTTPS to Steam and F-Droid works. The Frame went offline before its 0.3.1 run on the headset.</dd>
|
||||
</dl></div>
|
||||
<div class="card"><h3>Linux</h3><dl>
|
||||
<dt>Built</dt><dd>x86_64 and arm64: <code>AppImage</code> and <code>.deb</code></dd>
|
||||
<dt>Signing</dt><dd>n/a</dd>
|
||||
<dt>Tested on</dt><dd>x86_64 Ubuntu 26.04 with no <code>adb</code> and no clipboard tools, using the 0.3.1 AppImage under Xvfb with the bundled Python and <code>adb</code>. The arm64 builds and the <code>.deb</code> packages weren't run; the arm64 package was only checked to contain an ARM Python.</dd>
|
||||
</dl></div>
|
||||
</div>
|
||||
|
||||
<h2>Features</h2>
|
||||
<div class="legend">
|
||||
<span><i class="dot" style="background:var(--tested)"></i><b>Tested</b>: worked against the real Frame on that OS</span>
|
||||
<span><i class="dot" style="background:var(--partial)"></i><b>Partial</b>: only part of the feature was tested (see note)</span>
|
||||
<span><i class="dot" style="background:var(--auto)"></i><b>Automated</b>: covered by CI tests on that OS, not tried on a real Frame</span>
|
||||
<span><i class="dot" style="background:var(--built)"></i><b>Built</b>: in the build, not tested</span>
|
||||
<span><i class="dot" style="background:var(--no)"></i><b>Not built</b></span>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Feature</th><th class="os">macOS</th><th class="os">Windows</th><th class="os">Linux</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="group"><td colspan="4">Connection</td></tr>
|
||||
<tr><td><div class="feat">Set Up Connection</div><div class="note">Finds the Frame, writes the <code>frame</code> SSH alias, copies your key using the Frame's password. macOS runs <code>connect.sh</code> in Terminal; Windows and Linux run <code>frame_connect.py</code>.</div></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Existing alias used, script not re-run</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Shared SSH connection</div><div class="note">A single SSH connection is reused, so each request takes about 0.3 s. Windows OpenSSH can't do this, so there each request opens its own connection (about 0.5 to 1 s).</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td>
|
||||
<td class="os"><span class="pill n">Not built</span><div class="note">OpenSSH limitation</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">Headset view</td></tr>
|
||||
<tr><td><div class="feat">Capture headset view</div><div class="note">The left eye or both eyes as the lenses show them, saved as PNG</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Capture desktop panel</div><div class="note">gamescope's flat layer</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Live view</div><div class="note">720p H.264 at about 30 fps, decoded with WebCodecs</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Headset screenshots</div><div class="note">Browse the screenshots you took with Steam's shortcut, and save them to <code>~/Pictures/SteamFrame</code></div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Listed (5 found); saving not tried</div></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Listed with thumbnails; saving not tried</div></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">Status</td></tr>
|
||||
<tr><td><div class="feat">Battery and charging</div><div class="note">Percentage, watts, time to full or empty, charger type, temperature</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">System status</div><div class="note">Storage, memory, temperature, Wi-Fi, uptime, running services</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Volume and mute</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Read only</div></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Read only</div></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">Games</td></tr>
|
||||
<tr><td><div class="feat">Owned games with Frame ratings</div><div class="note">Verified, Playable, Unsupported or Unknown</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Install a game on the Frame</div><div class="note">Uses the headset's Steam client, with live progress</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
<tr><td><div class="feat">Store search, Buy, Store on Frame</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
<tr><td><div class="feat">Library shelf and Play button</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">Android apps</td></tr>
|
||||
<tr><td><div class="feat">Installed Android apps list</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">F-Droid catalogue search</div><div class="note">About 4,500 apps with Frame ratings, bundled with the app</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Install, launch, stop, test, remove an app</div><div class="note">Each app runs as its own Lepton instance, using the bundled <code>adb</code>. APK files are read by a built-in parser (no <code>aapt2</code>) that matched <code>aapt2</code> on 9 F-Droid APKs.</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span><div class="note">Diary: read, install, launch, test, remove</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Launch and stop</div></td></tr>
|
||||
<tr><td><div class="feat">Report an APK</div><div class="note">Reports are saved on your computer; the shared database is maintainer-only</div></td>
|
||||
<td class="os"><span class="pill a">Automated</span></td><td class="os"><span class="pill a">Automated</span></td><td class="os"><span class="pill a">Automated</span></td></tr>
|
||||
<tr><td><div class="feat">Android display settings</div><div class="note">Resolution, UI scale, text size</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span><div class="note">Density and text size set, then reset</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Read over the bundled adb</div></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">Transfer</td></tr>
|
||||
<tr><td><div class="feat">Send files to ~/Downloads</div><div class="note">Test files had non-English characters in their names (é, ✓). macOS and Linux copy with rsync; Windows uses scp.</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Drop an APK to install it</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
<tr><td><div class="feat">Send text or clipboard to the Frame</div><div class="note">Needs the headset desktop open. The app reads your clipboard through Electron, so no extra tools are needed.</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span><div class="note">Reading the clipboard retested in 0.3.1</div></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Reached the Frame; desktop was closed</div></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Clipboard read with no xclip; Frame desktop was closed</div></td></tr>
|
||||
<tr><td><div class="feat">Flatpak install and remove</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">One-click tools</td></tr>
|
||||
<tr><td><div class="feat">SSH or SFTP in a terminal</div><div class="note">macOS: Terminal. Windows: cmd. Linux: GNOME Terminal, Konsole, xterm and others.</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
<tr><td><div class="feat">Steam Link</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
<tr><td><div class="feat">Remote desktop</div><div class="note">macOS: Windows App. Windows: Remote Desktop. Linux: Remmina or FreeRDP.</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
<tr><td><div class="feat">Sleep, restart, shut down</div><div class="note">Opens a terminal because SteamOS asks for the sudo password</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">App</td></tr>
|
||||
<tr><td><div class="feat">Local server test suite</div><div class="note">Runs in GitHub Actions on every push (Python 3.12 on macOS and Windows, Python 3.13 on Ubuntu), including the APK reader tests</div></td>
|
||||
<td class="os"><span class="pill a">Automated</span></td><td class="os"><span class="pill a">Automated</span></td><td class="os"><span class="pill a">Automated</span></td></tr>
|
||||
<tr><td><div class="feat">Mac or PC wording</div><div class="note">The UI says Finder or File Explorer, and Mac or PC, to match your system</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Notes</h2>
|
||||
<ul>
|
||||
<li><b>Tested</b> means the app, running on that OS, got a successful response from the real Frame: for example, a PNG from a capture, 868 owned games, or the Android apps listed.</li>
|
||||
<li>The Windows VM tests ran in its desktop session. <code>ssh.exe</code> hangs when it's started from a remote SSH session, but a normal desktop user won't hit that.</li>
|
||||
<li>The macOS test from 25 September also covered the capture shown when the headset is in standby, input validation, and using the clipboard with the headset desktop open.</li>
|
||||
<li>Everything marked <b>Built</b> runs a command that works on its own. It just hasn't been tried end to end from the app on that OS yet.</li>
|
||||
</ul>
|
||||
<footer>Frame Control is an unofficial tool, not made by Valve. MIT licence.</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,158 @@
|
||||
# Testing
|
||||
|
||||
Frame Control is tested in three layers, from fast and fake to slow and real.
|
||||
A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/steam-frame/issues/6)).
|
||||
|
||||
| Layer | Runs | Needs | Covers |
|
||||
|---|---|---|---|
|
||||
| Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
|
||||
| Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame |
|
||||
| Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID |
|
||||
|
||||
## Unit tests
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
|
||||
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
|
||||
|
||||
## The fake Frame
|
||||
|
||||
`tests/fakeframe/` builds a container that stands in for the headset, and a
|
||||
second one for the computer Frame Control runs on. `scripts/e2e.sh` builds
|
||||
both, starts them with `docker compose`, runs `tests/e2e` in the host
|
||||
container and takes everything down, exiting with the tests' status:
|
||||
|
||||
```sh
|
||||
scripts/e2e.sh # everything, about 2 minutes plus the first build
|
||||
scripts/e2e.sh test_titles # one module
|
||||
scripts/e2e.sh test_faults.Faults.test_disk_full # one test
|
||||
FAKEFRAME_KEEP=1 scripts/e2e.sh # leave it running afterwards
|
||||
```
|
||||
|
||||
It needs a Linux host with Docker and `docker compose`, and zsh. The images
|
||||
are `fakeframe-frame` and `fakeframe-host`; the compose project, network and
|
||||
volumes are `fakeframe-e2e*`. CI runs it on a native arm64 runner
|
||||
(`ubuntu-24.04-arm`, the `e2e` job in `.github/workflows/checks.yml`).
|
||||
|
||||
The host container exists because OpenSSH reads `~/.ssh/config` from the
|
||||
passwd home directory, not `$HOME`. There, `ssh frame` reaches the fake Frame
|
||||
through the same `Host frame` block `ui/frame_connect.py` writes, the
|
||||
repository is mounted read-only at `/repo`, and each test module starts the
|
||||
real `ui/server.py` (Python 3.9) and talks to it over HTTP with the headers
|
||||
its guards want.
|
||||
|
||||
### What's real and what's fake
|
||||
|
||||
| On the fake Frame | |
|
||||
|---|---|
|
||||
| Arch Linux (`archlinux:base`, or Valve's Holo Core aarch64 preview on arm64), user `steamos`, `/etc/os-release` with BUILD_ID 20260922.6101926 | Real OS, Frame's identity |
|
||||
| `sshd` with key and password logins, `rsync`, `python3` | Real |
|
||||
| Valve's steamos-devkit-service on port 32000 and its hooks, vendored unmodified in `tests/fakeframe/steamos-devkit-service` | Real; only its `dbus` import (for mDNS through systemd-resolved) is a stand-in that logs the registration |
|
||||
| Valve's devkit-utils, copied over by Frame Control itself | Real |
|
||||
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs | Fake |
|
||||
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF | The JS engine is real; the objects are fake |
|
||||
| `steam`, `wpctl`, `flatpak`, `podman`, `nmcli`, `qdbus6`, `gamescopectl`, SteamOS's `steamos-enable-sshd` helper, and Lepton's launcher | Stubs that record their calls |
|
||||
| Battery, charger and thermal zones under `/sys/class` | Files the supervisor writes. `/sys` is read-only in a container and Docker's AppArmor profile refuses writes under it, so each folder is a volume mounted twice: over `/sys/class/...` for `frame_status.py` to read, and under `/var/lib/fakeframe/sys` for the supervisor to write |
|
||||
| `vrserver` and `plasmashell` | Renamed `sleep` processes, so the status page and the clipboard find them |
|
||||
|
||||
Every fake behaviour copied from the device has a comment citing the doc or
|
||||
observation and the BUILD_ID it came from; anything not seen on a headset is
|
||||
marked as a guess. The fake keeps its state in `/var/lib/fakeframe/state.json`
|
||||
(shortcuts, devkit titles, compat tool mapping, launches, pairing requests,
|
||||
Lepton instances, volume, Flatpaks, clipboard) and logs stub calls to
|
||||
`calls.jsonl` beside it.
|
||||
|
||||
Native programs really run: a launched aarch64 title executes on an arm64
|
||||
host, and an x86-64 one on x86-64 (the container shares the host's kernel).
|
||||
Proton titles are recorded with the command Steam would run, not run.
|
||||
|
||||
### Fault switches
|
||||
|
||||
`fakeframe-ctl` works over SSH (`ssh frame fakeframe-ctl help`) and from the
|
||||
host container (`FAKEFRAME_CTL=http://fakeframe:9999`), so a test can flip a
|
||||
switch while SSH is down:
|
||||
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `pairing on\|off` | Steam's **Pair new host** screen open or not; off gives the device's 403 text |
|
||||
| `answer approve\|deny\|timeout` | How the pairing prompt is answered |
|
||||
| `steam on\|off` | Steam client running (pid file, pipe, DevTools) |
|
||||
| `sleep on\|off` | Headset asleep: ports 22 and 32000 accept and never answer, so SSH times out |
|
||||
| `sshd on\|off` | sshd stopped: new connections are refused, open ones stay |
|
||||
| `devkit-service on\|off` | Port 32000 closed |
|
||||
| `disk-full on\|off` | Fills the small (64 MB) filesystem on `~/devkit-game` |
|
||||
| `runtime NAME installed\|missing` | Proton, the Steam Linux Runtimes, Lepton |
|
||||
| `battery KEY=VALUE...` | e.g. `capacity=15 status=Discharging current_now=-900000` |
|
||||
| `keys harness\|none`, `authorized-keys` | Set or read `~/.ssh/authorized_keys` |
|
||||
| `reset`, `state`, `calls [TOOL]` | Start over; read the state and call log |
|
||||
|
||||
### What the fake can't show
|
||||
|
||||
- Rendering: the headset view, desktop capture content, live video, SteamVR,
|
||||
gamescope and panels. The capture stub returns a placeholder PNG.
|
||||
- Proton and FEX: whether a Windows or x86-64 program actually runs.
|
||||
- Android: there's no Android in the Lepton stand-in, so no ADB, display
|
||||
settings, probes or app crashes.
|
||||
- The real Steam client's UI and anything it does that isn't modelled, and
|
||||
mDNS discovery.
|
||||
- `sudo` and the power buttons, Tailscale, and the Windows and macOS sides of
|
||||
the app (the host container is Linux, so the `rsync` paths are tested and the
|
||||
`scp` fallback isn't).
|
||||
|
||||
## Headset smoke test
|
||||
|
||||
```sh
|
||||
scripts/frame-smoke.sh # needs `ssh frame` to work without a password
|
||||
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
|
||||
```
|
||||
|
||||
It checks `properties.json` and the status, then installs, launches and
|
||||
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
|
||||
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
|
||||
an x86-64 `.exe` that exits at once). A launch passes only with fresh evidence:
|
||||
the ARM64 program running, the `.exe` started (its process or Steam's log),
|
||||
and the x86-64 program running or Steam logging that its runtime isn't
|
||||
installed, which is what the Frame does today. Steam's log lines about each
|
||||
title are kept.
|
||||
|
||||
Everything it installs is removed again, also after a failure: the titles and
|
||||
their Steam shortcuts, a paired key, and `~/devkit-utils` if it wasn't there
|
||||
before (if it was, it stays, synced to this checkout as Frame Control always
|
||||
does). A cleanup that fails counts as a failed step. Results go to
|
||||
`tests/smoke/results/<time>-<BUILD_ID>.json` (not committed) with a summary on
|
||||
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
|
||||
isn't reachable.
|
||||
|
||||
`--pair` asks the devkit service to pair a new RSA key, which needs someone
|
||||
in the headset to open **Settings → Developer → Pair new host** and approve
|
||||
it; the key is checked and then taken out of `authorized_keys` again.
|
||||
|
||||
## When the device disagrees with the fake
|
||||
|
||||
The fake is only as good as what's been seen on a headset. When the smoke
|
||||
test (or anyone) finds the Frame doing something else:
|
||||
|
||||
1. Record what the device did, with the date and BUILD_ID, in the doc that
|
||||
covers it (`docs/sideloading.md`, `docs/ssh.md` and so on).
|
||||
2. Change the fake to match, with a comment citing that observation. The
|
||||
behaviours are in `tests/fakeframe/rootfs/usr/local/lib/fakeframe/`
|
||||
(`fakesteam.py` for Steam, `cef_shim.js` for DevTools, `init.py` for the
|
||||
switches, the stubs in `rootfs/usr/local/bin`).
|
||||
3. Run `scripts/e2e.sh`. If the app is wrong, the tests now fail the way the
|
||||
device did; fix the app and add a unit test.
|
||||
|
||||
For example, on 2026-09-27 the smoke test found that Steam's `create-shortcut`
|
||||
refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
|
||||
accepted. The fake now refuses them the same way, and Frame Control makes ids
|
||||
Steam accepts.
|
||||
|
||||
## Agent interfaces
|
||||
|
||||
`tests/test_agent.py` exercises MCP stdio, exact-action human approvals and the
|
||||
assistant against an in-process HTTP endpoint with canned responses (no keys or
|
||||
external calls). `tests/e2e/test_agents.py` runs the MCP/HTTP/SSH path against the
|
||||
fake Frame for approved installs, clipboard and file transfer. Headset Chromium
|
||||
rendering and real screenshots still need a device; see [agent evidence](agents.md#evidence-and-limits).
|
||||
@@ -0,0 +1,4 @@
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
build/
|
||||
DerivedData/
|
||||
@@ -0,0 +1,539 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */; };
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; };
|
||||
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; };
|
||||
41A697B9924018DA48F24A1F /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237D9AF04EEA257AB382F60E /* Keys.swift */; };
|
||||
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */; };
|
||||
476D8858DC2C9E6616B084BC /* PortForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */; };
|
||||
765661DBC0E6798A27CC60DB /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24E1BCD4F69A24C7DEF02F /* RootView.swift */; };
|
||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; };
|
||||
84423CB45629465420180A64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */; };
|
||||
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
|
||||
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
|
||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
|
||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; };
|
||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
E1823E86AC0698172B566DB0 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 72E728699F904E68DEC369D3 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 1015B8BE90EB02C2062752A1;
|
||||
remoteInfo = FrameControl;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
|
||||
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
|
||||
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameFinder.swift; sourceTree = "<group>"; };
|
||||
6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FrameControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||
8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlApp.swift; sourceTree = "<group>"; };
|
||||
9B24E1BCD4F69A24C7DEF02F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||
A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortForwarder.swift; sourceTree = "<group>"; };
|
||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = "<group>"; };
|
||||
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = "<group>"; };
|
||||
DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
|
||||
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = "<group>"; };
|
||||
F3E2F5607DD877272483D64E /* FrameControl.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FrameControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
35C098707058D19A2E23092E /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1518C8775325C731AD7E2421 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B4F84A5777E9EFEAEB54DB91 /* FrameControl */,
|
||||
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */,
|
||||
59B34B34E2BE8BCD237BCF26 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5064A5B6FE5B17B18E5FA4B8 /* SSH */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */,
|
||||
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */,
|
||||
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */,
|
||||
237D9AF04EEA257AB382F60E /* Keys.swift */,
|
||||
A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */,
|
||||
);
|
||||
path = SSH;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
59B34B34E2BE8BCD237BCF26 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F3E2F5607DD877272483D64E /* FrameControl.app */,
|
||||
6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
68AF00C8593B71502E1FB72B /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8A11F3431826A26B247C0695 /* AppModel.swift */,
|
||||
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
|
||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
||||
);
|
||||
path = FrameControlTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A939D1267299AE8A48092557 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9B24E1BCD4F69A24C7DEF02F /* RootView.swift */,
|
||||
DB544223FC60A59CC3E8EF5F /* SetupView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4F84A5777E9EFEAEB54DB91 /* FrameControl */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */,
|
||||
6B5B6718C5EA77FA67F6B14C /* Info.plist */,
|
||||
68AF00C8593B71502E1FB72B /* App */,
|
||||
5064A5B6FE5B17B18E5FA4B8 /* SSH */,
|
||||
A939D1267299AE8A48092557 /* Views */,
|
||||
CC25EAB6C385A68D63F7DDF7 /* Web */,
|
||||
);
|
||||
path = FrameControl;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CC25EAB6C385A68D63F7DDF7 /* Web */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */,
|
||||
);
|
||||
path = Web;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
1015B8BE90EB02C2062752A1 /* FrameControl */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = F08406CA3118DCFFD91EEA4D /* Build configuration list for PBXNativeTarget "FrameControl" */;
|
||||
buildPhases = (
|
||||
81ACE79C878CE95DC2C74A8B /* Pack the Frame bundle */,
|
||||
D452F3AE39D323226E2E4D1D /* Sources */,
|
||||
B6E396EEA6D8BB0EE84E01A4 /* Resources */,
|
||||
35C098707058D19A2E23092E /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = FrameControl;
|
||||
packageProductDependencies = (
|
||||
6BA549B6CC0A0CB847126456 /* Citadel */,
|
||||
);
|
||||
productName = FrameControl;
|
||||
productReference = F3E2F5607DD877272483D64E /* FrameControl.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
88565F33E966FD0BAC7AC9C8 /* FrameControlTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 3EE44365AF181B5C85B38B07 /* Build configuration list for PBXNativeTarget "FrameControlTests" */;
|
||||
buildPhases = (
|
||||
F220B2041FE675A075E860BB /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
B88C5AA6F25947DCEE51C178 /* PBXTargetDependency */,
|
||||
);
|
||||
name = FrameControlTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = FrameControlTests;
|
||||
productReference = 6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
72E728699F904E68DEC369D3 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1430;
|
||||
TargetAttributes = {
|
||||
};
|
||||
};
|
||||
buildConfigurationList = D6217CB1638429ED91524BB3 /* Build configuration list for PBXProject "FrameControl" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = 1518C8775325C731AD7E2421;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
AD49230A09C7F457BC247E4D /* XCRemoteSwiftPackageReference "Citadel" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 59B34B34E2BE8BCD237BCF26 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
1015B8BE90EB02C2062752A1 /* FrameControl */,
|
||||
88565F33E966FD0BAC7AC9C8 /* FrameControlTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
B6E396EEA6D8BB0EE84E01A4 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
84423CB45629465420180A64 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
81ACE79C878CE95DC2C74A8B /* Pack the Frame bundle */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Pack the Frame bundle";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "mkdir -p \"${DERIVED_FILE_DIR}\"\npython3 \"${SRCROOT}/scripts/make_frame_bundle.py\" \"${DERIVED_FILE_DIR}/frame-bundle.tar.gz\" > \"${DERIVED_FILE_DIR}/frame-bundle.version\"\nmkdir -p \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\ncp \"${DERIVED_FILE_DIR}/frame-bundle.tar.gz\" \"${DERIVED_FILE_DIR}/frame-bundle.version\" \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
D452F3AE39D323226E2E4D1D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
|
||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
|
||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
|
||||
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */,
|
||||
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */,
|
||||
41A697B9924018DA48F24A1F /* Keys.swift in Sources */,
|
||||
476D8858DC2C9E6616B084BC /* PortForwarder.swift in Sources */,
|
||||
765661DBC0E6798A27CC60DB /* RootView.swift in Sources */,
|
||||
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */,
|
||||
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F220B2041FE675A075E860BB /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
B88C5AA6F25947DCEE51C178 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 1015B8BE90EB02C2062752A1 /* FrameControl */;
|
||||
targetProxy = E1823E86AC0698172B566DB0 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
0B43879551190738EFF21848 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = FrameControl/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.saphid.framecontrol;
|
||||
PRODUCT_NAME = "Frame Control";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
3228B6B229BF6430C8338B55 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
54BEF779B5906F671E4134CE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
57A1F4BD520A2EDA424181E8 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.saphid.FrameControlTests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Frame Control.app/Frame Control";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
6E69BB8A560DC32B8D0E10A6 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.saphid.FrameControlTests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Frame Control.app/Frame Control";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
C7FCE7EB18B4AEF8EFEC8FDE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = FrameControl/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.saphid.framecontrol;
|
||||
PRODUCT_NAME = "Frame Control";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
3EE44365AF181B5C85B38B07 /* Build configuration list for PBXNativeTarget "FrameControlTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
6E69BB8A560DC32B8D0E10A6 /* Debug */,
|
||||
57A1F4BD520A2EDA424181E8 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
D6217CB1638429ED91524BB3 /* Build configuration list for PBXProject "FrameControl" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
54BEF779B5906F671E4134CE /* Debug */,
|
||||
3228B6B229BF6430C8338B55 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
F08406CA3118DCFFD91EEA4D /* Build configuration list for PBXNativeTarget "FrameControl" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
C7FCE7EB18B4AEF8EFEC8FDE /* Debug */,
|
||||
0B43879551190738EFF21848 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
AD49230A09C7F457BC247E4D /* XCRemoteSwiftPackageReference "Citadel" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/orlandos-nl/Citadel.git";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 0.12.1;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
6BA549B6CC0A0CB847126456 /* Citadel */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = AD49230A09C7F457BC247E4D /* XCRemoteSwiftPackageReference "Citadel" */;
|
||||
productName = Citadel;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 72E728699F904E68DEC369D3 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"originHash" : "06e1233a9a9b220c5f5b14eefc3220aa9e394ac504fece9df28b2a550b7d6017",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "bigint",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/attaswift/BigInt.git",
|
||||
"state" : {
|
||||
"revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe",
|
||||
"version" : "5.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "citadel",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/orlandos-nl/Citadel.git",
|
||||
"state" : {
|
||||
"revision" : "ae8562f895de06ccb86fdb1cbb65fd99c8976e12",
|
||||
"version" : "0.12.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2",
|
||||
"version" : "1.7.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
|
||||
"version" : "1.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "98ef3c98609a1e31b7e157b5b619579001a789d6",
|
||||
"version" : "1.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
|
||||
"version" : "3.15.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb",
|
||||
"version" : "1.15.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d",
|
||||
"version" : "2.103.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-ssh",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Wellz26/swift-nio-ssh.git",
|
||||
"state" : {
|
||||
"revision" : "d88989f3d3bb1dfb2a38ce4af598afbf7fc3095c",
|
||||
"version" : "0.3.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750",
|
||||
"version" : "1.8.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
|
||||
BuildableName = "FrameControl.app"
|
||||
BlueprintName = "FrameControl"
|
||||
ReferencedContainer = "container:FrameControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "NO"
|
||||
buildForProfiling = "NO"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "88565F33E966FD0BAC7AC9C8"
|
||||
BuildableName = "FrameControlTests.xctest"
|
||||
BlueprintName = "FrameControlTests"
|
||||
ReferencedContainer = "container:FrameControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
|
||||
BuildableName = "FrameControl.app"
|
||||
BlueprintName = "FrameControl"
|
||||
ReferencedContainer = "container:FrameControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "88565F33E966FD0BAC7AC9C8"
|
||||
BuildableName = "FrameControlTests.xctest"
|
||||
BlueprintName = "FrameControlTests"
|
||||
ReferencedContainer = "container:FrameControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
|
||||
BuildableName = "FrameControl.app"
|
||||
BlueprintName = "FrameControl"
|
||||
ReferencedContainer = "container:FrameControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
|
||||
BuildableName = "FrameControl.app"
|
||||
BlueprintName = "FrameControl"
|
||||
ReferencedContainer = "container:FrameControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,291 @@
|
||||
import Citadel
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// The app's one piece of state: which headset, and how far along connecting to it is.
|
||||
@MainActor
|
||||
final class AppModel: ObservableObject {
|
||||
enum Phase: Equatable {
|
||||
case setup
|
||||
case connecting(String)
|
||||
case ready(URL)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@Published private(set) var phase: Phase
|
||||
@Published private(set) var settings: FrameSettings?
|
||||
/// Install links that arrived before the page was ready for them.
|
||||
@Published var pendingInstallLinks: [InstallLink] = []
|
||||
|
||||
private var link: FrameLink?
|
||||
private var server: HeadsetServer?
|
||||
private var forwarder: PortForwarder?
|
||||
private var attempt = 0
|
||||
|
||||
private static let settingsKey = "frame.settings"
|
||||
private static let hostKeyKey = "frame.hostKey"
|
||||
|
||||
init() {
|
||||
let saved = UserDefaults.standard.data(forKey: Self.settingsKey).flatMap { try? JSONDecoder().decode(FrameSettings.self, from: $0) }
|
||||
settings = saved
|
||||
phase = saved == nil ? .setup : .connecting("Connecting")
|
||||
}
|
||||
|
||||
var deviceName: String { UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone" }
|
||||
private var hostKey: String? { UserDefaults.standard.string(forKey: Self.hostKeyKey) }
|
||||
|
||||
// MARK: pairing
|
||||
|
||||
/// First time: log in with the Developer Mode password, add this phone's key to
|
||||
/// ~/.ssh/authorized_keys, record the Frame's host key, then connect with the key.
|
||||
func pair(host: String, user: String, password: String) async {
|
||||
guard let target = Self.parse(host: host, user: user) else {
|
||||
fail("Enter the headset's address and user name.", retry: false)
|
||||
return
|
||||
}
|
||||
invalidate()
|
||||
let mine = attempt
|
||||
await teardown()
|
||||
guard mine == attempt else { return }
|
||||
phase = .connecting("Signing in to \(target.host)")
|
||||
let pin = PinnedHostKey(expected: nil)
|
||||
do {
|
||||
let link = try await FrameLink.connect(target, auth: .passwordBased(username: target.user, password: password), hostKey: pin)
|
||||
defer { Task { await link.close() } }
|
||||
guard mine == attempt else { return }
|
||||
phase = .connecting("Adding this \(deviceName)'s key")
|
||||
let line = authorizedKeysLine
|
||||
// A file whose last line has no newline would otherwise swallow the key.
|
||||
let file = "~/.ssh/authorized_keys"
|
||||
try await link.check("umask 077; mkdir -p ~/.ssh && touch \(file) && "
|
||||
+ "{ grep -qxF \(shellQuote(line)) \(file) || { "
|
||||
+ "[ -s \(file) ] && [ -n \"$(tail -c 1 \(file))\" ] && printf '\\n' >> \(file); "
|
||||
+ "printf '%s\\n' \(shellQuote(line)) >> \(file); }; }",
|
||||
"Couldn't add the key on the Frame")
|
||||
guard mine == attempt else { return } // cancelled meanwhile: save nothing
|
||||
guard let seen = pin.seen else { throw FrameFailure("The Frame didn't show a host key") }
|
||||
UserDefaults.standard.set(seen, forKey: Self.hostKeyKey)
|
||||
UserDefaults.standard.set(try JSONEncoder().encode(target), forKey: Self.settingsKey)
|
||||
settings = target
|
||||
} catch {
|
||||
guard mine == attempt else { return }
|
||||
let failure = error as? FrameFailure
|
||||
fail(failure?.message ?? FrameLink.describe(error, host: target.host), retry: false,
|
||||
needsPairing: failure?.needsPairing ?? false)
|
||||
return
|
||||
}
|
||||
await connect()
|
||||
}
|
||||
|
||||
/// For someone who added this phone's key to the Frame themselves: no password.
|
||||
/// The Frame's host key is recorded on this first connection.
|
||||
func useKey(host: String, user: String) async {
|
||||
guard let target = Self.parse(host: host, user: user) else {
|
||||
fail("Enter the headset's address and user name.", retry: false)
|
||||
return
|
||||
}
|
||||
UserDefaults.standard.removeObject(forKey: Self.hostKeyKey)
|
||||
UserDefaults.standard.set(try? JSONEncoder().encode(target), forKey: Self.settingsKey)
|
||||
settings = target
|
||||
await connect()
|
||||
}
|
||||
|
||||
/// "host", "host:port" or "[v6]:port", plus a user name.
|
||||
nonisolated static func parse(host: String, user: String) -> FrameSettings? {
|
||||
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
|
||||
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
|
||||
let rest = target.host[target.host.index(after: close)...]
|
||||
if rest.hasPrefix(":"), let port = Int(rest.dropFirst()) { target.port = port }
|
||||
target.host = String(target.host[target.host.index(after: target.host.startIndex)..<close])
|
||||
} else if target.host.filter({ $0 == ":" }).count == 1, let colon = target.host.lastIndex(of: ":"),
|
||||
let port = Int(target.host[target.host.index(after: colon)...]) {
|
||||
target.port = port
|
||||
target.host = String(target.host[..<colon])
|
||||
}
|
||||
guard !target.host.isEmpty, !target.user.isEmpty, (1...65535).contains(target.port) else { return nil }
|
||||
return target
|
||||
}
|
||||
|
||||
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
|
||||
var authorizedKeysLine: String {
|
||||
DeviceKey.authorizedKeysLine(DeviceKey.loadOrCreate(), comment: "frame-control@\(deviceName)")
|
||||
}
|
||||
|
||||
/// Forget the headset: back to the pairing screen. The Frame keeps the key line;
|
||||
/// remove it from ~/.ssh/authorized_keys there to revoke this phone.
|
||||
func forget() async {
|
||||
invalidate()
|
||||
await teardown()
|
||||
UserDefaults.standard.removeObject(forKey: Self.settingsKey)
|
||||
UserDefaults.standard.removeObject(forKey: Self.hostKeyKey)
|
||||
settings = nil
|
||||
phase = .setup
|
||||
}
|
||||
|
||||
func showSetup() {
|
||||
invalidate()
|
||||
Task { await teardown() }
|
||||
phase = .setup
|
||||
}
|
||||
|
||||
/// Whether the failure screen is retrying on its own.
|
||||
@Published private(set) var retrying = false
|
||||
|
||||
// MARK: connecting
|
||||
|
||||
/// Every connection attempt has a number; anything that finishes after a newer
|
||||
/// attempt started (or the user went back to setup) closes what it made and stops.
|
||||
private func invalidate() {
|
||||
attempt += 1
|
||||
retrying = false
|
||||
}
|
||||
|
||||
/// quiet: a background retry, which leaves the failure screen up until it works.
|
||||
func connect(quiet: Bool = false) async {
|
||||
guard let settings else {
|
||||
phase = .setup
|
||||
return
|
||||
}
|
||||
invalidate()
|
||||
let mine = attempt
|
||||
await teardown()
|
||||
func current() -> Bool { mine == attempt }
|
||||
func step(_ s: String) { if current() && !quiet { phase = .connecting(s) } }
|
||||
step("Connecting to \(settings.host)")
|
||||
var link: FrameLink?
|
||||
var forwarder: PortForwarder?
|
||||
do {
|
||||
let bundle = try HeadsetServer.Bundle.fromApp()
|
||||
let auth = SSHAuthenticationMethod.ed25519(username: settings.user, privateKey: DeviceKey.loadOrCreate())
|
||||
let pin = PinnedHostKey(expected: hostKey)
|
||||
let l = try await FrameLink.connect(settings, auth: auth, hostKey: pin)
|
||||
link = l
|
||||
guard current() else { throw CancellationError() }
|
||||
if hostKey == nil, let seen = pin.seen { UserDefaults.standard.set(seen, forKey: Self.hostKeyKey) }
|
||||
let dir = try await HeadsetServer.deploy(bundle, over: l) { s in Task { @MainActor in step(s) } }
|
||||
guard current() else { throw CancellationError() }
|
||||
step("Starting Frame Control on the headset")
|
||||
let key = Self.randomKey()
|
||||
let server = try await HeadsetServer.start(in: dir, over: l, key: key, device: deviceName)
|
||||
guard current() else { throw CancellationError() }
|
||||
let f = try await PortForwarder.start(over: l, to: server.port)
|
||||
forwarder = f
|
||||
guard current() else { throw CancellationError() }
|
||||
if let tail = server.exited { // stopped while the tunnel was opening
|
||||
throw FrameFailure("Frame Control on the headset stopped. \(tail.suffix(200))")
|
||||
}
|
||||
// Only now does this attempt's connection become the app's.
|
||||
self.link = l
|
||||
self.server = server
|
||||
self.forwarder = f
|
||||
readySince = Date()
|
||||
var page = "http://127.0.0.1:\(f.localPort)/?key=\(key)"
|
||||
#if DEBUG
|
||||
// Test hooks for the Simulator: open on a given tab, and leave the URL where
|
||||
// a test can drive the same tunnel (`simctl get_app_container … data`).
|
||||
if let tab = ProcessInfo.processInfo.environment["FRAME_TEST_PAGE"] { page += "#\(tab)" }
|
||||
if let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
||||
try? page.write(to: dir.appendingPathComponent("frame-test-url.txt"), atomically: true, encoding: .utf8)
|
||||
}
|
||||
#endif
|
||||
phase = .ready(URL(string: page)!)
|
||||
// Runs at once if it stopped in the moment since the check above.
|
||||
server.whenExited { [weak self] tail in
|
||||
Task { @MainActor in self?.lost(mine, "Frame Control on the headset stopped. \(tail.suffix(200))") }
|
||||
}
|
||||
watchHealth(mine)
|
||||
} catch {
|
||||
forwarder?.stop()
|
||||
if let link { await link.close() } // ends its server too
|
||||
guard current(), !(error is CancellationError) else { return }
|
||||
let failure = error as? FrameFailure
|
||||
fail(failure?.message ?? FrameLink.describe(error, host: settings.host), retry: !(failure?.needsPairing ?? false),
|
||||
needsPairing: failure?.needsPairing ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the last failure needs the user to pair again rather than wait.
|
||||
@Published private(set) var needsPairing = false
|
||||
|
||||
private func fail(_ message: String, retry: Bool, needsPairing: Bool = false) {
|
||||
self.needsPairing = needsPairing
|
||||
phase = .failed(message)
|
||||
retrying = retry && settings != nil
|
||||
guard retrying else { return }
|
||||
// Keep trying quietly while the app is open: the Frame may just be asleep.
|
||||
// A new task each time, so retrying for hours doesn't nest awaits.
|
||||
let mine = attempt
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
guard let self, mine == self.attempt, case .failed = self.phase,
|
||||
UIApplication.shared.applicationState == .active else { return }
|
||||
await self.connect(quiet: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// While connected, check every 20 s that the SSH session still answers: a
|
||||
/// network change can leave it looking open while nothing gets through.
|
||||
private func watchHealth(_ mine: Int) {
|
||||
Task { [weak self] in
|
||||
while true {
|
||||
try? await Task.sleep(nanoseconds: 20_000_000_000)
|
||||
guard let self, mine == self.attempt, case .ready = self.phase else { return }
|
||||
if UIApplication.shared.applicationState != .active { continue }
|
||||
if await !(self.link?.answers() ?? false) {
|
||||
guard mine == self.attempt else { return }
|
||||
await self.connect(quiet: true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the app comes back to the foreground: iOS may have dropped the
|
||||
/// connection, or left it looking open, while it was in the background.
|
||||
func resume() {
|
||||
switch phase {
|
||||
case .ready:
|
||||
let mine = attempt
|
||||
Task {
|
||||
let ok = await link?.answers() ?? false
|
||||
if (!ok || server?.exited != nil), mine == attempt { await connect() }
|
||||
}
|
||||
case .failed:
|
||||
// A changed identity or a refused login needs the user, not another try.
|
||||
if settings != nil, !needsPairing { Task { await connect() } }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private var readySince = Date.distantPast
|
||||
|
||||
private func lost(_ which: Int, _ why: String) {
|
||||
guard which == attempt, case .ready = phase else { return }
|
||||
// Restart it once; if it dies again straight away, say so instead of looping.
|
||||
if Date().timeIntervalSince(readySince) < 20 {
|
||||
invalidate()
|
||||
Task { await teardown() }
|
||||
fail(why, retry: false)
|
||||
} else {
|
||||
Task { await connect() }
|
||||
}
|
||||
}
|
||||
|
||||
private func teardown() async {
|
||||
forwarder?.stop()
|
||||
forwarder = nil
|
||||
server = nil
|
||||
if let link {
|
||||
self.link = nil
|
||||
await link.close() // ends the server too: its stdin closes
|
||||
}
|
||||
}
|
||||
|
||||
private static func randomKey() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 24)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
return bytes.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct FrameControlApp: App {
|
||||
@StateObject private var model = AppModel()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(model: model)
|
||||
.task {
|
||||
#if DEBUG
|
||||
// Simulator testing without the pairing screen: print this device's key,
|
||||
// and connect to FRAME_TEST_HOST with it (`simctl launch` passes
|
||||
// SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST).
|
||||
print("FRAME_CONTROL_KEY: \(model.authorizedKeysLine)")
|
||||
// FRAME_TEST_LANDSCAPE=1 turns the app on its side, to check the safe areas there.
|
||||
if ProcessInfo.processInfo.environment["FRAME_TEST_LANDSCAPE"] != nil,
|
||||
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscapeRight))
|
||||
}
|
||||
// FRAME_TEST_PAIR="host|user|password" runs the real password pairing.
|
||||
if model.settings == nil, let pair = ProcessInfo.processInfo.environment["FRAME_TEST_PAIR"] {
|
||||
let f = pair.components(separatedBy: "|")
|
||||
if f.count == 3 { await model.pair(host: f[0], user: f[1], password: f[2]); return }
|
||||
}
|
||||
if model.settings == nil, let host = ProcessInfo.processInfo.environment["FRAME_TEST_HOST"] {
|
||||
await model.useKey(host: host, user: "steamos")
|
||||
return
|
||||
}
|
||||
#endif
|
||||
if model.settings != nil { await model.connect() }
|
||||
}
|
||||
.onOpenURL { url in
|
||||
// frame-control://install?… from a website (docs/web-install.md).
|
||||
guard let link = InstallLink(url.absoluteString), model.pendingInstallLinks.count < 5 else { return }
|
||||
model.pendingInstallLinks.append(link)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { model.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// frame-control://install?manifest=URL or ?url=URL (docs/web-install.md), the same
|
||||
/// first filter as app/install-link.js. The server on the Frame applies the full
|
||||
/// rules (HTTPS, no private addresses, redirects) before fetching anything.
|
||||
struct InstallLink: Equatable {
|
||||
enum Kind: String { case manifest, url }
|
||||
let kind: Kind
|
||||
let target: String
|
||||
|
||||
static let scheme = "frame-control"
|
||||
private static let maxLink = 4096
|
||||
private static let maxURL = 2048
|
||||
|
||||
init?(_ raw: String) {
|
||||
guard raw.count <= Self.maxLink, raw.lowercased().hasPrefix("\(Self.scheme):"),
|
||||
let link = URLComponents(string: raw), link.scheme?.lowercased() == Self.scheme,
|
||||
link.host?.lowercased() == "install", ["", "/"].contains(link.path) else { return nil }
|
||||
let items = link.queryItems ?? []
|
||||
guard items.count == 1, let item = items.first, let kind = Kind(rawValue: item.name),
|
||||
let target = item.value, !target.isEmpty, target.count <= Self.maxURL,
|
||||
let url = URLComponents(string: target), ["https", "http"].contains(url.scheme?.lowercased() ?? ""),
|
||||
url.host?.isEmpty == false, url.user == nil, url.password == nil else { return nil }
|
||||
self.kind = kind
|
||||
self.target = target
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0xFF", "green" : "0x9F", "red" : "0x1A" } }, "idiom" : "universal" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images" : [ { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 190 KiB |
@@ -0,0 +1 @@
|
||||
{ "images" : [ { "filename" : "icon.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } }
|
||||
|
After Width: | Height: | Size: 190 KiB |
@@ -0,0 +1 @@
|
||||
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Frame Control</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.saphid.framecontrol.install</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>frame-control</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>ssh</string>
|
||||
<string>sftp</string>
|
||||
<string>steamlink</string>
|
||||
<string>rdp</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_steamos-devkit._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Frame Control finds your Steam Frame on your network and connects to it.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Frame Control saves headset captures and screenshots to your photo library when you ask it to.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string></string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Dark</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Finds the Frame on the local network so nobody has to type its address.
|
||||
/// A Frame in Developer Mode advertises Valve's devkit service over Bonjour
|
||||
/// (`_steamos-devkit._tcp`); failing that, `fallback` (the saved address, or
|
||||
/// frame.local) is checked by opening its SSH port. Both repeat until stopped.
|
||||
@MainActor
|
||||
final class FrameFinder: ObservableObject {
|
||||
struct Found: Equatable {
|
||||
let host: String // what to connect to
|
||||
let name: String // what to call it
|
||||
}
|
||||
|
||||
@Published private(set) var found: Found?
|
||||
/// When the search began, to tell "still looking" from "can't find it".
|
||||
@Published private(set) var since = Date()
|
||||
|
||||
private var browser: NWBrowser?
|
||||
private var probeTask: Task<Void, Never>?
|
||||
private var fallback = "frame.local"
|
||||
|
||||
func start(fallback: String?) {
|
||||
stop()
|
||||
self.fallback = (fallback?.isEmpty == false ? fallback : nil) ?? "frame.local"
|
||||
found = nil
|
||||
since = Date()
|
||||
browse()
|
||||
probeTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
let host = self.fallback
|
||||
if self.found == nil, await Self.sshAnswers(host: host) {
|
||||
self.found = Found(host: host, name: host)
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
probeTask?.cancel()
|
||||
probeTask = nil
|
||||
}
|
||||
|
||||
private func browse() {
|
||||
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
for result in results {
|
||||
guard case let .service(name, _, _, _) = result.endpoint else { continue }
|
||||
Self.resolve(result.endpoint) { host in
|
||||
Task { @MainActor in
|
||||
guard let self, let host else { return }
|
||||
// A found device is used over the fallback probe.
|
||||
self.found = Found(host: host, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
browser.start(queue: .main)
|
||||
self.browser = browser
|
||||
}
|
||||
|
||||
/// The device's IP address: connect to the service and read where it went.
|
||||
nonisolated private static func resolve(_ endpoint: NWEndpoint, done: @escaping @Sendable (String?) -> Void) {
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let once = Once()
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
var host: String?
|
||||
if case let .hostPort(h, _)? = connection.currentPath?.remoteEndpoint {
|
||||
host = "\(h)".components(separatedBy: "%").first // drop an IPv6 interface suffix
|
||||
}
|
||||
connection.cancel()
|
||||
if once.claim() { done(host) }
|
||||
case .failed, .cancelled:
|
||||
if once.claim() { done(nil) }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: .global())
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 5) {
|
||||
connection.cancel()
|
||||
if once.claim() { done(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether something answers on the SSH port of "host" or "host:port" within a few seconds.
|
||||
nonisolated static func sshAnswers(host address: String) async -> Bool {
|
||||
var host = address, port: UInt16 = 22
|
||||
if let target = AppModel.parse(host: address, user: "steamos") {
|
||||
host = target.host
|
||||
port = UInt16(target.port)
|
||||
}
|
||||
return await sshAnswers(host: host, port: port)
|
||||
}
|
||||
|
||||
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
|
||||
await withCheckedContinuation { (c: CheckedContinuation<Bool, Never>) in
|
||||
let connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port) ?? 22, using: .tcp)
|
||||
let once = Once()
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
connection.cancel()
|
||||
if once.claim() { c.resume(returning: true) }
|
||||
case .failed, .waiting:
|
||||
connection.cancel()
|
||||
if once.claim() { c.resume(returning: false) }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: .global())
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 3) {
|
||||
connection.cancel()
|
||||
if once.claim() { c.resume(returning: false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import Citadel
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOSSH
|
||||
|
||||
/// Where the Frame is and who to log in as.
|
||||
struct FrameSettings: Codable, Equatable {
|
||||
var host: String
|
||||
var port: Int = 22
|
||||
var user: String = "steamos"
|
||||
}
|
||||
|
||||
struct FrameFailure: LocalizedError {
|
||||
let message: String
|
||||
/// Retrying can't help: the Frame's identity changed, or it refused this phone's login.
|
||||
var needsPairing = false
|
||||
init(_ message: String, needsPairing: Bool = false) {
|
||||
self.message = message
|
||||
self.needsPairing = needsPairing
|
||||
}
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// Trust on first use: pairing records the Frame's host key; later connections
|
||||
/// accept that key and nothing else, as ssh's known_hosts does.
|
||||
final class PinnedHostKey: NIOSSHClientServerAuthenticationDelegate, @unchecked Sendable {
|
||||
struct Changed: Error {}
|
||||
let expected: String?
|
||||
private let lock = NSLock()
|
||||
private var _seen: String?
|
||||
var seen: String? { lock.withLock { _seen } }
|
||||
|
||||
init(expected: String?) { self.expected = expected }
|
||||
|
||||
func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise<Void>) {
|
||||
let key = String(openSSHPublicKey: hostKey)
|
||||
lock.withLock { _seen = key }
|
||||
if expected == nil || expected == key {
|
||||
validationCompletePromise.succeed(())
|
||||
} else {
|
||||
validationCompletePromise.fail(Changed())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One SSH connection to the Frame, and the few things the app does over it.
|
||||
final class FrameLink: @unchecked Sendable {
|
||||
let client: SSHClient
|
||||
|
||||
private init(client: SSHClient) { self.client = client }
|
||||
|
||||
static func connect(_ settings: FrameSettings, auth: SSHAuthenticationMethod, hostKey: PinnedHostKey) async throws -> FrameLink {
|
||||
do {
|
||||
let client = try await SSHClient.connect(
|
||||
host: settings.host, port: settings.port, authenticationMethod: auth,
|
||||
hostKeyValidator: .custom(hostKey), reconnect: .never, connectTimeout: .seconds(8))
|
||||
return FrameLink(client: client)
|
||||
} catch {
|
||||
let text = String(describing: error)
|
||||
throw FrameFailure(describe(error, host: settings.host),
|
||||
needsPairing: error is PinnedHostKey.Changed || text.contains("allAuthenticationOptionsFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The plain-language reason a connection failed, like the desktop server's messages.
|
||||
static func describe(_ error: Error, host: String) -> String {
|
||||
if error is PinnedHostKey.Changed {
|
||||
return "The Frame's SSH identity changed (after a reinstall, or a different device at \(host)). Pair again."
|
||||
}
|
||||
let text = String(describing: error)
|
||||
if text.contains("allAuthenticationOptionsFailed") || text.contains("authentication") {
|
||||
return "The Frame didn't accept the login. Pair again, and check the Developer Mode password."
|
||||
}
|
||||
if ["timeout", "Timeout", "timed out", "Host is down", "No route to host", "Network is unreachable",
|
||||
"errno: 64", "errno: 65", "errno: 51", "errno: 60"].contains(where: text.contains) {
|
||||
return "The Frame isn't answering at \(host). It may be asleep, switched off, or on another network."
|
||||
}
|
||||
if text.contains("refused") || text.contains("ECONNREFUSED") {
|
||||
return "The Frame refused the connection at \(host). Check Developer Mode is still on."
|
||||
}
|
||||
if text.contains("NXDOMAIN") || text.contains("resolve") || text.contains("unknownHost") || text.contains("NoAddress") {
|
||||
return "Can't find \(host) on the network. Check the address, and that the Frame is on the same network."
|
||||
}
|
||||
return "Couldn't connect to \(host): \(text)"
|
||||
}
|
||||
|
||||
var isConnected: Bool { client.isConnected }
|
||||
|
||||
/// Whether the Frame answers a trivial command within a few seconds. The probe
|
||||
/// runs unstructured: a dead link can keep it waiting well past the deadline,
|
||||
/// and the answer mustn't wait for it.
|
||||
func answers(within seconds: Double = 6) async -> Bool {
|
||||
guard client.isConnected else { return false }
|
||||
let once = Once()
|
||||
return await withCheckedContinuation { (c: CheckedContinuation<Bool, Never>) in
|
||||
Task { let ok = (try? await self.run("true").status) == 0; if once.claim() { c.resume(returning: ok) } }
|
||||
Task { try? await Task.sleep(nanoseconds: UInt64(seconds * 1e9)); if once.claim() { c.resume(returning: false) } }
|
||||
}
|
||||
}
|
||||
|
||||
func close() async {
|
||||
try? await client.close()
|
||||
}
|
||||
|
||||
/// Runs a shell command; returns its combined output and exit status.
|
||||
func run(_ command: String) async throws -> (output: String, status: Int) {
|
||||
// stderr joins stdout (Citadel treats any stderr as a failure), and the
|
||||
// status comes back as the last line so a non-zero exit isn't an exception.
|
||||
let buffer = try await client.executeCommand("{ \(command)\n} 2>&1; echo \"@@rc=$?\"")
|
||||
var text = String(buffer: buffer)
|
||||
var status = 0
|
||||
if let range = text.range(of: "@@rc=", options: .backwards) {
|
||||
status = Int(text[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines)) ?? -1
|
||||
text = String(text[..<range.lowerBound])
|
||||
}
|
||||
return (text.trimmingCharacters(in: .whitespacesAndNewlines), status)
|
||||
}
|
||||
|
||||
/// Runs a command that must succeed; its output, or a FrameFailure with it.
|
||||
@discardableResult
|
||||
func check(_ command: String, _ what: String) async throws -> String {
|
||||
let r = try await run(command)
|
||||
guard r.status == 0 else { throw FrameFailure("\(what): \(r.output.isEmpty ? "exit \(r.status)" : r.output)") }
|
||||
return r.output
|
||||
}
|
||||
|
||||
/// Writes data to a path relative to the home directory.
|
||||
func upload(_ data: Data, to path: String) async throws {
|
||||
let sftp = try await client.openSFTP()
|
||||
do {
|
||||
try await sftp.withFile(filePath: path, flags: [.write, .create, .truncate]) { file in
|
||||
try await file.write(ByteBuffer(bytes: data))
|
||||
}
|
||||
try? await sftp.close()
|
||||
} catch {
|
||||
try? await sftp.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the first caller only.
|
||||
final class Once: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
|
||||
}
|
||||
|
||||
func shellQuote(_ s: String) -> String {
|
||||
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import Citadel
|
||||
import Foundation
|
||||
import NIOCore
|
||||
|
||||
/// Frame Control's server, running on the Frame itself. The app copies the bundle
|
||||
/// (ios/scripts/make_frame_bundle.py) to ~/.cache/frame-control/<version> once per
|
||||
/// version, then starts ui/server.py there over SSH. It listens only on the Frame's
|
||||
/// 127.0.0.1, and it exits when this SSH session ends (--exit-on-eof).
|
||||
final class HeadsetServer: @unchecked Sendable {
|
||||
let port: Int
|
||||
private let lock = NSLock()
|
||||
private var _exited: String?
|
||||
private var onExit: (@Sendable (String) -> Void)?
|
||||
/// Set once the server stops, with its last output.
|
||||
var exited: String? { lock.withLock { _exited } }
|
||||
|
||||
private init(port: Int) { self.port = port }
|
||||
|
||||
/// Calls back once when the server stops, at once if it already has.
|
||||
func whenExited(_ callback: @escaping @Sendable (String) -> Void) {
|
||||
let already: String? = lock.withLock {
|
||||
if _exited == nil { onExit = callback }
|
||||
return _exited
|
||||
}
|
||||
if let already { callback(already) }
|
||||
}
|
||||
|
||||
fileprivate func markExited(_ tail: String) {
|
||||
let callback: (@Sendable (String) -> Void)? = lock.withLock {
|
||||
guard _exited == nil else { return nil }
|
||||
_exited = tail
|
||||
defer { onExit = nil }
|
||||
return onExit
|
||||
}
|
||||
callback?(tail)
|
||||
}
|
||||
|
||||
static let cacheDir = ".cache/frame-control"
|
||||
|
||||
struct Bundle {
|
||||
let data: Data
|
||||
let version: String
|
||||
|
||||
static func fromApp() throws -> Bundle {
|
||||
guard let url = Foundation.Bundle.main.url(forResource: "frame-bundle", withExtension: "tar.gz"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let vurl = Foundation.Bundle.main.url(forResource: "frame-bundle", withExtension: "version"),
|
||||
let version = try? String(contentsOf: vurl, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
version.range(of: "^[0-9a-f]{16}$", options: .regularExpression) != nil else {
|
||||
throw FrameFailure("This build of the app is missing its Frame bundle")
|
||||
}
|
||||
return Bundle(data: data, version: version)
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the bundle over unless this version is already there; removes older versions.
|
||||
static func deploy(_ bundle: Bundle, over link: FrameLink, progress: @escaping @Sendable (String) -> Void) async throws -> String {
|
||||
let dir = "\(cacheDir)/\(bundle.version)"
|
||||
let py = try await link.run("command -v python3 >/dev/null && python3 -c 'import sys; print(sys.version_info >= (3, 8))'")
|
||||
guard py.status == 0, py.output.hasSuffix("True") else {
|
||||
throw FrameFailure("The Frame has no Python 3.8 or later, which Frame Control needs there.")
|
||||
}
|
||||
if try await link.run("test -f \(dir)/ui/server.py").status != 0 {
|
||||
progress("Copying Frame Control to the headset")
|
||||
try await link.check("mkdir -p \(cacheDir)", "Couldn't make \(cacheDir)")
|
||||
let archive = "\(dir).tar.gz"
|
||||
try await link.upload(bundle.data, to: archive)
|
||||
progress("Unpacking")
|
||||
try await link.check("rm -rf \(dir).tmp && mkdir \(dir).tmp && tar xzf \(archive) -C \(dir).tmp && rm -f \(archive) "
|
||||
+ "&& rm -rf \(dir) && mv \(dir).tmp \(dir)", "Couldn't unpack Frame Control on the headset")
|
||||
}
|
||||
// Another phone or iPad may be running a different version right now: a version
|
||||
// goes only when no server runs from it and it hasn't been used for two weeks
|
||||
// (this one is marked as used). Servers run by absolute path, so pgrep sees it.
|
||||
_ = try? await link.run("touch \(dir) && cd \(cacheDir) && for d in */; do d=${d%/}; "
|
||||
+ "[ \"$d\" = \(bundle.version) ] && continue; "
|
||||
+ "[ -n \"$(find \"$d\" -maxdepth 0 -mtime +14)\" ] || continue; "
|
||||
+ "pgrep -f \"$PWD/$d/\" >/dev/null && continue; rm -rf -- \"$d\"; done")
|
||||
return dir
|
||||
}
|
||||
|
||||
/// Starts the server in dir and waits for it to say which port it took.
|
||||
static func start(in dir: String, over link: FrameLink, key: String, device: String) async throws -> HeadsetServer {
|
||||
let command = "cd \(dir) && FRAME_LOCAL=1 FRAME_UI_KEY=\(key) FRAME_DEVICE=\(shellQuote(device)) "
|
||||
+ "exec python3 -I -u -B \"$PWD/ui/server.py\" --port 0 --exit-on-eof 2>&1"
|
||||
let stream = try await link.client.executeCommandStream(command)
|
||||
let box = PortWaiter()
|
||||
let reader = Task { () -> Void in
|
||||
var text = ""
|
||||
do {
|
||||
for try await chunk in stream {
|
||||
switch chunk {
|
||||
case .stdout(let b), .stderr(let b): text += String(buffer: b)
|
||||
}
|
||||
if text.count > 20_000 { text = String(text.suffix(10_000)) }
|
||||
if let port = Self.port(in: text) { box.found(port) }
|
||||
}
|
||||
} catch {
|
||||
text += "\n\(error)"
|
||||
}
|
||||
box.ended(text)
|
||||
}
|
||||
let server: HeadsetServer
|
||||
do {
|
||||
server = HeadsetServer(port: try await box.wait(seconds: 30))
|
||||
} catch {
|
||||
reader.cancel()
|
||||
throw error
|
||||
}
|
||||
box.whenEnded { [weak server] tail in server?.markExited(tail) }
|
||||
return server
|
||||
}
|
||||
|
||||
/// The port from the server's first line. Output arrives in chunks, so the digits
|
||||
/// only count once something follows them (the line goes on after the port).
|
||||
static func port(in text: String) -> Int? {
|
||||
guard let r = text.range(of: #"Frame Control on http://127\.0\.0\.1:[0-9]+\s"#, options: .regularExpression),
|
||||
let port = Int(text[r].dropLast().split(separator: ":").last ?? ""), (1...65535).contains(port) else { return nil }
|
||||
return port
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the port from the output reader to start(), or the output if the server died first.
|
||||
private final class PortWaiter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<Int, Error>?
|
||||
private var result: Result<Int, Error>?
|
||||
private var endedTail: String?
|
||||
private var onEnd: (@Sendable (String) -> Void)?
|
||||
|
||||
/// Calls back when the output ends, at once if it already has.
|
||||
func whenEnded(_ callback: @escaping @Sendable (String) -> Void) {
|
||||
let already: String? = lock.withLock {
|
||||
if endedTail == nil { onEnd = callback }
|
||||
return endedTail
|
||||
}
|
||||
if let already { callback(already) }
|
||||
}
|
||||
|
||||
func found(_ port: Int) { finish(.success(port)) }
|
||||
|
||||
func ended(_ text: String) {
|
||||
let tail = String(text.suffix(600)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let callback: (@Sendable (String) -> Void)? = lock.withLock {
|
||||
endedTail = tail
|
||||
defer { onEnd = nil }
|
||||
return onEnd
|
||||
}
|
||||
finish(.failure(FrameFailure("Frame Control's server on the headset stopped: \(tail.isEmpty ? "no output" : tail)")))
|
||||
callback?(tail)
|
||||
}
|
||||
|
||||
private func finish(_ r: Result<Int, Error>) {
|
||||
let c: CheckedContinuation<Int, Error>? = lock.withLock {
|
||||
guard result == nil else { return nil }
|
||||
result = r
|
||||
defer { continuation = nil }
|
||||
return continuation
|
||||
}
|
||||
c?.resume(with: r)
|
||||
}
|
||||
|
||||
func wait(seconds: Double) async throws -> Int {
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(seconds * 1e9))
|
||||
self?.finish(.failure(FrameFailure("Frame Control's server on the headset didn't start within \(Int(seconds)) s")))
|
||||
}
|
||||
return try await withCheckedThrowingContinuation { c in
|
||||
let done: Result<Int, Error>? = lock.withLock {
|
||||
if let result { return result }
|
||||
continuation = c
|
||||
return nil
|
||||
}
|
||||
if let done { c.resume(with: done) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import NIOSSH
|
||||
import Security
|
||||
|
||||
/// Small wrapper over the Keychain for this app's secrets.
|
||||
enum Keychain {
|
||||
private static let service = "com.saphid.framecontrol"
|
||||
|
||||
private static func query(_ account: String) -> [String: Any] {
|
||||
[kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account]
|
||||
}
|
||||
|
||||
static func data(_ account: String) -> Data? {
|
||||
var q = query(account)
|
||||
q[kSecReturnData as String] = true
|
||||
q[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var out: AnyObject?
|
||||
return SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess ? out as? Data : nil
|
||||
}
|
||||
|
||||
static func set(_ data: Data, _ account: String) {
|
||||
SecItemDelete(query(account) as CFDictionary)
|
||||
var q = query(account)
|
||||
q[kSecValueData as String] = data
|
||||
// Only on this device and not in backups: the key is this phone's identity.
|
||||
q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
SecItemAdd(q as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func delete(_ account: String) {
|
||||
SecItemDelete(query(account) as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
/// This phone's SSH key: ed25519, made once, kept in the Keychain.
|
||||
enum DeviceKey {
|
||||
private static let account = "ssh-ed25519"
|
||||
|
||||
static func loadOrCreate() -> Curve25519.Signing.PrivateKey {
|
||||
if let raw = Keychain.data(account), let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: raw) {
|
||||
return key
|
||||
}
|
||||
let key = Curve25519.Signing.PrivateKey()
|
||||
Keychain.set(key.rawRepresentation, account)
|
||||
return key
|
||||
}
|
||||
|
||||
/// The line for ~/.ssh/authorized_keys, e.g. "ssh-ed25519 AAAA… frame-control@iPhone".
|
||||
static func authorizedKeysLine(_ key: Curve25519.Signing.PrivateKey, comment: String) -> String {
|
||||
String(openSSHPublicKey: NIOSSHPrivateKey(ed25519Key: key).publicKey) + " " + comment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import Citadel
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
import NIOSSH
|
||||
|
||||
/// Listens on this phone's 127.0.0.1 and carries each connection to a port on the
|
||||
/// Frame's 127.0.0.1 through the SSH session (ssh -L). The web view loads the
|
||||
/// server from here; every API request still needs the session's key.
|
||||
final class PortForwarder: @unchecked Sendable {
|
||||
private let channel: Channel
|
||||
let localPort: Int
|
||||
|
||||
private init(channel: Channel, localPort: Int) {
|
||||
self.channel = channel
|
||||
self.localPort = localPort
|
||||
}
|
||||
|
||||
static func start(over link: FrameLink, to remotePort: Int) async throws -> PortForwarder {
|
||||
let client = link.client
|
||||
// The listener shares the SSH connection's event loop, so the glue between
|
||||
// each pair of channels never crosses threads.
|
||||
let bootstrap = ServerBootstrap(group: client.eventLoop)
|
||||
.serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
||||
.childChannelOption(ChannelOptions.allowRemoteHalfClosure, value: true)
|
||||
// Nothing is read from the web view until the SSH side is ready for it.
|
||||
.childChannelOption(ChannelOptions.autoRead, value: false)
|
||||
.childChannelInitializer { inbound in
|
||||
inbound.eventLoop.makeFutureWithTask {
|
||||
let (local, remote) = GlueHandler.matchedPair()
|
||||
try await inbound.pipeline.addHandler(local).get()
|
||||
let origin = try inbound.remoteAddress ?? SocketAddress(ipAddress: "127.0.0.1", port: 0)
|
||||
_ = try await client.createDirectTCPIPChannel(
|
||||
using: SSHChannelType.DirectTCPIP(targetHost: "127.0.0.1", targetPort: remotePort, originatorAddress: origin)
|
||||
) { channel in channel.pipeline.addHandler(remote) }
|
||||
try await inbound.setOption(ChannelOptions.autoRead, value: true).get()
|
||||
}
|
||||
}
|
||||
let channel = try await bootstrap.bind(host: "127.0.0.1", port: 0).get()
|
||||
guard let port = channel.localAddress?.port else { throw FrameFailure("Couldn't open a local port") }
|
||||
return PortForwarder(channel: channel, localPort: port)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
channel.close(promise: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins two channels: what one reads, the other writes, with backpressure and
|
||||
/// half-close passed across (the pattern from SwiftNIO's examples).
|
||||
final class GlueHandler: ChannelDuplexHandler, @unchecked Sendable {
|
||||
typealias InboundIn = NIOAny
|
||||
typealias OutboundIn = NIOAny
|
||||
typealias OutboundOut = NIOAny
|
||||
|
||||
private var partner: GlueHandler?
|
||||
private var context: ChannelHandlerContext?
|
||||
private var pendingRead = false
|
||||
|
||||
static func matchedPair() -> (GlueHandler, GlueHandler) {
|
||||
let a = GlueHandler(), b = GlueHandler()
|
||||
a.partner = b
|
||||
b.partner = a
|
||||
return (a, b)
|
||||
}
|
||||
|
||||
private func partnerWrite(_ data: NIOAny) { context?.write(data, promise: nil) }
|
||||
private func partnerFlush() { context?.flush() }
|
||||
private func partnerWriteEOF() { context?.close(mode: .output, promise: nil) }
|
||||
private func partnerClose() { context?.close(promise: nil) }
|
||||
private var partnerWritable: Bool { context?.channel.isWritable ?? false }
|
||||
|
||||
private func partnerBecameWritable() {
|
||||
if pendingRead {
|
||||
pendingRead = false
|
||||
context?.read()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerAdded(context: ChannelHandlerContext) { self.context = context }
|
||||
|
||||
func handlerRemoved(context: ChannelHandlerContext) {
|
||||
self.context = nil
|
||||
partner = nil
|
||||
}
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) { partner?.partnerWrite(data) }
|
||||
func channelReadComplete(context: ChannelHandlerContext) { partner?.partnerFlush() }
|
||||
func channelInactive(context: ChannelHandlerContext) { partner?.partnerClose() }
|
||||
|
||||
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
|
||||
if let e = event as? ChannelEvent, case .inputClosed = e {
|
||||
partner?.partnerWriteEOF()
|
||||
}
|
||||
context.fireUserInboundEventTriggered(event)
|
||||
}
|
||||
|
||||
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||
partner?.partnerClose()
|
||||
}
|
||||
|
||||
func channelWritabilityChanged(context: ChannelHandlerContext) {
|
||||
if context.channel.isWritable { partner?.partnerBecameWritable() }
|
||||
}
|
||||
|
||||
func read(context: ChannelHandlerContext) {
|
||||
if let partner, partner.partnerWritable {
|
||||
context.read()
|
||||
} else {
|
||||
pendingRead = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.frameBackground.ignoresSafeArea()
|
||||
switch model.phase {
|
||||
case .setup:
|
||||
SetupView(model: model)
|
||||
case .connecting(let step):
|
||||
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
|
||||
case .failed(let message) where model.retrying && !model.needsPairing:
|
||||
WaitingView(host: model.settings.map { $0.port == 22 ? $0.host : "\($0.host):\($0.port)" } ?? "", detail: message, deviceName: model.deviceName,
|
||||
reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() })
|
||||
case .failed(let message):
|
||||
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
|
||||
retry: { Task { await model.connect() } }, change: { model.showSetup() })
|
||||
case .ready(let url):
|
||||
WebShell(url: url, model: model).ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.tint(.frameBlue)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
static let frameBackground = Color(red: 0.055, green: 0.078, blue: 0.106)
|
||||
static let framePanel = Color(red: 0.118, green: 0.137, blue: 0.161)
|
||||
static let frameBlue = Color(red: 0.102, green: 0.624, blue: 1.0)
|
||||
static let frameMuted = Color(red: 0.561, green: 0.596, blue: 0.627)
|
||||
}
|
||||
|
||||
struct ConnectingView: View {
|
||||
let step: String
|
||||
let host: String?
|
||||
let cancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
ProgressView().controlSize(.large)
|
||||
Text(step).font(.headline).multilineTextAlignment(.center)
|
||||
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
|
||||
Button("Change headset", action: cancel).padding(.top, 8)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
|
||||
struct FailedView: View {
|
||||
let message: String
|
||||
let canRetry: Bool
|
||||
let retrying: Bool
|
||||
let needsPairing: Bool
|
||||
let retry: () -> Void
|
||||
let change: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
|
||||
.font(.system(size: 44)).foregroundStyle(.orange)
|
||||
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
|
||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
|
||||
if needsPairing {
|
||||
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
} else if canRetry {
|
||||
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
}
|
||||
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: 480)
|
||||
}
|
||||
}
|
||||
|
||||
/// A paired Frame that isn't answering is almost always asleep: say how to wake
|
||||
/// it, and connect the moment it does (its SSH port is checked every 3 s).
|
||||
struct WaitingView: View {
|
||||
let host: String
|
||||
let detail: String
|
||||
let deviceName: String
|
||||
let reachable: () -> Void
|
||||
let change: () -> Void
|
||||
@State private var pulse = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.opacity(pulse ? 1 : 0.55)
|
||||
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
|
||||
Text("Waiting for your Frame").font(.title3.bold())
|
||||
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
|
||||
.multilineTextAlignment(.center)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
|
||||
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
|
||||
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
|
||||
Button("Connect to a different Frame", action: change).font(.footnote)
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 480)
|
||||
.onAppear { pulse = true }
|
||||
.task(id: host) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
if !host.isEmpty, await FrameFinder.sshAnswers(host: host) {
|
||||
reachable()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// First run: two steps. Wake the Frame (the app finds it by itself), then type the
|
||||
/// Developer Mode password once. Everything else waits under "Other ways to connect".
|
||||
struct SetupView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@StateObject private var finder = FrameFinder()
|
||||
@State private var password = ""
|
||||
@State private var manualHost = ""
|
||||
@State private var user = "steamos"
|
||||
@State private var showOther = false
|
||||
@State private var showHelp = false
|
||||
@FocusState private var passwordFocused: Bool
|
||||
|
||||
/// Where Connect goes: what the finder saw, else what was typed, else frame.local.
|
||||
private var host: String {
|
||||
let typed = manualHost.trimmingCharacters(in: .whitespaces)
|
||||
return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 22) {
|
||||
header
|
||||
StepCard(number: 1, title: "Wake your Frame", done: finder.found != nil) { wakeStep }
|
||||
StepCard(number: 2, title: "Enter its Developer Mode password", done: false) { passwordStep }
|
||||
otherWays
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: 560)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background(Color.frameBackground)
|
||||
.onAppear {
|
||||
manualHost = model.settings?.host ?? ""
|
||||
user = model.settings?.user ?? "steamos"
|
||||
var fallback = model.settings?.host
|
||||
#if DEBUG
|
||||
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
|
||||
#endif
|
||||
finder.start(fallback: fallback)
|
||||
}
|
||||
.onDisappear { finder.stop() }
|
||||
// After a few seconds of not finding it, say exactly what to check.
|
||||
.task(id: finder.since) {
|
||||
try? await Task.sleep(nanoseconds: 8_000_000_000)
|
||||
showHelp = true
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Image("AppIconImage").resizable().frame(width: 56, height: 56).clipShape(RoundedRectangle(cornerRadius: 13))
|
||||
Text("Connect to your Steam Frame").font(.title2.bold())
|
||||
Text("One time only. After this, the app connects by itself whenever your Frame is awake.")
|
||||
.foregroundStyle(Color.frameMuted)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: step 1
|
||||
|
||||
@ViewBuilder private var wakeStep: some View {
|
||||
if let found = finder.found {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Found your Frame").fontWeight(.semibold)
|
||||
Text(found.name == found.host ? found.host : "\(found.name) · \(found.host)")
|
||||
.font(.footnote).foregroundStyle(Color.frameMuted)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Looking for it on this network…").foregroundStyle(Color.frameMuted)
|
||||
}
|
||||
Text("Put the headset on, or press its power button, so it's awake.")
|
||||
if showHelp {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Still can't see it? Check:").font(.subheadline.weight(.semibold))
|
||||
Tip(icon: "wifi", text: "The Frame and this \(model.deviceName) are on the same Wi-Fi.")
|
||||
Tip(icon: "hammer", text: "Developer Mode is on: on the Frame, Steam Settings → System → Enable Developer Mode.")
|
||||
Tip(icon: "network", text: "Local Network is allowed for Frame Control: \(model.deviceName) Settings → Apps → Frame Control.")
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: step 2
|
||||
|
||||
@ViewBuilder private var passwordStep: some View {
|
||||
SecureField("Developer Mode password", text: $password)
|
||||
.textContentType(.password)
|
||||
.submitLabel(.go)
|
||||
.focused($passwordFocused)
|
||||
.onSubmit(connect)
|
||||
.padding(12)
|
||||
.background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 10))
|
||||
Text("Haven't set one? On the Frame: Steam Settings → Developer → Set User Password. It's only used now, to let this \(model.deviceName) in; it isn't saved.")
|
||||
.font(.footnote).foregroundStyle(Color.frameMuted)
|
||||
Button(action: connect) {
|
||||
Text(finder.found == nil ? "Connect to \(host)" : "Connect")
|
||||
.fontWeight(.semibold).frame(maxWidth: .infinity).padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(password.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: everything else, out of the way
|
||||
|
||||
private var otherWays: some View {
|
||||
DisclosureGroup(isExpanded: $showOther) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Address").font(.footnote).foregroundStyle(Color.frameMuted)
|
||||
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
|
||||
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.onSubmit { finder.start(fallback: manualHost) }
|
||||
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
|
||||
TextField("User", text: $user)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
|
||||
Text("Typing an address here uses it instead of searching.").font(.caption).foregroundStyle(Color.frameMuted)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Already reach the Frame over SSH? Add this \(model.deviceName)'s key to ~/.ssh/authorized_keys there, then connect without a password.")
|
||||
.font(.footnote).foregroundStyle(Color.frameMuted)
|
||||
HStack {
|
||||
Button("Copy key") { UIPasteboard.general.string = model.authorizedKeysLine }
|
||||
Spacer()
|
||||
Button("Connect with the key") {
|
||||
let (h, u) = (host, user)
|
||||
Task { await model.useKey(host: h, user: u) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if let saved = model.settings {
|
||||
Button("Forget \(saved.host)", role: .destructive) { Task { await model.forget() } }
|
||||
}
|
||||
}
|
||||
.padding(.top, 10)
|
||||
} label: {
|
||||
Text("Other ways to connect").foregroundStyle(Color.frameMuted)
|
||||
}
|
||||
.onChange(of: manualHost) { _, value in
|
||||
// A typed address replaces the search.
|
||||
if !value.trimmingCharacters(in: .whitespaces).isEmpty, finder.found?.host != value { finder.start(fallback: value) }
|
||||
}
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
guard !password.isEmpty else { passwordFocused = true; return }
|
||||
let (h, u, p) = (host, user, password)
|
||||
password = ""
|
||||
finder.stop()
|
||||
Task { await model.pair(host: h, user: u, password: p) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A numbered step with a tick once it's done.
|
||||
struct StepCard<Content: View>: View {
|
||||
let number: Int
|
||||
let title: String
|
||||
let done: Bool
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle().fill(done ? Color.green : Color.frameBlue).frame(width: 26, height: 26)
|
||||
if done { Image(systemName: "checkmark").font(.caption.bold()) } else { Text("\(number)").font(.subheadline.bold()) }
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
Text(title).font(.headline)
|
||||
}
|
||||
content
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
struct Tip: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
Label { Text(text).font(.subheadline).fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: icon).foregroundStyle(Color.frameBlue) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// The Frame Control page, served by the server on the headset, in a web view.
|
||||
/// window.frameApp (the same bridge the desktop app's preload.js provides) lets
|
||||
/// the page use the phone: clipboard, saving images, other apps, install links.
|
||||
struct WebShell: UIViewRepresentable {
|
||||
let url: URL
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(model: model) }
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
let content = WKUserContentController()
|
||||
content.addUserScript(WKUserScript(source: Self.bridge, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
content.addScriptMessageHandler(context.coordinator, contentWorld: .page, name: "frameApp")
|
||||
config.userContentController = content
|
||||
config.allowsInlineMediaPlayback = true
|
||||
let web = WKWebView(frame: .zero, configuration: config)
|
||||
web.navigationDelegate = context.coordinator
|
||||
web.uiDelegate = context.coordinator
|
||||
web.isOpaque = false
|
||||
web.backgroundColor = UIColor(red: 0.055, green: 0.078, blue: 0.106, alpha: 1)
|
||||
web.scrollView.backgroundColor = web.backgroundColor
|
||||
web.scrollView.contentInsetAdjustmentBehavior = .never // the page pads for the safe area itself
|
||||
web.allowsBackForwardNavigationGestures = false
|
||||
#if DEBUG
|
||||
web.isInspectable = true
|
||||
#endif
|
||||
context.coordinator.web = web
|
||||
web.load(URLRequest(url: url))
|
||||
return web
|
||||
}
|
||||
|
||||
func updateUIView(_ web: WKWebView, context: Context) {
|
||||
if context.coordinator.loaded != url {
|
||||
context.coordinator.loaded = url
|
||||
web.load(URLRequest(url: url))
|
||||
}
|
||||
context.coordinator.deliverInstallLinks()
|
||||
}
|
||||
|
||||
static let bridge = """
|
||||
(() => {
|
||||
const call = (name, arg) => window.webkit.messageHandlers.frameApp.postMessage({ name, arg: arg ?? null });
|
||||
let installCb = null;
|
||||
window.frameApp = {
|
||||
platform: "ios",
|
||||
readClipboard: () => call("readClipboard"),
|
||||
setUpConnection: () => call("setUpConnection"),
|
||||
open: (what) => call("open", what),
|
||||
saveImages: (images) => call("saveImages", images),
|
||||
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady"); },
|
||||
};
|
||||
window.__frameInstallLink = (req) => { if (installCb) installCb(req); };
|
||||
})();
|
||||
"""
|
||||
|
||||
final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate {
|
||||
let model: AppModel
|
||||
weak var web: WKWebView?
|
||||
var loaded: URL?
|
||||
private var installReady = false
|
||||
|
||||
init(model: AppModel) { self.model = model }
|
||||
|
||||
// MARK: bridge
|
||||
|
||||
@MainActor
|
||||
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage,
|
||||
replyHandler: @escaping (Any?, String?) -> Void) {
|
||||
guard let body = message.body as? [String: Any], let name = body["name"] as? String else {
|
||||
return replyHandler(nil, "bad message")
|
||||
}
|
||||
let arg = body["arg"]
|
||||
switch name {
|
||||
case "readClipboard":
|
||||
replyHandler(UIPasteboard.general.string ?? "", nil)
|
||||
case "setUpConnection":
|
||||
model.showSetup()
|
||||
replyHandler(nil, nil)
|
||||
case "open":
|
||||
let result = open(arg as? String ?? "")
|
||||
replyHandler(result.message.map { ["message": $0] }, result.error)
|
||||
case "saveImages":
|
||||
let images = (arg as? [[String: Any]] ?? []).compactMap { item -> UIImage? in
|
||||
guard let b64 = item["data"] as? String, let data = Data(base64Encoded: b64) else { return nil }
|
||||
return UIImage(data: data)
|
||||
}
|
||||
guard !images.isEmpty else { return replyHandler(nil, "No images to save") }
|
||||
share(images)
|
||||
replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil)
|
||||
case "installLinkReady":
|
||||
installReady = true
|
||||
deliverInstallLinks()
|
||||
replyHandler(nil, nil)
|
||||
default:
|
||||
replyHandler(nil, "unknown request \(name)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func deliverInstallLinks() {
|
||||
guard installReady, let web, !model.pendingInstallLinks.isEmpty else { return }
|
||||
let links = model.pendingInstallLinks
|
||||
model.pendingInstallLinks = []
|
||||
for link in links {
|
||||
let req = ["kind": link.kind.rawValue, "target": link.target]
|
||||
guard let json = try? JSONSerialization.data(withJSONObject: req), let text = String(data: json, encoding: .utf8) else { continue }
|
||||
web.evaluateJavaScript("window.__frameInstallLink(\(text))")
|
||||
}
|
||||
}
|
||||
|
||||
/// SSH, SFTP, Steam Link and remote desktop open in the apps that handle them.
|
||||
@MainActor
|
||||
private func open(_ what: String) -> (message: String?, error: String?) {
|
||||
guard let s = model.settings else { return (nil, "Not paired with a Frame") }
|
||||
let host = s.host.contains(":") ? "[\(s.host)]" : s.host
|
||||
let target: (url: String, app: String, store: String)
|
||||
switch what {
|
||||
case "terminal": target = ("ssh://\(s.user)@\(host):\(s.port)", "an SSH app such as Blink Shell or Termius", "https://apps.apple.com/search?term=ssh")
|
||||
case "sftp": target = ("sftp://\(s.user)@\(host):\(s.port)", "an SFTP app such as Termius or Secure ShellFish", "https://apps.apple.com/search?term=sftp")
|
||||
case "steamlink": target = ("steamlink://", "Steam Link", "https://apps.apple.com/app/steam-link/id1246969117")
|
||||
case "rdp": target = ("rdp://full%20address=s:\(s.host):3389", "Windows App (Microsoft Remote Desktop)", "https://apps.apple.com/app/windows-app/id714464092")
|
||||
default: return (nil, "Can't open \(what) on this \(model.deviceName)")
|
||||
}
|
||||
guard let url = URL(string: target.url) else { return (nil, "Bad address") }
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url)
|
||||
return ("Opening \(target.app)", nil)
|
||||
}
|
||||
if let store = URL(string: target.store) { UIApplication.shared.open(store) }
|
||||
return (nil, "Install \(target.app) to open this; opening the App Store")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func share(_ images: [UIImage]) {
|
||||
guard let web, let root = web.window?.rootViewController else { return }
|
||||
let sheet = UIActivityViewController(activityItems: images, applicationActivities: nil)
|
||||
sheet.popoverPresentationController?.sourceView = web
|
||||
sheet.popoverPresentationController?.sourceRect = CGRect(x: web.bounds.midX, y: web.bounds.midY, width: 1, height: 1)
|
||||
(root.presentedViewController ?? root).present(sheet, animated: true)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Simulator test hook: FRAME_TEST_JS runs in the page once it has loaded.
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard let js = ProcessInfo.processInfo.environment["FRAME_TEST_JS"] else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { webView.evaluateJavaScript(js) }
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: navigation: the app's page stays here; other sites open in Safari
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = action.request.url else { return decisionHandler(.cancel) }
|
||||
if url.host == "127.0.0.1" || url.scheme == "about" || url.scheme == "blob" || url.scheme == "data" {
|
||||
return decisionHandler(.allow)
|
||||
}
|
||||
UIApplication.shared.open(url)
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
||||
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
||||
if let url = action.request.url { UIApplication.shared.open(url) } // target="_blank" links
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: alert() and confirm(), which the page uses before removing things
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
||||
present(message, actions: [UIAlertAction(title: "OK", style: .default) { _ in completionHandler() }], fallback: completionHandler)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
||||
present(message, actions: [
|
||||
UIAlertAction(title: "Cancel", style: .cancel) { _ in completionHandler(false) },
|
||||
UIAlertAction(title: "OK", style: .default) { _ in completionHandler(true) },
|
||||
], fallback: { completionHandler(false) })
|
||||
}
|
||||
|
||||
private func present(_ message: String, actions: [UIAlertAction], fallback: @escaping () -> Void) {
|
||||
guard let root = web?.window?.rootViewController else { return fallback() }
|
||||
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
|
||||
actions.forEach(alert.addAction)
|
||||
(root.presentedViewController ?? root).present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
@testable import Frame_Control
|
||||
|
||||
final class InstallLinkTests: XCTestCase {
|
||||
func testAcceptsManifestAndURLLinks() {
|
||||
XCTAssertEqual(InstallLink("frame-control://install?manifest=https://example.com/app.json"),
|
||||
InstallLink("frame-control://install/?manifest=https://example.com/app.json"))
|
||||
XCTAssertEqual(InstallLink("frame-control://install?url=https://example.com/a.apk")?.kind, .url)
|
||||
XCTAssertEqual(InstallLink("FRAME-CONTROL://install?manifest=https://example.com/m.json")?.target, "https://example.com/m.json")
|
||||
}
|
||||
|
||||
func testRejectsAnythingElse() {
|
||||
for raw in ["frame-control://other?url=https://example.com/a.apk",
|
||||
"frame-control://install?url=ftp://example.com/a.apk",
|
||||
"frame-control://install?url=https://user:pw@example.com/a.apk",
|
||||
"frame-control://install?url=https://example.com/a&manifest=https://example.com/b",
|
||||
"frame-control://install?url=https://a.example/x&url=https://b.example/y",
|
||||
"frame-control://install?url=",
|
||||
"frame-control://install/deeper?url=https://example.com/a.apk",
|
||||
"https://example.com/?url=https://example.com/a.apk",
|
||||
"frame-control://install?url=https://example.com/" + String(repeating: "a", count: 2100)] {
|
||||
XCTAssertNil(InstallLink(raw), raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class HeadsetServerTests: XCTestCase {
|
||||
func testReadsThePortTheServerPrints() {
|
||||
XCTAssertEqual(HeadsetServer.port(in: "Frame Control on http://127.0.0.1:41234 (alias: frame; Ctrl-C to stop)\n"), 41234)
|
||||
XCTAssertNil(HeadsetServer.port(in: "Traceback (most recent call last):"))
|
||||
XCTAssertNil(HeadsetServer.port(in: "Frame Control on http://127.0.0.1:4")) // more digits may follow
|
||||
XCTAssertNil(HeadsetServer.port(in: "Frame Control on http://127.0.0.1:99999 "))
|
||||
}
|
||||
|
||||
func testBundleIsInTheApp() throws {
|
||||
let bundle = try HeadsetServer.Bundle.fromApp()
|
||||
XCTAssertGreaterThan(bundle.data.count, 100_000)
|
||||
XCTAssertEqual(bundle.version.count, 16)
|
||||
}
|
||||
}
|
||||
|
||||
final class KeyTests: XCTestCase {
|
||||
func testAuthorizedKeysLine() {
|
||||
let line = DeviceKey.authorizedKeysLine(Curve25519.Signing.PrivateKey(), comment: "frame-control@iPhone")
|
||||
let parts = line.split(separator: " ")
|
||||
XCTAssertEqual(parts.count, 3)
|
||||
XCTAssertEqual(parts[0], "ssh-ed25519")
|
||||
XCTAssertEqual(Data(base64Encoded: String(parts[1]))?.count, 51) // string "ssh-ed25519" + 32-byte key
|
||||
XCTAssertEqual(parts[2], "frame-control@iPhone")
|
||||
}
|
||||
|
||||
func testShellQuote() {
|
||||
XCTAssertEqual(shellQuote("it's"), "'it'\\''s'")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
name: FrameControl
|
||||
options:
|
||||
bundleIdPrefix: com.saphid
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
createIntermediateGroups: true
|
||||
packages:
|
||||
Citadel:
|
||||
url: https://github.com/orlandos-nl/Citadel.git
|
||||
exactVersion: 0.12.1
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
MARKETING_VERSION: "0.1.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
targets:
|
||||
FrameControl:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: FrameControl
|
||||
dependencies:
|
||||
- package: Citadel
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.saphid.framecontrol
|
||||
PRODUCT_NAME: Frame Control
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_FILE: FrameControl/Info.plist
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||
info:
|
||||
path: FrameControl/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Frame Control
|
||||
UILaunchScreen:
|
||||
UIColorName: ""
|
||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
||||
UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
||||
UIUserInterfaceStyle: Dark
|
||||
NSLocalNetworkUsageDescription: Frame Control finds your Steam Frame on your network and connects to it.
|
||||
NSBonjourServices: [_steamos-devkit._tcp]
|
||||
NSPhotoLibraryAddUsageDescription: Frame Control saves headset captures and screenshots to your photo library when you ask it to.
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsLocalNetworking: true
|
||||
LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp]
|
||||
CFBundleURLTypes:
|
||||
- CFBundleURLName: com.saphid.framecontrol.install
|
||||
CFBundleURLSchemes: [frame-control]
|
||||
preBuildScripts:
|
||||
- name: Pack the Frame bundle
|
||||
# The server, headset helpers and catalogue, as the app copies them to the Frame.
|
||||
script: |
|
||||
mkdir -p "${DERIVED_FILE_DIR}"
|
||||
python3 "${SRCROOT}/scripts/make_frame_bundle.py" "${DERIVED_FILE_DIR}/frame-bundle.tar.gz" > "${DERIVED_FILE_DIR}/frame-bundle.version"
|
||||
mkdir -p "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"
|
||||
cp "${DERIVED_FILE_DIR}/frame-bundle.tar.gz" "${DERIVED_FILE_DIR}/frame-bundle.version" "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/"
|
||||
basedOnDependencyAnalysis: false
|
||||
FrameControlTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: FrameControlTests
|
||||
dependencies:
|
||||
- target: FrameControl
|
||||
settings:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Frame Control.app/Frame Control"
|
||||
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||
schemes:
|
||||
FrameControl:
|
||||
build:
|
||||
targets:
|
||||
FrameControl: all
|
||||
FrameControlTests: [test]
|
||||
test:
|
||||
targets: [FrameControlTests]
|
||||
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1a9fff"/>
|
||||
<stop offset="1" stop-color="#6f42c1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="visor" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#dfe8f5"/>
|
||||
</linearGradient>
|
||||
<clipPath id="tile"><rect x="100" y="100" width="824" height="824" rx="185"/></clipPath>
|
||||
<mask id="nose">
|
||||
<rect width="1024" height="1024" fill="#fff"/>
|
||||
<ellipse cx="512" cy="690" rx="78" ry="96" fill="#000"/>
|
||||
</mask>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#0b1020" flood-opacity=".35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="1024" height="1024" fill="url(#bg)"/>
|
||||
<g transform="translate(512 512) scale(1.2427) translate(-512 -512)">
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="222" y="350" width="580" height="320" rx="130" fill="url(#visor)" mask="url(#nose)"/>
|
||||
</g>
|
||||
<rect x="300" y="430" width="160" height="124" rx="50" fill="#13233a"/>
|
||||
<rect x="564" y="430" width="160" height="124" rx="50" fill="#13233a"/>
|
||||
<rect x="320" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
|
||||
<rect x="584" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pack what Frame Control's server needs to run on the Frame itself (the files the
|
||||
desktop app ships, plus ui/local-bin) into one reproducible .tar.gz.
|
||||
|
||||
The iPhone app copies it to ~/.cache/frame-control/<version> on the Frame and
|
||||
starts ui/server.py there. <version> is the SHA-256 of the archive, so a new
|
||||
build replaces an old one and an unchanged one isn't copied again.
|
||||
|
||||
Usage: make_frame_bundle.py OUT.tar.gz (prints the version)
|
||||
"""
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js"]
|
||||
|
||||
|
||||
def files():
|
||||
found = set()
|
||||
for pattern in PATTERNS:
|
||||
for p in ROOT.glob(pattern):
|
||||
if p.is_file() and "__pycache__" not in p.parts:
|
||||
found.add(p)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def build():
|
||||
raw = io.BytesIO()
|
||||
with tarfile.open(fileobj=raw, mode="w", format=tarfile.PAX_FORMAT) as tar:
|
||||
for p in files():
|
||||
info = tarfile.TarInfo(str(p.relative_to(ROOT)))
|
||||
data = p.read_bytes()
|
||||
info.size, info.mtime, info.uid, info.gid, info.uname, info.gname = len(data), 0, 0, 0, "", ""
|
||||
info.mode = 0o755 if p.stat().st_mode & 0o111 else 0o644
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
out = io.BytesIO()
|
||||
with gzip.GzipFile(fileobj=out, mode="wb", mtime=0) as gz:
|
||||
gz.write(raw.getvalue())
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit(__doc__)
|
||||
data = build()
|
||||
Path(sys.argv[1]).write_bytes(data)
|
||||
print(hashlib.sha256(data).hexdigest()[:16])
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Open Frame Control's assistant as a Chromium panel. Ctrl-C closes it and its SSH tunnel.
|
||||
|
||||
Start ui/server.py first. Requires the platform Chromium Flatpak and zsh on the
|
||||
computer (the existing panel launcher). No model endpoint or key is configured.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--port', type=int, default=47810, help='local Frame Control port')
|
||||
parser.add_argument('--frame-port', type=int, default=47812, help='Frame loopback tunnel port')
|
||||
args = parser.parse_args()
|
||||
alias = os.environ.get('FRAME_ALIAS', 'frame')
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', alias) or any(not 1 <= p <= 65535 for p in (args.port, args.frame_port)):
|
||||
parser.error('Invalid alias or port')
|
||||
if not shutil.which('zsh'):
|
||||
parser.error('The panel launcher requires zsh on this computer')
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
from frame_mcp import Client
|
||||
Client('http://127.0.0.1:' + str(args.port), os.environ.get('FRAME_UI_KEY', '1')).request('/api/host')
|
||||
profile = '/tmp/frame-control-assistant-' + uuid.uuid4().hex
|
||||
log_path = ''
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
tunnel = subprocess.Popen(['ssh', '-N', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
|
||||
'-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15',
|
||||
'-o', 'ServerAliveCountMax=2', '-R',
|
||||
f'127.0.0.1:{args.frame_port}:127.0.0.1:{args.port}', alias])
|
||||
try:
|
||||
# Check the forwarded page before starting a browser; no arbitrary sleeps.
|
||||
probe = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias,
|
||||
'curl --retry 5 --retry-connrefused --retry-delay 1 --max-time 10 -fsS ' +
|
||||
shlex.quote(f'http://127.0.0.1:{args.frame_port}/assistant')],
|
||||
stdout=subprocess.DEVNULL, timeout=30)
|
||||
if probe.returncode or tunnel.poll() is not None:
|
||||
raise RuntimeError('Could not forward Frame Control to the Frame')
|
||||
launched = subprocess.run(['zsh', str(ROOT / 'scripts/panel-on-frame.sh'), '--name', 'Frame Control Assistant',
|
||||
'org.chromium.Chromium', '--user-data-dir=' + profile, '--no-first-run',
|
||||
'--disable-background-networking', '--disable-sync',
|
||||
f'--app=http://127.0.0.1:{args.frame_port}/assistant'], check=True, timeout=45, stdout=subprocess.PIPE, text=True)
|
||||
print(launched.stdout, end='', flush=True)
|
||||
match = re.search(r'log (/tmp/panel-on-frame\.[A-Za-z0-9]+)', launched.stdout)
|
||||
if match:
|
||||
log_path = match.group(1)
|
||||
print('Assistant panel open. Ctrl-C closes this panel and its tunnel.', flush=True)
|
||||
tunnel.wait()
|
||||
raise RuntimeError('SSH tunnel ended')
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
tunnel.terminate()
|
||||
try:
|
||||
tunnel.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
tunnel.kill()
|
||||
tunnel.wait()
|
||||
# Only this unique browser profile, never a shared Chromium instance.
|
||||
cleanup = '''import os, pathlib, signal, shutil, sys, time
|
||||
profile = sys.argv[1]
|
||||
needle = ('--user-data-dir=' + profile).encode()
|
||||
owned = []
|
||||
for p in pathlib.Path('/proc').iterdir():
|
||||
try:
|
||||
if p.name.isdigit() and p.stat().st_uid == os.getuid() and needle in (p / 'cmdline').read_bytes().split(b'\\0'):
|
||||
owned.append(int(p.name))
|
||||
except OSError:
|
||||
pass
|
||||
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||||
for pid in owned:
|
||||
try: os.kill(pid, sig)
|
||||
except ProcessLookupError: pass
|
||||
time.sleep(.3)
|
||||
shutil.rmtree(profile, ignore_errors=True)
|
||||
if sys.argv[2]:
|
||||
pathlib.Path(sys.argv[2]).unlink(missing_ok=True)
|
||||
'''
|
||||
result = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias,
|
||||
'python3 - ' + shlex.quote(profile) + ' ' + shlex.quote(log_path)], input=cleanup, text=True, timeout=20)
|
||||
if result.returncode:
|
||||
print('Cleanup failed; close the assistant panel and remove ' + profile + ' on the Frame.', file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Linux host with Docker: run the end-to-end tests against the fake Frame.
|
||||
# Builds the fake Frame and host images (tests/fakeframe), starts them with
|
||||
# docker compose, runs tests/e2e in the host container, prints the results
|
||||
# and takes everything down again. Exits with the tests' status (2 if the
|
||||
# harness itself didn't come up). See docs/testing.md.
|
||||
#
|
||||
# Usage: scripts/e2e.sh [TEST...] e.g. scripts/e2e.sh test_titles test_faults.Faults.test_disk_full
|
||||
# Env: FAKEFRAME_BASE base image (default: archlinux:base, or Valve's Holo Core aarch64 on arm64)
|
||||
# FAKEFRAME_KEEP=1 leave the containers running afterwards
|
||||
set -uo pipefail
|
||||
|
||||
root=${0:A:h:h}
|
||||
cd "$root" || exit 2
|
||||
case $(uname -m) in
|
||||
x86_64|amd64) base=archlinux:base ;;
|
||||
aarch64|arm64) base=registry.gitlab.steamos.cloud/holo/holo-core-aarch64-preview/base-devel:latest ;;
|
||||
*) print -u2 "No Arch Linux base image known for $(uname -m); set FAKEFRAME_BASE"; exit 2 ;;
|
||||
esac
|
||||
base=${FAKEFRAME_BASE:-$base}
|
||||
compose=(docker compose -p fakeframe-e2e -f tests/fakeframe/compose.yaml)
|
||||
started=$SECONDS
|
||||
|
||||
print "==> Building fakeframe-frame (from $base) and fakeframe-host"
|
||||
# Quiet when it works; if a build fails, build again with the full log so CI shows why.
|
||||
build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } }
|
||||
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
|
||||
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
|
||||
|
||||
logs() {
|
||||
print "\n==> Fake Frame logs"
|
||||
$compose logs --no-color --tail 80 fakeframe
|
||||
$compose exec -T fakeframe sh -c 'for f in /var/log/fakeframe/*.log; do echo "--- $f"; tail -n 40 "$f"; done' 2>/dev/null
|
||||
print "\n==> ui/server.py log"
|
||||
$compose exec -T host sh -c 'tail -n 80 /tmp/fakeframe-e2e-server.log' 2>/dev/null
|
||||
}
|
||||
|
||||
finish() {
|
||||
if [[ ${FAKEFRAME_KEEP:-0} == 1 ]]; then
|
||||
print "==> Left running: ${(j: :)compose} exec host bash"
|
||||
else
|
||||
$compose down -v --remove-orphans >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
trap finish EXIT
|
||||
|
||||
$compose down -v --remove-orphans >/dev/null 2>&1
|
||||
print "==> Starting the fake Frame and the host"
|
||||
if ! $compose up -d --wait; then
|
||||
logs
|
||||
exit 2
|
||||
fi
|
||||
print "==> Up after $(( SECONDS - started )) s; running tests/e2e"
|
||||
|
||||
if (( $# )); then
|
||||
args=(-v "$@")
|
||||
else
|
||||
args=(discover -v -s .)
|
||||
fi
|
||||
tests_started=$SECONDS
|
||||
$compose exec -T -w /repo/tests/e2e host python3 -m unittest "${args[@]}"
|
||||
rc=$?
|
||||
(( rc == 0 )) || logs
|
||||
print "\n==> tests/e2e: $([[ $rc == 0 ]] && echo passed || echo "FAILED (exit $rc)") in $(( SECONDS - tests_started )) s" \
|
||||
"($(( SECONDS - started )) s with builds)"
|
||||
exit $rc
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac or Linux: the headset smoke test. Installs, launches and removes tiny
|
||||
# test titles on the Frame (the `frame` alias) and records the results with
|
||||
# its BUILD_ID under tests/smoke/results/. See docs/testing.md.
|
||||
#
|
||||
# Usage: scripts/frame-smoke.sh [--pair] (--pair needs you in the headset to approve)
|
||||
set -euo pipefail
|
||||
exec python3 "${0:A:h:h}/tests/smoke/frame_smoke.py" "$@"
|
||||
@@ -59,9 +59,13 @@ colordepth=32
|
||||
quality=9
|
||||
viewonly=0
|
||||
showcursor=1
|
||||
scale=1
|
||||
viewmode=1
|
||||
window_maximize=1
|
||||
EOF
|
||||
echo \"wrote \$d/mac-screen-sharing.remmina\"
|
||||
"
|
||||
print "On the Mac: System Settings > General > Sharing > Screen Sharing (i) >"
|
||||
print " enable 'VNC viewers may control screen with password' and set one."
|
||||
print "Remmina may ask for your Mac account name + login password instead (Apple auth)."
|
||||
fi
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: stop the Steam Frame from going to sleep while an agent works on it.
|
||||
#
|
||||
# The Frame sleeps when Steam's own idle timer runs out ("Sleep after
|
||||
# inactivity": 60 min on AC, 15 min on battery by default). SSH activity
|
||||
# doesn't count as input, and asleep the Frame is off the network. `on` sets
|
||||
# both timers to Never through Steam's UI (DevTools on 127.0.0.1:8080, via
|
||||
# ui/frame_steam.py) and holds a logind sleep inhibitor as a user unit.
|
||||
# `off` drops the inhibitor and restores the timers `on` saved.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/keep-awake.sh on
|
||||
# scripts/keep-awake.sh off
|
||||
# scripts/keep-awake.sh status
|
||||
set -euo pipefail
|
||||
|
||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
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" \
|
||||
'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
|
||||
# timers are client settings system_idle_suspend_{ac,battery}_sec (0 = Never),
|
||||
# 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'
|
||||
import json, os, subprocess, sys
|
||||
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
||||
from frame_steam import Page
|
||||
|
||||
cmd = sys.argv[1]
|
||||
saved_path = os.path.expanduser("~/.cache/frame-control/keep-awake.json")
|
||||
unit = "fc-keep-awake"
|
||||
keys = ("system_idle_suspend_ac_sec", "system_idle_suspend_battery_sec")
|
||||
|
||||
if cmd == "off": # release the lock first, even if Steam's UI is down
|
||||
subprocess.run(["systemctl", "--user", "stop", unit], stderr=subprocess.DEVNULL)
|
||||
page = Page()
|
||||
def read():
|
||||
return {k: page.eval(f"settingsStore.clientSettings.{k}") for k in keys}
|
||||
def write(values):
|
||||
page.eval("""(async () => { let req;
|
||||
webpackChunksteamui.push([[Symbol()], {}, r => { req = r }]);
|
||||
const mod = Object.keys(req.m).map(id => req.m[id].toString().includes("Settings.SetSetting") ? req(id) : null).find(Boolean);
|
||||
const set = Object.values(mod).find(f => typeof f == "function" && f.toString().includes("SetSetting("));
|
||||
for (const [k, v] of Object.entries(%s)) await set(k, v);
|
||||
await new Promise(r => setTimeout(r, 1000)); })()""" % json.dumps(values))
|
||||
def inhibitor():
|
||||
return subprocess.run(["systemctl", "--user", "is-active", "-q", unit]).returncode == 0
|
||||
|
||||
if cmd == "on":
|
||||
current = read()
|
||||
if not os.path.exists(saved_path):
|
||||
with open(saved_path, "w") as f:
|
||||
json.dump(current, f)
|
||||
write({k: 0 for k in keys})
|
||||
if not inhibitor():
|
||||
subprocess.run(["systemd-run", "--user", "-q", f"--unit={unit}",
|
||||
"--description=Frame Control: keep the Frame awake",
|
||||
"systemd-inhibit", "--what=sleep:idle:handle-suspend-key:handle-power-key",
|
||||
"--who=Frame Control", "--why=Keep the Frame awake while an agent works on it",
|
||||
"--mode=block", "sleep", "infinity"], check=True)
|
||||
elif cmd == "off":
|
||||
if os.path.exists(saved_path): # no backup: leave the timers as they are
|
||||
with open(saved_path) as f:
|
||||
write(json.load(f))
|
||||
os.remove(saved_path)
|
||||
|
||||
print(json.dumps({"timers": read(), "inhibitor": inhibitor()}))
|
||||
EOF
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Hammerspoon: draw a ring around the Mac pointer so it shows in the VNC
|
||||
-- mirror on the Frame. macOS Screen Sharing leaves the pointer out of the
|
||||
-- framebuffer; a real on-screen window is captured like anything else.
|
||||
--
|
||||
-- Install: brew install --cask hammerspoon, then in ~/.hammerspoon/init.lua:
|
||||
-- dofile("/path/to/frame-control/scripts/mac-cursor-ring.lua")
|
||||
-- Toggle: ctrl+alt+cmd+M. Polls the pointer position, so no Accessibility
|
||||
-- permission is needed.
|
||||
|
||||
local SIZE, WIDTH = 34, 3
|
||||
local COLOR = { red = 1, green = 0.2, blue = 0.2, alpha = 0.9 }
|
||||
|
||||
local ring = hs.canvas.new({ x = 0, y = 0, w = SIZE, h = SIZE })
|
||||
ring:appendElements({
|
||||
type = "circle", action = "stroke",
|
||||
strokeColor = COLOR, strokeWidth = WIDTH,
|
||||
radius = (SIZE - WIDTH) / 2,
|
||||
})
|
||||
ring:level(hs.canvas.windowLevels.cursor)
|
||||
ring:behavior({ "canJoinAllSpaces", "stationary", "ignoresCycle" })
|
||||
|
||||
local last = {}
|
||||
local function follow()
|
||||
local p = hs.mouse.absolutePosition()
|
||||
if p.x ~= last.x or p.y ~= last.y then
|
||||
ring:topLeft({ x = p.x - SIZE / 2, y = p.y - SIZE / 2 })
|
||||
last = p
|
||||
end
|
||||
end
|
||||
|
||||
frameCursorRing = { canvas = ring, timer = hs.timer.new(1 / 60, follow) }
|
||||
|
||||
local function show() follow(); ring:show(); frameCursorRing.timer:start() end
|
||||
local function hide() frameCursorRing.timer:stop(); ring:hide() end
|
||||
|
||||
hs.hotkey.bind({ "ctrl", "alt", "cmd" }, "M", function()
|
||||
if ring:isShowing() then hide() else show() end
|
||||
end)
|
||||
|
||||
show()
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/bin/sh
|
||||
# Publish a tested draft release so running copies of Frame Control offer it
|
||||
# (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
|
||||
set -eu
|
||||
tag="${1:?usage: $0 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"
|
||||
|
||||
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)"
|
||||
|
||||
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")
|
||||
case "$digest" in
|
||||
sha256:*) echo "ok $name" ;;
|
||||
absent) echo "MISSING $name"; missing=1 ;;
|
||||
*) echo "NO HASH $name"; missing=1 ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$missing" ] || { echo "not publishing: fix the assets above" >&2; exit 1; }
|
||||
|
||||
# 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],
|
||||
"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
|
||||
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"
|
||||
@@ -24,6 +24,10 @@
|
||||
<a href="/feedback/">Feedback</a>
|
||||
</nav>
|
||||
<div class="end">
|
||||
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,9 +49,11 @@
|
||||
<a href="https://github.com/saphid/frame-control">GitHub</a>
|
||||
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
|
||||
<a href="/feedback/">Feedback</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
|
||||
<a href="/privacy/">Privacy</a>
|
||||
</div>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve.</p>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
@@ -32,12 +32,14 @@ code { font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; background: rg
|
||||
-webkit-backdrop-filter: saturate(160%) blur(14px); border-bottom: 1px solid var(--line); }
|
||||
.top .wrap { display: flex; align-items: center; gap: 28px; height: 64px; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; color: var(--bright); font-weight: 700; letter-spacing: 2.2px; font-size: 14px; text-transform: uppercase; }
|
||||
.brand { white-space: nowrap; }
|
||||
.brand img { width: 30px; height: 30px; }
|
||||
.top nav { display: flex; gap: 22px; margin-left: 8px; }
|
||||
.top nav a { color: var(--muted); font-size: 14.5px; font-weight: 500; }
|
||||
.top nav a:hover, .top nav a[aria-current] { color: var(--bright); }
|
||||
.top .end { margin-left: auto; display: flex; gap: 10px; align-items: center; }
|
||||
@media (max-width: 880px) { .top nav { display: none; } }
|
||||
@media (max-width: 480px) { .top .end .ghost { display: none; } }
|
||||
|
||||
/* ---- buttons ---- */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 9px; height: 46px; padding: 0 22px; border-radius: 10px;
|
||||
@@ -46,6 +48,8 @@ code { font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; background: rg
|
||||
.btn:hover { background: rgba(103, 112, 123, .4); color: var(--bright); }
|
||||
.btn.primary { background: var(--action); box-shadow: 0 8px 28px rgba(26, 159, 255, .28); }
|
||||
.btn.primary:hover { background: var(--action-hi); transform: translateY(-1px); }
|
||||
/* The background shorthand resets this; without it the gradient repeats under the transparent border. */
|
||||
.btn.primary, .btn.primary:hover { background-origin: border-box; }
|
||||
.btn.ghost { background: transparent; border-color: var(--line); }
|
||||
.btn.ghost:hover { border-color: rgba(143,152,160,.4); background: rgba(255,255,255,.03); }
|
||||
.btn.small { height: 36px; padding: 0 14px; font-size: 14px; border-radius: 8px; }
|
||||
@@ -63,6 +67,8 @@ code { font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; background: rg
|
||||
.eyebrow { display: inline-block; max-width: 100%; padding: 6px 14px; border-radius: 999px; font-size: 13.5px;
|
||||
color: var(--link); background: rgba(26,159,255,.1); border: 1px solid rgba(102,192,244,.22); margin-bottom: 26px; }
|
||||
.eyebrow b { color: var(--bright); font-weight: 600; }
|
||||
.eyebrow .plats { white-space: nowrap; }
|
||||
@media (max-width: 520px) { .eyebrow { border-radius: 16px; } .eyebrow .sep { display: none; } .eyebrow .plats { display: block; white-space: normal; } }
|
||||
.hero h1 { max-width: 880px; margin: 0 auto; }
|
||||
.hero h1 span { background: linear-gradient(90deg, #66c0f4, #1a9fff 45%, #8a6cff); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
.lede { max-width: 680px; margin: 22px auto 0; font-size: 19px; color: var(--text); }
|
||||
@@ -135,6 +141,8 @@ section.alt { background: var(--bg-2); border-block: 1px solid var(--line); }
|
||||
.faq details[open] summary::after { transform: rotate(45deg); }
|
||||
.faq details > div { padding: 0 0 20px; color: var(--muted); }
|
||||
.faq details > div p + p { margin-top: 10px; }
|
||||
.faq.notes section { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 18px 22px 20px; color: var(--muted); }
|
||||
.faq.notes h2 { font-size: 16px; font-weight: 600; letter-spacing: 0; color: var(--bright); margin: 0 0 10px; }
|
||||
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.split .card { padding: 36px; }
|
||||
@@ -145,7 +153,7 @@ section.alt { background: var(--bg-2); border-block: 1px solid var(--line); }
|
||||
/* ---- footer ---- */
|
||||
footer { border-top: 1px solid var(--line); padding: 48px 0 56px; color: var(--dim); font-size: 14px; }
|
||||
footer .wrap { display: flex; flex-wrap: wrap; gap: 24px 48px; justify-content: space-between; }
|
||||
footer .cols { display: flex; gap: 28px; flex-wrap: wrap; }
|
||||
footer .cols { display: flex; gap: 12px 28px; flex-wrap: wrap; }
|
||||
footer a { color: var(--muted); }
|
||||
footer .legal { flex-basis: 100%; font-size: 13px; }
|
||||
|
||||
@@ -160,6 +168,7 @@ aside.panel p { color: var(--muted); font-size: 15px; }
|
||||
aside.panel p + h3 { margin-top: 26px; }
|
||||
.field { display: grid; gap: 8px; margin-bottom: 22px; }
|
||||
.field > label, .field > legend { color: var(--bright); font-weight: 600; font-size: 14.5px; padding: 0; }
|
||||
.field > legend { margin-bottom: 10px; } /* fieldset grids ignore gap for the legend */
|
||||
.field small { color: var(--muted); font-size: 13px; font-weight: 400; }
|
||||
.row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
@media (max-width: 640px) { .row3 { grid-template-columns: 1fr; } }
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
<a href="/feedback/" aria-current="page">Feedback</a>
|
||||
</nav>
|
||||
<div class="end">
|
||||
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,9 +132,11 @@
|
||||
<a href="https://github.com/saphid/frame-control">GitHub</a>
|
||||
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
|
||||
<a href="/feedback/">Feedback</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
|
||||
<a href="/privacy/">Privacy</a>
|
||||
</div>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve.</p>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 94 KiB |
@@ -41,7 +41,7 @@
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="wrap">
|
||||
<span class="eyebrow"><b>Free and open source</b> · macOS · Windows · Linux · iPhone</span>
|
||||
<span class="eyebrow"><b>Free and open source</b><span class="sep"> · </span><span class="plats">macOS · Windows · Linux · iPhone</span></span>
|
||||
<h1>Your Steam Frame, <span>managed from your desk.</span></h1>
|
||||
<p class="lede">See what the headset sees, install games and Android apps, move files and text across, and keep an eye on battery and status. All over SSH, with nothing to install on the Frame.</p>
|
||||
<div class="cta">
|
||||
@@ -163,7 +163,7 @@
|
||||
<h2>The fiddly bits, done for you</h2>
|
||||
<p>Open an SSH session or SFTP, start Steam Link or remote desktop, change the volume, or put the headset to sleep, all from one tab.</p>
|
||||
</div>
|
||||
<img src="/img/tools.jpg" width="1600" height="1000" loading="lazy" alt="The Tools tab: SSH, SFTP, Steam Link, remote desktop and power controls.">
|
||||
<img src="/img/tools.jpg" width="1600" height="600" loading="lazy" alt="The Tools tab: SSH, SFTP, Steam Link, remote desktop and power controls.">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const SITE = {
|
||||
repo: "saphid/frame-control",
|
||||
// Ko-fi page name, the part after ko-fi.com/. Donate buttons stay hidden while it's empty.
|
||||
kofi: "",
|
||||
kofi: "alexsouthwell",
|
||||
};
|
||||
|
||||
const RELEASE = `https://github.com/${SITE.repo}/releases/latest/download/`;
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
<a href="/feedback/">Feedback</a>
|
||||
</nav>
|
||||
<div class="end">
|
||||
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,10 +40,10 @@
|
||||
<h1>Privacy</h1>
|
||||
<p>Short version: the app collects nothing, and the website keeps only what you choose to send.</p>
|
||||
</div>
|
||||
<div class="faq">
|
||||
<details open><summary>The app</summary><div><p>Frame Control talks only to your headset (over SSH on your network), to GitHub for releases, and to Steam and F-Droid for game and app listings. It has no analytics and no accounts.</p></div></details>
|
||||
<details open><summary>The feedback form</summary><div><p>What you type becomes a public GitHub issue on <a href="https://github.com/saphid/frame-control/issues">saphid/frame-control</a>. To stop abuse, the form keeps a one-way hash of your IP address for about an hour to count submissions. The address itself isn't stored or published.</p></div></details>
|
||||
<details open><summary>This website</summary><div><p>Hosted on Cloudflare Pages. No cookies, no analytics, no trackers. The download section asks GitHub for the latest version number. Donations go through Ko-fi, under Ko-fi's own privacy policy.</p></div></details>
|
||||
<div class="faq notes">
|
||||
<section><h2>The app</h2><p>Frame Control talks only to your headset (over SSH on your network), to GitHub for releases, and to Steam and F-Droid for game and app listings. It has no analytics and no accounts.</p></section>
|
||||
<section><h2>The feedback form</h2><p>What you type becomes a public GitHub issue on <a href="https://github.com/saphid/frame-control/issues">saphid/frame-control</a>. To stop abuse, the form keeps a one-way hash of your IP address for about an hour to count submissions. The address itself isn't stored or published.</p></section>
|
||||
<section><h2>This website</h2><p>Hosted on Cloudflare Pages. No cookies, no analytics, no trackers. The download section asks GitHub for the latest version number. Donations go through Ko-fi, under Ko-fi's own privacy policy.</p></section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -51,9 +55,11 @@
|
||||
<a href="https://github.com/saphid/frame-control">GitHub</a>
|
||||
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
|
||||
<a href="/feedback/">Feedback</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
|
||||
<a href="/privacy/">Privacy</a>
|
||||
</div>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve.</p>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Run the actual page script with a tiny DOM/fetch fixture; no browser dependency.
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const assert = require('node:assert/strict');
|
||||
const elements = new Map();
|
||||
const events = new Map();
|
||||
const requests = [];
|
||||
const element = id => {
|
||||
if (!elements.has(id)) elements.set(id, {value:'', checked:false, disabled:false, textContent:'',
|
||||
addEventListener(){}, reset(){}});
|
||||
return elements.get(id);
|
||||
};
|
||||
const context = {
|
||||
document:{getElementById:element}, location:{hash:''}, URLSearchParams,
|
||||
window:{addEventListener:(name, fn) => events.set(name, fn)},
|
||||
fetch:(path, options) => new Promise(resolve => requests.push({path, options, resolve})),
|
||||
};
|
||||
const html = fs.readFileSync(process.argv[2], 'utf8');
|
||||
vm.runInNewContext(html.match(/<script>([\s\S]*?)<\/script>/)[1].replace('__FRAME_KEY__', '"test"'), context);
|
||||
const answer = (index, data) => requests[index].resolve({ok:true,json:async () => data});
|
||||
(async () => {
|
||||
context.location.hash = '#confirm=first';
|
||||
const first = events.get('hashchange')();
|
||||
context.location.hash = '#confirm=second';
|
||||
const second = events.get('hashchange')();
|
||||
answer(1, {action:{name:'second'},approved:false});
|
||||
await second;
|
||||
answer(0, {action:{name:'first'},approved:false});
|
||||
await first;
|
||||
assert.match(element('action').textContent, /second/);
|
||||
assert.doesNotMatch(element('action').textContent, /first/);
|
||||
const approved = element('approve').onclick();
|
||||
assert.equal(JSON.parse(requests[2].options.body).confirmation, 'second');
|
||||
context.location.hash = '#confirm=third';
|
||||
const third = events.get('hashchange')();
|
||||
answer(3, {action:{name:'third'},approved:false});
|
||||
await third;
|
||||
answer(2, {message:'Approved for one use'});
|
||||
await approved;
|
||||
assert.equal(element('approval-status').textContent, '');
|
||||
assert.match(element('action').textContent, /third/);
|
||||
console.log('Approval navigation races: pass');
|
||||
})().catch(error => { console.error(error); process.exitCode=1; });
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Plumbing for the end-to-end tests against the fake Frame (tests/fakeframe).
|
||||
|
||||
scripts/e2e.sh runs these inside the compose `host` container, where
|
||||
`ssh frame` reaches the fake Frame and FAKEFRAME_CTL is its control port.
|
||||
Each test starts from `fakeframe-ctl reset` and drives the real ui/server.py
|
||||
(started once, on a free port) over HTTP, then checks the fake's state.
|
||||
Without FRAME_E2E=1 every test here is skipped.
|
||||
"""
|
||||
import atexit
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import urllib.request
|
||||
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'
|
||||
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
|
||||
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
|
||||
HOME = '/home/steamos'
|
||||
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)')
|
||||
|
||||
|
||||
# ---- the fake Frame's control port --------------------------------------------
|
||||
|
||||
def _ctl_request(path, body=None, timeout=60):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(CTL + path, data=data, headers={'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def ctl(*args):
|
||||
out = _ctl_request('/ctl', {'args': list(args)})
|
||||
if 'error' in out:
|
||||
raise AssertionError(f'fakeframe-ctl {" ".join(args)}: {out["error"]}')
|
||||
return out
|
||||
|
||||
|
||||
def state():
|
||||
return _ctl_request('/state')
|
||||
|
||||
|
||||
def calls(tool=None):
|
||||
return _ctl_request('/calls' + (f'?{tool}' if tool else ''))
|
||||
|
||||
|
||||
def wait_for(check, timeout=30, what='a condition', every=0.3):
|
||||
"""Poll check() until it returns something truthy; returns that."""
|
||||
deadline = time.monotonic() + timeout
|
||||
last = None
|
||||
while time.monotonic() < deadline:
|
||||
last = check()
|
||||
if last:
|
||||
return last
|
||||
time.sleep(every)
|
||||
raise AssertionError(f'timed out after {timeout}s waiting for {what} (last: {last!r})')
|
||||
|
||||
|
||||
def reset():
|
||||
ctl('reset')
|
||||
wait_for(lambda: _ctl_request('/ping')['ok'], 30, 'the fake Frame to come back after reset')
|
||||
|
||||
|
||||
def ssh(cmd, check=True, timeout=30):
|
||||
"""Run cmd on the fake Frame through the `frame` alias, as Frame Control does."""
|
||||
r = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', 'frame', cmd],
|
||||
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=timeout)
|
||||
if check and r.returncode != 0:
|
||||
raise AssertionError(f'ssh frame {cmd!r} exited {r.returncode}: {r.stderr.strip()}')
|
||||
return r.stdout
|
||||
|
||||
|
||||
def exists(path):
|
||||
return ssh(f'test -e {path} && echo yes || echo no').strip() == 'yes'
|
||||
|
||||
|
||||
# ---- the real server ----------------------------------------------------------
|
||||
|
||||
class Server:
|
||||
proc = None
|
||||
port = None
|
||||
|
||||
@classmethod
|
||||
def start(cls):
|
||||
if cls.proc and cls.proc.poll() is None:
|
||||
return
|
||||
with socket.socket() as s:
|
||||
s.bind(('127.0.0.1', 0))
|
||||
cls.port = s.getsockname()[1]
|
||||
env = dict(os.environ, FRAME_CONTROL_LOCAL_LINKS='1')
|
||||
log = open(SERVER_LOG, 'ab')
|
||||
cls.proc = subprocess.Popen([sys.executable, str(ROOT / 'ui' / 'server.py'), '--port', str(cls.port)],
|
||||
cwd=str(ROOT), env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=log)
|
||||
log.close()
|
||||
atexit.register(cls.stop)
|
||||
wait_for(lambda: cls._up(), 20, 'ui/server.py to listen')
|
||||
|
||||
@classmethod
|
||||
def _up(cls):
|
||||
try:
|
||||
return api('GET', '/api/host')[0] == 200
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def stop(cls):
|
||||
if cls.proc and cls.proc.poll() is None:
|
||||
cls.proc.terminate()
|
||||
try:
|
||||
cls.proc.wait(10)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.proc.kill()
|
||||
|
||||
|
||||
def api(method, path, body=None, raw=None, headers=None, timeout=120):
|
||||
"""One request to the server with the headers its guards want. -> (status, JSON or bytes, headers)."""
|
||||
conn = http.client.HTTPConnection('127.0.0.1', Server.port, timeout=timeout)
|
||||
try:
|
||||
hdrs = {'X-Frame-UI': '1', **(headers or {})} # Host is 127.0.0.1:<port>, which it accepts
|
||||
data = raw if raw is not None else (json.dumps(body).encode() if body is not None else None)
|
||||
if data is not None and 'Content-Type' not in hdrs:
|
||||
hdrs['Content-Type'] = 'application/json'
|
||||
conn.request(method, path, body=data, headers=hdrs)
|
||||
r = conn.getresponse()
|
||||
payload = r.read()
|
||||
if r.getheader('Content-Type', '').startswith('application/json'):
|
||||
payload = json.loads(payload)
|
||||
return r.status, payload, dict(r.getheaders())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def ok(method, path, body=None, **kw):
|
||||
status, out, _ = api(method, path, body, **kw)
|
||||
if status != 200:
|
||||
raise AssertionError(f'{method} {path} -> {status}: {out}')
|
||||
return out
|
||||
|
||||
|
||||
def finished(started, timeout=60):
|
||||
"""Wait for a background job (server.start_job's {"job": id}); returns its final state."""
|
||||
return wait_for(lambda: (lambda j: j['done'] and j)(ok('GET', f"/api/job?id={started['job']}")),
|
||||
timeout, f"job {started['job']}")
|
||||
|
||||
|
||||
def upload(path, mode, name=None):
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
return api('POST', '/api/upload', raw=data, headers={
|
||||
'X-Filename': name or os.path.basename(path), 'X-Mode': mode, 'Content-Type': 'application/octet-stream'})
|
||||
|
||||
|
||||
def wait_title_job(token, timeout=180):
|
||||
def done():
|
||||
job = ok('GET', f'/api/titles/job?token={token}')
|
||||
return job if job['done'] else None
|
||||
return wait_for(done, timeout, f'title install job {token}', every=0.5)
|
||||
|
||||
|
||||
def install_title(path, **options):
|
||||
"""Upload (or, for a folder, inspect by path) and install; returns (plan, finished job)."""
|
||||
if os.path.isdir(path):
|
||||
staged = ok('POST', '/api/titles', {'action': 'inspect', 'path': path})
|
||||
else:
|
||||
status, staged, _ = upload(path, 'title')
|
||||
if status != 200:
|
||||
raise AssertionError(f'upload -> {status}: {staged}')
|
||||
started = ok('POST', '/api/titles', {'action': 'install', 'token': staged['token'], **options})
|
||||
return staged['plan'], wait_title_job(started['job'])
|
||||
|
||||
|
||||
def launches(kind=None):
|
||||
return [r for r in state()['launches'] if kind is None or r['kind'] == kind]
|
||||
|
||||
|
||||
class FrameTestCase(unittest.TestCase):
|
||||
"""Starts the server once and the fake Frame afresh for every test."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
Server.start()
|
||||
|
||||
def setUp(self):
|
||||
reset()
|
||||
self.tmp = tempfile.mkdtemp(prefix='fakeframe-e2e-')
|
||||
self.addCleanup(subprocess.run, ['rm', '-rf', self.tmp])
|
||||
|
||||
def path(self, *parts):
|
||||
return os.path.join(self.tmp, *parts)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Real HTTP/MCP adapter against fake-Frame SSH; no model service needed."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import harness
|
||||
from harness import api, ok, finished, ssh
|
||||
|
||||
sys.path.insert(0, str(harness.ROOT / 'ui'))
|
||||
import frame_mcp
|
||||
|
||||
|
||||
class Agents(harness.FrameTestCase):
|
||||
def client(self):
|
||||
return frame_mcp.Client('http://127.0.0.1:%d' % harness.Server.port)
|
||||
|
||||
def call(self, name, args):
|
||||
return json.loads(frame_mcp.call(self.client(), name, args)['content'][0]['text'])
|
||||
|
||||
def approve(self, proposal):
|
||||
ok('POST', '/api/agent/approval', {'confirmation': proposal['confirmation'], 'accept': True})
|
||||
return proposal['confirmation']
|
||||
|
||||
def test_status_and_approved_install_job(self):
|
||||
self.assertIn('battery', self.call('status', {}))
|
||||
proposal = self.call('install', {'id': 'org.example.AgentTest'})
|
||||
before = api('POST', '/api/agent/call', {'name': 'install', 'arguments': {'id': 'org.example.AgentTest'}, 'confirmation': proposal['confirmation']})
|
||||
self.assertEqual(before[0], 400)
|
||||
token = self.approve(proposal)
|
||||
job = self.call('install', {'id': 'org.example.AgentTest', 'confirmation': token})
|
||||
self.assertFalse(finished(job).get('error'))
|
||||
self.assertIn('org.example.AgentTest', ssh('flatpak list --app --columns=application'))
|
||||
denied = api('POST', '/api/agent/call', {'name': 'install', 'arguments': {'id': 'org.example.AgentTest'}, 'confirmation': token})
|
||||
self.assertEqual(denied[0], 400)
|
||||
|
||||
def test_approved_file_and_text(self):
|
||||
path = Path(self.path('agent-note.txt'))
|
||||
path.write_text('MCP file content\n')
|
||||
args = {'path': str(path)}
|
||||
token = self.approve(self.call('send_file', args))
|
||||
self.call('send_file', {**args, 'confirmation': token})
|
||||
self.assertEqual(ssh('cat ~/Downloads/agent-note.txt'), path.read_text())
|
||||
args = {'text': 'MCP clipboard text'}
|
||||
token = self.approve(self.call('send_text', args))
|
||||
self.call('send_text', {**args, 'confirmation': token})
|
||||
self.assertEqual(harness.state()['clipboard'], ['MCP clipboard text'])
|
||||
@@ -0,0 +1,76 @@
|
||||
"""An APK as its own Lepton instance (ui/frame_android.py): the shortcut goes in
|
||||
through the fake Steam client's DevTools port, the launch through `steam`,
|
||||
Lepton's launcher and podman."""
|
||||
import unittest
|
||||
|
||||
import harness
|
||||
from harness import HOME, exists, ok, state, upload, wait_for
|
||||
from test_frame_apk import apk, manifest, resources
|
||||
|
||||
harness.require()
|
||||
|
||||
PKG = 'com.example.fakeframe'
|
||||
APP_DIR = f'{HOME}/Applications/Android/{PKG}'
|
||||
|
||||
|
||||
class AndroidApps(harness.FrameTestCase):
|
||||
def build_apk(self, min_sdk=26):
|
||||
arsc = resources({(1, '', 0): {0: 1, 1: 2}, (2, '', 640): {0: 4}})
|
||||
data = apk({'AndroidManifest.xml': manifest(PKG, 0x7f010000, 0x7f010001, min_sdk),
|
||||
'resources.arsc': arsc, 'res/icon_hi.png': b'\x89PNG fake icon',
|
||||
'lib/arm64-v8a/libgame.so': b''})
|
||||
path = self.path('fake-app.apk')
|
||||
with open(path, 'wb') as f:
|
||||
f.write(data)
|
||||
return path
|
||||
|
||||
def test_install_launch_stop_remove(self):
|
||||
status, out, _ = upload(self.build_apk(), 'apk')
|
||||
self.assertEqual(status, 200, out)
|
||||
meta = out['app']
|
||||
self.assertEqual((meta['package'], meta['label'], meta['version']), (PKG, 'App label', '2.1'))
|
||||
shortcut = next(s for s in state()['steam']['shortcuts'] if s['appid'] == meta['shortcut'])
|
||||
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.assertTrue(exists(f'{APP_DIR}/{f}'), f)
|
||||
self.assertEqual(meta['game_id'], (meta['shortcut'] << 32) | 0x02000000)
|
||||
|
||||
ok('POST', '/api/android', {'action': 'launch', 'package': PKG})
|
||||
ctr = f"lepton-steamlaunch-{meta['instance']}"
|
||||
running = wait_for(lambda: state()['lepton'].get(ctr), 20, 'the Lepton instance')
|
||||
self.assertTrue(running['flatscreen'])
|
||||
self.assertGreaterEqual(running['port'], 5556)
|
||||
call = next(c for c in harness.calls('lepton') if 'env' in c)
|
||||
self.assertEqual(call['env']['SteamAppId'], str(meta['instance']))
|
||||
self.assertTrue(call['env']['STEAM_COMPAT_DATA_PATH'].startswith(f'{HOME}/.local/share/Steam/'))
|
||||
apps = ok('GET', '/api/android')['apps']
|
||||
self.assertEqual([(a['package'], a['running']) for a in apps], [(PKG, True)])
|
||||
|
||||
ok('POST', '/api/android', {'action': 'stop', 'package': PKG})
|
||||
wait_for(lambda: ctr not in state()['lepton'], 15, 'the instance to stop')
|
||||
ok('POST', '/api/android', {'action': 'remove', 'package': PKG})
|
||||
self.assertEqual(state()['steam']['shortcuts'], [])
|
||||
self.assertFalse(exists(APP_DIR))
|
||||
self.assertFalse(exists(f"{HOME}/.local/share/Steam/steamapps/compatdata/{meta['instance']}"))
|
||||
|
||||
def test_reinstall_reuses_the_shortcut(self):
|
||||
first = upload(self.build_apk(), 'apk')[1]['app']
|
||||
second = upload(self.build_apk(), 'apk')[1]['app']
|
||||
self.assertEqual(first['shortcut'], second['shortcut'])
|
||||
self.assertEqual(len(state()['steam']['shortcuts']), 1)
|
||||
|
||||
def test_launch_without_lepton_installed_fails_on_the_frame(self):
|
||||
meta = upload(self.build_apk(), 'apk')[1]['app']
|
||||
harness.ctl('runtime', 'lepton', 'missing')
|
||||
ok('POST', '/api/android', {'action': 'launch', 'package': PKG})
|
||||
run = wait_for(lambda: next((r for r in state()['launches'] if r.get('appid') == meta['shortcut']
|
||||
and r.get('exit') is not None), None), 20, 'launch.sh to exit')
|
||||
self.assertEqual(run['exit'], 1) # launch.sh: "Lepton isn't installed (Steam app 3056000)"
|
||||
self.assertEqual(state()['lepton'], {})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Status, Steam library, volume, clipboard, Flatpaks and the desktop capture,
|
||||
through the server against the fake Frame."""
|
||||
import unittest
|
||||
|
||||
import harness
|
||||
from harness import api, ctl, finished, launches, ok, state, wait_for
|
||||
|
||||
harness.require()
|
||||
|
||||
|
||||
class Device(harness.FrameTestCase):
|
||||
def test_status(self):
|
||||
s = ok('GET', '/api/status')
|
||||
self.assertEqual(s['hostname'], 'frame')
|
||||
self.assertEqual(s['os'], {'version': '0.3.0', 'build': '20260922.6101926', 'variant': 'vr'})
|
||||
b = s['battery']
|
||||
self.assertEqual((b['percent'], b['status'], b['health']), (76, 'Charging', 'Good'))
|
||||
self.assertAlmostEqual(b['watts'], 9.62, places=1)
|
||||
self.assertAlmostEqual(b['tempC'], 31.2, places=3)
|
||||
self.assertEqual(s['power'], {'type': 'C PD [PD_PPS]', 'watts': 20.0})
|
||||
self.assertEqual(s['temp'], 41.5)
|
||||
self.assertEqual(s['wifi'], {'ssid': 'Fake:Frame Wi-Fi', 'signal': 72})
|
||||
self.assertEqual(s['volume'], {'level': 0.4, 'muted': False})
|
||||
self.assertEqual(s['services'], {'steamvr': True, 'desktop': True, 'lepton': False, 'rdp': False})
|
||||
# Runtimes and Lepton are Steam "apps" too; the status hides them.
|
||||
self.assertEqual([g['name'] for g in s['games']], ['Beat Saber'])
|
||||
self.assertIsNotNone(s['disk']['home'])
|
||||
|
||||
ctl('battery', 'capacity=15', 'status=Discharging', 'current_now=-900000')
|
||||
b = ok('GET', '/api/status')['battery']
|
||||
self.assertEqual((b['percent'], b['status']), (15, 'Discharging'))
|
||||
self.assertLess(b['watts'], 0)
|
||||
|
||||
def test_volume_and_mute(self):
|
||||
ok('POST', '/api/volume', {'level': 0.55, 'muted': True})
|
||||
self.assertEqual(state()['volume'], {'level': 0.55, 'muted': True})
|
||||
self.assertEqual(ok('GET', '/api/status')['volume'], {'level': 0.55, 'muted': True})
|
||||
status, out, _ = api('POST', '/api/volume', {'level': 2})
|
||||
self.assertEqual(status, 400, out)
|
||||
|
||||
def test_clipboard_goes_to_klipper(self):
|
||||
text = 'héllo from the e2e tests\nline two, with a trailing newline\n'
|
||||
out = ok('POST', '/api/clipboard', {'text': text})
|
||||
# ${#text} counts bytes or characters depending on the session's locale.
|
||||
self.assertRegex(out['message'], r'^copied via Klipper \(\d+ chars\)$')
|
||||
self.assertEqual(state()['clipboard'], [text])
|
||||
|
||||
def test_flatpak_install_and_remove(self):
|
||||
# Installs run as background jobs (server.start_job); uninstall answers at once.
|
||||
job = finished(ok('POST', '/api/flatpak', {'id': 'org.videolan.VLC', 'action': 'install'}))
|
||||
self.assertIsNone(job['error'], job)
|
||||
self.assertEqual([f['id'] for f in ok('GET', '/api/status')['flatpaks']], ['org.videolan.VLC'])
|
||||
ok('POST', '/api/flatpak', {'id': 'org.videolan.VLC', 'action': 'uninstall'})
|
||||
self.assertEqual(state()['flatpaks'], [])
|
||||
job = finished(ok('POST', '/api/flatpak', {'id': 'org.example.missing', 'action': 'install'}))
|
||||
self.assertIn('Nothing matches org.example.missing', job['error'])
|
||||
|
||||
def test_desktop_capture(self):
|
||||
status, png, headers = api('GET', '/api/screenshot')
|
||||
self.assertEqual(status, 200, png)
|
||||
self.assertTrue(png.startswith(b'\x89PNG\r\n\x1a\n'))
|
||||
self.assertEqual(headers.get('X-Capture-Source'), 'gamescope')
|
||||
|
||||
def test_steam_library_and_installs(self):
|
||||
owned = ok('GET', '/api/steam/owned')
|
||||
self.assertEqual(owned['country'], 'AU')
|
||||
games = {g['id']: g for g in owned['games']}
|
||||
self.assertEqual(set(games), {2379780, 274190, 620980})
|
||||
self.assertEqual((games[2379780]['frame'], games[620980]['installed']), (3, True))
|
||||
# Balatro queues at once; Broforce stops at the options dialog, which
|
||||
# frame_steam.py accepts with ContinueInstall().
|
||||
for appid, name in ((2379780, 'Balatro'), (274190, 'Broforce')):
|
||||
out = ok('POST', '/api/steam', {'appid': appid, 'action': 'install'})
|
||||
self.assertEqual(out, {'state': 'downloading', 'message': f'{name} is queued to download on the Frame'})
|
||||
self.assertEqual(ok('GET', '/api/steam/owned')['download']['appid'], 274190)
|
||||
|
||||
def test_launch_and_store_page(self):
|
||||
ok('POST', '/api/launch', {'appid': 620980})
|
||||
run = wait_for(lambda: launches('rungameid'), 10, 'the launch to reach Steam')[-1]
|
||||
self.assertEqual((run['appid'], run['started']), (620980, True))
|
||||
ok('POST', '/api/steam', {'appid': 1145360, 'action': 'store'})
|
||||
wait_for(lambda: state()['steam']['pages'], 10, 'the store page')
|
||||
self.assertEqual(state()['steam']['pages'][0]['title'], 'Hades on Steam')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""What Frame Control does when the headset misbehaves: Steam not running,
|
||||
the headset asleep or with sshd off, and a full disk."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
import harness
|
||||
import tiny_programs
|
||||
from harness import HOME, ROOT, api, ctl, exists, install_title, ok, state, wait_for
|
||||
|
||||
harness.require()
|
||||
|
||||
|
||||
class Faults(harness.FrameTestCase):
|
||||
def exe(self):
|
||||
return tiny_programs.write(self.tmp, 'exe')
|
||||
|
||||
def test_steam_not_running_then_install_again(self):
|
||||
ctl('steam', 'off')
|
||||
_, job = install_title(self.exe())
|
||||
# steam-client-create-shortcut's own words, passed on by frame_titles.
|
||||
self.assertEqual(job['error'], "Uploaded, but Steam didn't register it: The Steam client is not running. "
|
||||
"Registration did not complete. With Steam running on the Frame, "
|
||||
"install it again.")
|
||||
self.assertTrue(exists(f'{HOME}/devkit-game/fc_smoke_exe/fc-smoke-exe.exe')) # the files stay
|
||||
self.assertEqual(state()['devkit_games'], {})
|
||||
status, out, _ = api('GET', '/api/steam/owned')
|
||||
self.assertEqual(status, 502)
|
||||
self.assertIn("Steam's UI isn't answering", out['error'])
|
||||
|
||||
ctl('steam', 'on')
|
||||
wait_for(lambda: harness._ctl_request('/ping')['ok'], 20, 'Steam to start')
|
||||
_, job = install_title(self.exe())
|
||||
self.assertIsNone(job['error'], job)
|
||||
self.assertIn('fc_smoke_exe', state()['devkit_games'])
|
||||
|
||||
def test_launch_with_steam_stopped(self):
|
||||
_, job = install_title(self.exe())
|
||||
self.assertIsNone(job['error'], job)
|
||||
ctl('steam', 'off')
|
||||
status, out, _ = api('POST', '/api/titles', {'action': 'launch', 'id': 'fc_smoke_exe'})
|
||||
self.assertEqual(status, 502, out)
|
||||
self.assertIn('steam.pid', out['error'])
|
||||
self.assertEqual(harness.launches(), [])
|
||||
|
||||
def test_headset_asleep(self):
|
||||
ok('GET', '/api/status') # a shared connection is up
|
||||
ctl('sleep', 'on')
|
||||
t0 = time.monotonic()
|
||||
status, out, _ = api('GET', '/api/status', timeout=90)
|
||||
took = time.monotonic() - t0
|
||||
self.assertEqual(status, 502, out)
|
||||
self.assertRegex(out['error'], r'[Tt]imed out')
|
||||
self.assertLess(took, 40) # ConnectTimeout, not a hang
|
||||
ctl('sleep', 'off')
|
||||
# The next request after waking gets through again.
|
||||
wait_for(lambda: api('GET', '/api/status', timeout=60)[0] == 200, 60, 'status after waking')
|
||||
|
||||
def test_sshd_stopped(self):
|
||||
ok('GET', '/api/titles') # the server's shared connection is up
|
||||
ctl('sshd', 'off')
|
||||
# Stopping sshd keeps open sessions (Arch's sshd.service kills only the
|
||||
# listener), so the server carries on over its shared connection...
|
||||
self.assertEqual(api('GET', '/api/titles')[0], 200)
|
||||
# ...while anything that connects afresh is refused.
|
||||
out = subprocess.run([sys.executable, str(ROOT / 'ui' / 'frame_titles.py'), 'list'],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(out.returncode, 1)
|
||||
self.assertIn('Connection refused', out.stderr)
|
||||
ctl('sshd', 'on')
|
||||
wait_for(lambda: subprocess.run([sys.executable, str(ROOT / 'ui' / 'frame_titles.py'), 'list'],
|
||||
capture_output=True, timeout=60).returncode == 0, 30, 'sshd to be back')
|
||||
|
||||
def test_disk_full(self):
|
||||
path = self.path('Big Game.zip')
|
||||
with zipfile.ZipFile(path, 'w') as z:
|
||||
z.writestr('Big Game/BigGame.exe', tiny_programs.pe_x86_64())
|
||||
z.writestr('Big Game/data.pak', os.urandom(2 * 1024 * 1024))
|
||||
ctl('disk-full', 'on')
|
||||
_, job = install_title(path)
|
||||
self.assertIn('No space left on device', job['error'] or '', job)
|
||||
# A first install that failed part-way leaves nothing behind.
|
||||
self.assertFalse(exists(f'{HOME}/devkit-game/Big_Game'))
|
||||
self.assertEqual(state()['devkit_games'], {})
|
||||
ctl('disk-full', 'off')
|
||||
_, job = install_title(path)
|
||||
self.assertIsNone(job['error'], job)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Setting up the connection: ui/frame_connect.py against Valve's real
|
||||
steamos-devkit-service on the fake Frame, and its password fallback."""
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
import urllib.request
|
||||
|
||||
import harness
|
||||
from harness import FAKE_HOST, ROOT, ctl, state, wait_for
|
||||
|
||||
harness.require()
|
||||
|
||||
|
||||
class Pairing(harness.FrameTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
ctl('keys', 'none') # a computer the headset doesn't know yet
|
||||
|
||||
def connect(self, askpass=None):
|
||||
"""Start frame_connect.py FAKE_HOST with no terminal, as the app's setup window would."""
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith('SSH_ASKPASS')}
|
||||
if askpass:
|
||||
script = self.path('askpass')
|
||||
with open(script, 'w') as f:
|
||||
f.write(f'#!/bin/sh\necho {askpass}\n')
|
||||
os.chmod(script, stat.S_IRWXU)
|
||||
env.update(SSH_ASKPASS=script, SSH_ASKPASS_REQUIRE='force')
|
||||
proc = subprocess.Popen([sys.executable, str(ROOT / 'ui' / 'frame_connect.py'), FAKE_HOST],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, env=env, start_new_session=True)
|
||||
self.addCleanup(self.stop, proc) # a failed test mustn't leave it pairing into the next one
|
||||
return proc
|
||||
|
||||
@staticmethod
|
||||
def stop(proc):
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL) # frame_connect.py and its ssh children
|
||||
except OSError:
|
||||
pass
|
||||
proc.communicate()
|
||||
|
||||
def finish(self, proc, timeout=120):
|
||||
out, _ = proc.communicate(timeout=timeout)
|
||||
return proc.returncode, out
|
||||
|
||||
def authorized_keys(self):
|
||||
return ctl('authorized-keys')['text']
|
||||
|
||||
def test_service_properties_and_announcement(self):
|
||||
# docs/ssh.md: properties.json answers "login": "steamos" on the Frame.
|
||||
with urllib.request.urlopen(f'http://{FAKE_HOST}:32000/properties.json', timeout=10) as r:
|
||||
props = json.load(r)
|
||||
self.assertEqual(props['login'], 'steamos')
|
||||
self.assertEqual(props['devkit1'], ['devkit-1'])
|
||||
# At start the service announces _steamos-devkit._tcp under the Frame's hostname.
|
||||
ctl('devkit-service', 'off')
|
||||
ctl('devkit-service', 'on')
|
||||
reg = wait_for(lambda: [c for c in harness.calls('resolve1') if c['method'] == 'RegisterService'],
|
||||
15, 'the mDNS registration')
|
||||
self.assertEqual(reg[0]['args'][:3], ['frame', 'frame', '_steamos-devkit._tcp'])
|
||||
self.assertEqual(reg[0]['args'][3], 32000)
|
||||
|
||||
def test_pairing_mode_refusal_then_approval(self):
|
||||
proc = self.connect()
|
||||
# The first /register is refused: "Pair new host" isn't open.
|
||||
wait_for(lambda: any(r['answer'] == 'not in pairing mode' for r in state()['pairing_requests']),
|
||||
30, 'a refused pairing request')
|
||||
ctl('pairing', 'on') # the user opens Settings > Developer > Pair new host
|
||||
rc, out = self.finish(proc)
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn('paired; key login OK', out)
|
||||
answers = [r['answer'] for r in state()['pairing_requests']]
|
||||
self.assertEqual(answers[-1], 'approve')
|
||||
self.assertIn('not in pairing mode', answers)
|
||||
self.assertIn('frame-control@', state()['pairing_requests'][-1]['request'])
|
||||
# The hook turned sshd on and installed the RSA key for steamos.
|
||||
self.assertTrue(harness.calls('steamos-enable-sshd'))
|
||||
keys = self.authorized_keys()
|
||||
self.assertRegex(keys, r'(?m)^ssh-rsa \S+ frame-control@\S+$')
|
||||
self.assertNotIn('900b919520e4cf601998a71eec318fec', keys) # the magic phrase isn't stored
|
||||
|
||||
def test_denied_request_falls_back_to_the_password(self):
|
||||
ctl('pairing', 'on')
|
||||
ctl('answer', 'deny')
|
||||
rc, out = self.finish(self.connect()) # no password to give: the fallback can't finish
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn('devkit pairing failed: the pairing request was denied', out)
|
||||
self.assertIn('falling back to the password', out)
|
||||
self.assertNotIn('ssh-rsa', self.authorized_keys())
|
||||
|
||||
def test_service_down_uses_the_password(self):
|
||||
ctl('devkit-service', 'off')
|
||||
rc, out = self.finish(self.connect(askpass='frame'))
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn('devkit service not reachable on port 32000', out)
|
||||
self.assertIn('key login OK', out)
|
||||
with open(os.path.expanduser('~/.ssh/id_ed25519_frame.pub')) as f:
|
||||
ours = f.read().split()[1]
|
||||
self.assertIn(ours, self.authorized_keys())
|
||||
|
||||
def test_prompt_left_unanswered_times_out(self):
|
||||
# approve-ssh-key waits 30 s for Steam, then says so.
|
||||
ctl('pairing', 'on')
|
||||
ctl('answer', 'timeout')
|
||||
rc, out = self.finish(self.connect(), timeout=150)
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn('timeout - Steam did not respond to the pairing request', out)
|
||||
|
||||
def test_steam_not_running(self):
|
||||
ctl('pairing', 'on')
|
||||
ctl('steam', 'off')
|
||||
rc, out = self.finish(self.connect())
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn('devkit pairing failed: Steam is not running', out)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Sideloaded titles (ui/frame_titles.py) through the server's HTTP API, against
|
||||
Valve's devkit-utils talking to the fake Steam client."""
|
||||
import hashlib
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
import harness
|
||||
import tiny_programs
|
||||
from harness import HOME, ROOT, ctl, exists, install_title, launches, ok, ssh, state, wait_for
|
||||
|
||||
harness.require()
|
||||
|
||||
GAMES = f'{HOME}/devkit-game'
|
||||
# The host container shares the fake Frame's kernel, so a program of this machine's
|
||||
# architecture really runs there. The other one fails to exec, unless QEMU is
|
||||
# registered with binfmt_misc, which runs it emulated.
|
||||
NATIVE = {'aarch64': 'arm64', 'arm64': 'arm64', 'x86_64': 'x86_64'}.get(platform.machine())
|
||||
|
||||
|
||||
class Titles(harness.FrameTestCase):
|
||||
def game_zip(self, name='Cool Game-v1.2-win64.zip', extra=b''):
|
||||
path = self.path(name)
|
||||
with zipfile.ZipFile(path, 'w') as z:
|
||||
z.writestr('Cool Game/CoolGame.exe', tiny_programs.pe_x86_64())
|
||||
z.writestr('Cool Game/CoolGame_Data/level0', b'level data' + extra)
|
||||
return path
|
||||
|
||||
def folder(self, kind, name):
|
||||
os.makedirs(self.path(name))
|
||||
return tiny_programs.write(self.path(name), kind), self.path(name)
|
||||
|
||||
def assert_installed(self, gid, runtime, target):
|
||||
game = state()['devkit_games'][gid]
|
||||
self.assertEqual(game['settings']['compat_tool'], runtime)
|
||||
self.assertEqual(game['argv'], [target])
|
||||
steam = state()['steam']
|
||||
shortcut = next(s for s in steam['shortcuts'] if s['devkit_gameid'] == gid)
|
||||
self.assertEqual(steam['compat_tools'][str(shortcut['appid'])], runtime)
|
||||
self.assertEqual(ssh(f'stat -c %a {GAMES}/{gid}/{target}').strip(), '755')
|
||||
listed = {t['id']: t for t in ok('GET', '/api/titles')['titles']}
|
||||
self.assertEqual(listed[gid]['runtime'], runtime)
|
||||
self.assertTrue(listed[gid]['frame_control'])
|
||||
return shortcut
|
||||
|
||||
def test_zip_with_a_windows_exe(self):
|
||||
plan, job = install_title(self.game_zip())
|
||||
self.assertEqual((plan['name'], plan['id'], plan['target']), ('Cool Game', 'Cool_Game', 'CoolGame.exe'))
|
||||
self.assertEqual(plan['runtime'], 'proton-experimental')
|
||||
self.assertIsNone(job['error'], job)
|
||||
self.assertEqual(job['title']['runtime_label'], 'Proton Experimental')
|
||||
self.assert_installed('Cool_Game', 'proton-experimental', 'CoolGame.exe')
|
||||
game = state()['devkit_games']['Cool_Game']
|
||||
self.assertEqual(game['settings'], {'steam_play': '1', 'steam_play_debug': '0',
|
||||
'steam_play_debug_version': '2019', 'compat_tool': 'proton-experimental'})
|
||||
self.assertTrue(exists(f'{GAMES}/Cool_Game/CoolGame_Data/level0'))
|
||||
self.assertTrue(exists(f'{HOME}/devkit-utils/.frame-control-stamp'))
|
||||
|
||||
def test_folder_with_an_arm64_build_runs_natively(self):
|
||||
_, folder = self.folder('arm64', 'Tiny Arm Game')
|
||||
plan, job = install_title(folder)
|
||||
self.assertEqual(plan['runtime'], 'SteamLinuxRuntime_4-arm64')
|
||||
self.assertIsNone(job['error'], job)
|
||||
self.assert_installed('Tiny_Arm_Game', 'SteamLinuxRuntime_4-arm64', 'fc-smoke-arm64')
|
||||
ok('POST', '/api/titles', {'action': 'launch', 'id': 'Tiny_Arm_Game'})
|
||||
run = launches('devkit')[-1]
|
||||
# No runtime prefix: Steam ran the aarch64 build directly on the Frame.
|
||||
self.assertEqual(run['command'], f'{GAMES}/Tiny_Arm_Game/fc-smoke-arm64')
|
||||
if NATIVE == 'arm64':
|
||||
self.assertIsNotNone(run['pid'], run)
|
||||
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
|
||||
30, 'the arm64 test program to exit')
|
||||
self.assertEqual(done['exit'], 0)
|
||||
|
||||
def test_single_exe_upload_launch_and_remove(self):
|
||||
exe = tiny_programs.write(self.tmp, 'exe')
|
||||
plan, job = install_title(exe)
|
||||
self.assertEqual(plan['id'], 'fc_smoke_exe')
|
||||
self.assertIsNone(job['error'], job)
|
||||
shortcut = self.assert_installed('fc_smoke_exe', 'proton-experimental', 'fc-smoke-exe.exe')
|
||||
|
||||
ok('POST', '/api/titles', {'action': 'launch', 'id': 'fc_smoke_exe'})
|
||||
run = launches('devkit')[-1]
|
||||
self.assertTrue(run['started'])
|
||||
self.assertEqual(run['command'], f'proton waitforexitandrun "{GAMES}/fc_smoke_exe/fc-smoke-exe.exe"')
|
||||
prefix = f"{HOME}/.local/share/Steam/steamapps/compatdata/{shortcut['appid']}"
|
||||
self.assertTrue(exists(prefix))
|
||||
|
||||
ok('POST', '/api/titles', {'action': 'remove', 'id': 'fc_smoke_exe'})
|
||||
after = state()
|
||||
self.assertNotIn('fc_smoke_exe', after['devkit_games'])
|
||||
self.assertEqual(after['steam']['shortcuts'], [])
|
||||
for gone in (f'{GAMES}/fc_smoke_exe', prefix, *(f'{GAMES}/fc_smoke_exe-{k}.json'
|
||||
for k in ('argv', 'env', 'settings', 'framecontrol'))):
|
||||
self.assertFalse(exists(gone), gone)
|
||||
self.assertEqual(ok('GET', '/api/titles')['titles'], [])
|
||||
|
||||
def test_names_steam_would_refuse_are_made_safe(self):
|
||||
# Steam's create-shortcut takes ^[A-Za-z_][A-Za-z0-9_.]+$ only (device, 2026-09-27).
|
||||
exe = self.path('2048-Deluxe.exe')
|
||||
with open(exe, 'wb') as f:
|
||||
f.write(tiny_programs.pe_x86_64())
|
||||
plan, job = install_title(exe)
|
||||
self.assertEqual(plan['id'], '_2048_Deluxe')
|
||||
self.assertIsNone(job['error'], job)
|
||||
self.assert_installed('_2048_Deluxe', 'proton-experimental', '2048-Deluxe.exe')
|
||||
|
||||
def test_x86_64_linux_build_needs_a_runtime_the_frame_lacks(self):
|
||||
_, folder = self.folder('x86_64', 'Tiny PC Game')
|
||||
plan, job = install_title(folder)
|
||||
self.assertEqual(plan['runtime'], 'SteamLinuxRuntime_4')
|
||||
self.assertIsNone(job['error'], job)
|
||||
appid = self.assert_installed('Tiny_PC_Game', 'SteamLinuxRuntime_4', 'fc-smoke-x86_64')['appid']
|
||||
# Steam answers the launch, then doesn't start it (docs/sideloading.md).
|
||||
ok('POST', '/api/titles', {'action': 'launch', 'id': 'Tiny_PC_Game'})
|
||||
run = launches('devkit')[-1]
|
||||
self.assertFalse(run['started'])
|
||||
self.assertEqual(run['message'], f'Tool 4183110 "Steam Linux Runtime 4.0" is found for appID {appid}, '
|
||||
'but is not installed')
|
||||
# The headset smoke test finds this in Steam's logs, where compat_log.txt has
|
||||
# binary bytes in it: only grep -a returns the line (Frame, 2026-09-27).
|
||||
self.assertIn(run['message'], ssh('grep -arshF "but is not installed" ~/.local/share/Steam/logs/'))
|
||||
# With the runtime installed, it starts.
|
||||
ctl('runtime', 'SteamLinuxRuntime_4', 'installed')
|
||||
ok('POST', '/api/titles', {'action': 'launch', 'id': 'Tiny_PC_Game'})
|
||||
run = launches('devkit')[-1]
|
||||
self.assertTrue(run['started'])
|
||||
if NATIVE == 'x86_64':
|
||||
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
|
||||
30, 'the x86-64 test program to exit')
|
||||
self.assertEqual(done['exit'], 0)
|
||||
|
||||
def test_reinstall_with_another_runtime_keeps_one_shortcut(self):
|
||||
zip_path = self.game_zip()
|
||||
_, first = install_title(zip_path)
|
||||
self.assertIsNone(first['error'], first)
|
||||
appid = state()['devkit_games']['Cool_Game']['appid']
|
||||
_, second = install_title(zip_path, runtime='proton-stable')
|
||||
self.assertIsNone(second['error'], second)
|
||||
self.assert_installed('Cool_Game', 'proton-stable', 'CoolGame.exe')
|
||||
steam = state()['steam']
|
||||
self.assertEqual([s['appid'] for s in steam['shortcuts']], [appid])
|
||||
meta = json.loads(ssh(f'cat {GAMES}/Cool_Game-framecontrol.json'))
|
||||
self.assertEqual(meta['runtime'], 'proton-stable')
|
||||
|
||||
def test_install_link_with_a_local_manifest(self):
|
||||
# frame-control://install?manifest=... as a website would link it, served from
|
||||
# this computer (FRAME_CONTROL_LOCAL_LINKS=1 lets http://127.0.0.1 through).
|
||||
site = self.path('site')
|
||||
os.makedirs(site)
|
||||
with open(self.game_zip('linkgame-win64.zip'), 'rb') as f:
|
||||
data = f.read()
|
||||
with open(os.path.join(site, 'linkgame-win64.zip'), 'wb') as f:
|
||||
f.write(data)
|
||||
class Files(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args, directory=site)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
httpd = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Files)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
self.addCleanup(httpd.shutdown)
|
||||
base = f'http://127.0.0.1:{httpd.server_address[1]}'
|
||||
with open(os.path.join(site, 'manifest.json'), 'w') as f:
|
||||
json.dump({'schema': 'framedrop.install/v1', 'name': 'Link Game',
|
||||
'files': [{'url': f'{base}/linkgame-win64.zip', 'sha256': hashlib.sha256(data).hexdigest(),
|
||||
'size': len(data), 'exe': 'Cool Game/CoolGame.exe'}]}, f)
|
||||
|
||||
check = ok('POST', '/api/webinstall/check', {'manifest': f'{base}/manifest.json'})
|
||||
self.assertEqual((check['name'], check['kind'], check['size']), ('Link Game', 'title', len(data)))
|
||||
job_id = ok('POST', '/api/webinstall/start', {'id': check['id']})['job']
|
||||
job = wait_for(lambda: (lambda j: j if j['phase'] in ('done', 'error') else None)(
|
||||
ok('GET', f'/api/webinstall/job?id={job_id}')), 120, 'the link install')
|
||||
self.assertEqual(job['phase'], 'done', job)
|
||||
self.assert_installed('Link_Game', 'proton-experimental', 'CoolGame.exe')
|
||||
|
||||
def test_command_line_lists_what_the_app_installed(self):
|
||||
install_title(self.game_zip())
|
||||
out = subprocess.run([sys.executable, str(ROOT / 'ui' / 'frame_titles.py'), 'list'],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
self.assertEqual([t['id'] for t in json.loads(out.stdout)], ['Cool_Game'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,42 @@
|
||||
# The fake Steam Frame: Arch Linux (SteamOS's base) with sshd, rsync, python3,
|
||||
# Valve's steamos-devkit-service and hooks, a fake Steam client and stubs for
|
||||
# the Frame-only commands Frame Control runs. See docs/testing.md.
|
||||
#
|
||||
# BASE: archlinux:base on x86_64; on arm64, Valve's Holo Core aarch64 preview
|
||||
# (registry.gitlab.steamos.cloud/holo/holo-core-aarch64-preview/base-devel), the
|
||||
# Arch Linux ARM64 port the Frame's SteamOS is built on (docs/recovery-and-images.md).
|
||||
# scripts/e2e.sh picks one from `uname -m`.
|
||||
ARG BASE=archlinux:base
|
||||
FROM ${BASE}
|
||||
|
||||
RUN (pacman-key --init && pacman-key --populate) >/dev/null 2>&1; \
|
||||
pacman -Syu --noconfirm --needed openssh rsync python nodejs curl iproute2 procps-ng util-linux \
|
||||
&& pacman -Scc --noconfirm
|
||||
|
||||
# The Frame's user is steamos (docs/ssh.md). Its password stands in for the
|
||||
# Developer Mode password.
|
||||
RUN useradd -m -u 1000 -s /bin/bash steamos \
|
||||
&& echo 'steamos:frame' | chpasswd \
|
||||
&& ssh-keygen -A
|
||||
|
||||
# Valve's service and hooks where the service looks for them. It runs as
|
||||
# steamos, so it lists one user and properties.json says "login": "steamos",
|
||||
# as the Frame's does (docs/ssh.md, verified 2026-09-26, BUILD_ID 20260922.6101926).
|
||||
COPY steamos-devkit-service/src/steamos-devkit-service.py /usr/lib/steamos-devkit/
|
||||
COPY steamos-devkit-service/hooks/ /usr/share/steamos-devkit/hooks/
|
||||
COPY rootfs/ /
|
||||
|
||||
# /etc/os-release, pointed at /usr/lib/os-release, carries the Frame's
|
||||
# VERSION_ID 0.3.0, VARIANT_ID vr and BUILD_ID 20260922.6101926 (docs/apks.md).
|
||||
RUN ln -sf ../usr/lib/os-release /etc/os-release \
|
||||
&& chmod 755 /usr/share/steamos-devkit/hooks/approve-ssh-key /usr/share/steamos-devkit/hooks/install-ssh-key \
|
||||
/usr/share/steamos-devkit/hooks/devkit-1-identify /usr/local/bin/* /usr/local/lib/fakeframe/*.py \
|
||||
/usr/bin/steamos-polkit-helpers/steamos-enable-sshd \
|
||||
&& mkdir -p /usr/local/lib/fakeframe/bin /var/lib/fakeframe /var/log/fakeframe /home/steamos/devkit-game \
|
||||
&& cp /usr/bin/sleep /usr/local/lib/fakeframe/bin/vrserver \
|
||||
&& cp /usr/bin/sleep /usr/local/lib/fakeframe/bin/plasmashell \
|
||||
&& chmod 1777 /var/lib/fakeframe /var/log/fakeframe \
|
||||
&& chown -R steamos:steamos /home/steamos
|
||||
|
||||
EXPOSE 22 32000 9999
|
||||
CMD ["python3", "/usr/local/lib/fakeframe/init.py"]
|
||||
@@ -0,0 +1,54 @@
|
||||
# The fake Frame and the computer Frame Control runs on, for tests/e2e.
|
||||
# scripts/e2e.sh builds the two images, brings this up, runs the tests in
|
||||
# `host` and takes it down again.
|
||||
name: fakeframe-e2e
|
||||
services:
|
||||
fakeframe:
|
||||
image: fakeframe-frame
|
||||
pull_policy: never
|
||||
hostname: frame # the Frame's default hostname (docs/ssh.md)
|
||||
init: true
|
||||
tmpfs:
|
||||
# Small, so `fakeframe-ctl disk-full on` can fill it.
|
||||
- /home/steamos/devkit-game:size=64m,mode=0755,exec
|
||||
volumes:
|
||||
- keys:/keys
|
||||
# frame_status.py reads the battery and thermal zones from /sys, which is
|
||||
# read-only in a container (and docker's AppArmor profile refuses writes
|
||||
# under /sys even to a mount there). So each folder is a volume mounted
|
||||
# twice: over /sys/class/... for reading, and under /var/lib/fakeframe/sys
|
||||
# where the supervisor writes it (fakeframe-ctl battery).
|
||||
- power:/sys/class/power_supply
|
||||
- power:/var/lib/fakeframe/sys/power_supply
|
||||
- thermal:/sys/class/thermal
|
||||
- thermal:/var/lib/fakeframe/sys/thermal
|
||||
environment:
|
||||
FAKEFRAME_PAIRING_MODE: ${FAKEFRAME_PAIRING_MODE:-0}
|
||||
healthcheck:
|
||||
test: ["CMD", "fakeframe-ctl", "ping"]
|
||||
interval: 2s
|
||||
timeout: 10s
|
||||
retries: 60
|
||||
host:
|
||||
image: fakeframe-host
|
||||
pull_policy: never
|
||||
init: true
|
||||
depends_on:
|
||||
fakeframe:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ../..:/repo:ro
|
||||
- keys:/keys:ro
|
||||
environment:
|
||||
FAKEFRAME_CTL: http://fakeframe:9999
|
||||
FAKEFRAME_HOST: fakeframe
|
||||
FRAME_E2E: "1"
|
||||
healthcheck:
|
||||
test: ["CMD", "test", "-f", "/home/tester/.ready"]
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 120
|
||||
volumes:
|
||||
keys:
|
||||
power:
|
||||
thermal:
|
||||
@@ -0,0 +1,14 @@
|
||||
# The computer Frame Control runs on, for the e2e tests: Python 3.9 (the
|
||||
# oldest the app supports), the OpenSSH client and rsync, and nothing else.
|
||||
# The repository is mounted read-only at /repo (compose.yaml). OpenSSH reads
|
||||
# ~/.ssh/config from the passwd home, not $HOME, which is why this is a
|
||||
# container of its own rather than a HOME override on the machine running the tests.
|
||||
FROM python:3.9-slim-bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client rsync procps \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd -m -u 1000 -s /bin/bash tester
|
||||
COPY host/entrypoint.sh /usr/local/bin/fakeframe-host
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
USER tester
|
||||
WORKDIR /repo
|
||||
CMD ["fakeframe-host"]
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# The computer side of the harness: tester's ~/.ssh as Frame Control's setup
|
||||
# leaves it (ui/frame_connect.py's own config block), pointing `frame` at the
|
||||
# fake Frame, with the harness key the fake trusts.
|
||||
set -euo pipefail
|
||||
host=${FAKEFRAME_HOST:-fakeframe}
|
||||
for _ in $(seq 1 240); do
|
||||
[ -s /keys/id_ed25519_frame.pub ] && break
|
||||
sleep 0.5
|
||||
done
|
||||
mkdir -p -m 700 ~/.ssh
|
||||
install -m 600 /keys/id_ed25519_frame ~/.ssh/id_ed25519_frame
|
||||
install -m 644 /keys/id_ed25519_frame.pub ~/.ssh/id_ed25519_frame.pub
|
||||
python3 - "$host" > ~/.ssh/config <<'PY'
|
||||
import sys
|
||||
sys.path.insert(0, '/repo/ui')
|
||||
import frame_connect
|
||||
print('\n'.join(frame_connect.config_block(sys.argv[1])))
|
||||
PY
|
||||
chmod 600 ~/.ssh/config
|
||||
until ssh-keyscan -T 2 "$host" > ~/.ssh/known_hosts 2>/dev/null && [ -s ~/.ssh/known_hosts ]; do
|
||||
sleep 1
|
||||
done
|
||||
touch ~/.ready
|
||||
exec sleep infinity
|
||||
@@ -0,0 +1,21 @@
|
||||
# The fake Frame's sshd. With Developer Mode on, the Frame takes key logins and
|
||||
# the Developer Mode password for `steamos` (docs/ssh.md); the fake's password
|
||||
# is "frame". MaxSessions leaves room for Frame Control's shared connection.
|
||||
Port 22
|
||||
HostKey /etc/ssh/ssh_host_ed25519_key
|
||||
HostKey /etc/ssh/ssh_host_rsa_key
|
||||
HostKey /etc/ssh/ssh_host_ecdsa_key
|
||||
PermitRootLogin no
|
||||
PubkeyAuthentication yes
|
||||
AuthorizedKeysFile .ssh/authorized_keys
|
||||
PasswordAuthentication yes
|
||||
KbdInteractiveAuthentication no
|
||||
UsePAM no
|
||||
PrintMotd no
|
||||
MaxSessions 64
|
||||
MaxStartups 64:30:128
|
||||
# OpenSSH 9.8+ penalises an address after failed logins by refusing it for a
|
||||
# while. The pairing tests fail logins on purpose, so the fake turns that off.
|
||||
# (Whether the Frame's sshd penalises is unchecked.)
|
||||
PerSourcePenalties no
|
||||
Subsystem sftp /usr/lib/ssh/sftp-server
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stub for SteamOS's polkit helper, which approve-ssh-key runs once a pairing
|
||||
is approved: asks the fake Frame's supervisor to (re)start sshd."""
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, '/usr/local/lib/fakeframe')
|
||||
import fakeframe_state as fs # noqa: E402
|
||||
|
||||
fs.log('steamos-enable-sshd')
|
||||
req = urllib.request.Request('http://127.0.0.1:9999/ctl', data=json.dumps({'args': ['sshd', 'on']}).encode(),
|
||||
headers={'Content-Type': 'application/json'})
|
||||
urllib.request.urlopen(req, timeout=10).read()
|
||||
@@ -0,0 +1,9 @@
|
||||
NAME="SteamOS"
|
||||
PRETTY_NAME="SteamOS"
|
||||
ID=steamos
|
||||
ID_LIKE=arch
|
||||
LOGO=steamos
|
||||
HOME_URL="https://www.steampowered.com/"
|
||||
VERSION_ID=0.3.0
|
||||
VARIANT_ID=vr
|
||||
BUILD_ID=20260922.6101926
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Flip the fake Frame's fault switches and read its state; `fakeframe-ctl help`.
|
||||
|
||||
Talks to the supervisor's control port, so it works the same over SSH
|
||||
(`ssh frame fakeframe-ctl steam off`) and from the host container with
|
||||
FAKEFRAME_CTL=http://fakeframe:9999.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
url = os.environ.get('FAKEFRAME_CTL', 'http://127.0.0.1:9999').rstrip('/')
|
||||
req = urllib.request.Request(url + '/ctl', data=json.dumps({'args': sys.argv[1:]}).encode(),
|
||||
headers={'Content-Type': 'application/json'})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
out = json.load(r)
|
||||
except urllib.error.HTTPError as e:
|
||||
out = json.load(e)
|
||||
except OSError as e:
|
||||
sys.exit(f'fakeframe-ctl: control port not answering ({e})')
|
||||
if 'usage' in out:
|
||||
print(out['usage'])
|
||||
elif 'error' in out:
|
||||
sys.exit(f"fakeframe-ctl: {out['error']}")
|
||||
else:
|
||||
print(json.dumps(out, indent=1))
|
||||
if sys.argv[1:2] == ['ping'] and not out.get('ok'):
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stub for flatpak: --user installs and removals, and `list`, kept in the state file.
|
||||
Nothing is downloaded; an app id containing "missing" fails like an unknown ref."""
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/local/lib/fakeframe')
|
||||
import fakeframe_state as fs # noqa: E402
|
||||
|
||||
args = sys.argv[1:]
|
||||
fs.log('flatpak', args=args)
|
||||
words = [a for a in args if not a.startswith('-')]
|
||||
cmd = words[0] if words else ''
|
||||
if cmd == 'remote-add':
|
||||
pass
|
||||
elif cmd == 'install':
|
||||
app = words[-1]
|
||||
if 'missing' in app:
|
||||
sys.exit(f'error: Nothing matches {app} in remote flathub')
|
||||
with fs.update() as s:
|
||||
if not any(f['id'] == app for f in s['flatpaks']):
|
||||
s['flatpaks'].append({'id': app, 'name': app.rsplit('.', 1)[-1], 'version': '1.0', 'installation': 'user'})
|
||||
print(f'Installing {app}\nInstallation complete.')
|
||||
elif cmd == 'uninstall':
|
||||
app = words[-1]
|
||||
with fs.update() as s:
|
||||
before = len(s['flatpaks'])
|
||||
s['flatpaks'] = [f for f in s['flatpaks'] if f['id'] != app]
|
||||
gone = len(s['flatpaks']) < before
|
||||
if not gone:
|
||||
sys.exit(f'error: {app}/*unspecified*/*unspecified* not installed')
|
||||
print('Uninstall complete.')
|
||||
elif cmd == 'list':
|
||||
for f in fs.read()['flatpaks']:
|
||||
print('\t'.join((f['id'], f['name'], f['version'], f['installation'])))
|
||||
elif cmd == 'run':
|
||||
pass
|
||||
else:
|
||||
sys.exit(f'flatpak stub: unsupported {args}')
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stub for gamescopectl: `screenshot FILE` writes a small PNG, as gamescope does
|
||||
(asynchronously on the Frame, which server.SCREENSHOT waits out)."""
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
|
||||
sys.path.insert(0, '/usr/local/lib/fakeframe')
|
||||
import fakeframe_state as fs # noqa: E402
|
||||
|
||||
args = sys.argv[1:]
|
||||
fs.log('gamescopectl', args=args)
|
||||
if len(args) != 2 or args[0] != 'screenshot':
|
||||
sys.exit(f'gamescopectl stub: unsupported {args}')
|
||||
|
||||
|
||||
def chunk(kind, data):
|
||||
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
|
||||
|
||||
|
||||
w, h = 64, 36
|
||||
rows = b''.join(b'\0' + b''.join(bytes((x * 4, y * 7, 160)) for x in range(w)) for y in range(h))
|
||||
png = (b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0))
|
||||
+ chunk(b'IDAT', zlib.compress(rows)) + chunk(b'IEND', b''))
|
||||
with open(args[1], 'wb') as f:
|
||||
f.write(png)
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stub for nmcli: the Wi-Fi network frame_status.py reads with
|
||||
`nmcli -t -f active,ssid,signal dev wifi` (':' inside fields escaped as '\\:')."""
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/local/lib/fakeframe')
|
||||
import fakeframe_state as fs # noqa: E402
|
||||
|
||||
fs.log('nmcli', args=sys.argv[1:])
|
||||
print('yes:Fake\\:Frame Wi-Fi:72')
|
||||
print('no:Neighbours:31')
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stub for podman, for the fake Lepton instances (lepton.py): ps, stop, exec.
|
||||
`podman ps --format "{{.Names}} {{.Labels.adb_port}}"` lists what's running,
|
||||
as frame_android.running_instances reads it (docs/apks.md)."""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, '/usr/local/lib/fakeframe')
|
||||
import fakeframe_state as fs # noqa: E402
|
||||
|
||||
|
||||
def alive(c):
|
||||
try:
|
||||
os.kill(c['pid'], 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
args = sys.argv[1:]
|
||||
fs.log('podman', args=args)
|
||||
cmd = args[0] if args else ''
|
||||
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 == 'stop':
|
||||
name = args[-1]
|
||||
c = containers.get(name)
|
||||
if not c:
|
||||
sys.exit(f'Error: no container with name or ID "{name}" found: no such container')
|
||||
os.kill(c['pid'], 15)
|
||||
for _ in range(50):
|
||||
if not alive(c):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
print(name)
|
||||
elif cmd == 'exec':
|
||||
name, rest = args[1], ' '.join(args[2:])
|
||||
if name not in containers:
|
||||
sys.exit(f'Error: no container with name or ID "{name}" found: no such container')
|
||||
if 'pidof' in rest:
|
||||
print(4242) # the app is "up"; there's no Android to ask
|
||||
# logcat and anything else: nothing to report
|
||||
else:
|
||||
sys.exit(f'podman stub: unsupported {args}')
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stub for qdbus6: records Klipper setClipboardContents calls, the way
|
||||
Frame Control sends text to the headset desktop's clipboard (server.PASTE,
|
||||
verified 2026-09-25)."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/local/lib/fakeframe')
|
||||
import fakeframe_state as fs # noqa: E402
|
||||
|
||||
args = sys.argv[1:]
|
||||
fs.log('qdbus6', args=args[:3], bus=os.environ.get('DBUS_SESSION_BUS_ADDRESS'))
|
||||
if args[:3] == ['org.kde.klipper', '/klipper', 'org.kde.klipper.klipper.setClipboardContents'] and len(args) == 4:
|
||||
if not os.environ.get('DBUS_SESSION_BUS_ADDRESS'):
|
||||
sys.exit('Could not connect to D-Bus server')
|
||||
with fs.update() as s:
|
||||
s['clipboard'].append(args[3])
|
||||
else:
|
||||
sys.exit(f'qdbus6 stub: unsupported {args}')
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stub for SteamOS's `steam` command: hands steam:// URLs to the running client.
|
||||
|
||||
On the Frame, `steam steam://rungameid/N` over SSH starts the game in the
|
||||
running client (docs/apks.md, docs/steam-games.md, 2026-09-25). How the real
|
||||
wrapper passes the URL on isn't documented; this writes it as a line on
|
||||
~/.steam/steam.pipe, which fakesteam reads (guess).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/local/lib/fakeframe')
|
||||
import fakeframe_state as fs # noqa: E402
|
||||
|
||||
args = sys.argv[1:]
|
||||
running = fs.steam_pid() is not None
|
||||
fs.log('steam', args=args, client_running=running)
|
||||
if not running:
|
||||
# The real command would start the Steam client; this one can't.
|
||||
print('steam: the Steam client is not running', file=sys.stderr)
|
||||
sys.exit(0)
|
||||
fd = os.open(os.path.expanduser('~/.steam/steam.pipe'), os.O_RDWR)
|
||||
try:
|
||||
os.write(fd, (' '.join(args) + '\n').encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stub for PipeWire's wpctl: the default sink's volume and mute, kept in the state file.
|
||||
Output format as wpctl prints it: "Volume: 0.40" plus " [MUTED]"."""
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/local/lib/fakeframe')
|
||||
import fakeframe_state as fs # noqa: E402
|
||||
|
||||
args = sys.argv[1:]
|
||||
fs.log('wpctl', args=args)
|
||||
if len(args) == 2 and args[0] == 'get-volume':
|
||||
v = fs.read()['volume']
|
||||
print(f"Volume: {v['level']:.2f}" + (' [MUTED]' if v['muted'] else ''))
|
||||
elif len(args) == 3 and args[0] == 'set-volume':
|
||||
with fs.update() as s:
|
||||
s['volume']['level'] = max(0.0, min(1.5, float(args[2])))
|
||||
elif len(args) == 3 and args[0] == 'set-mute':
|
||||
with fs.update() as s:
|
||||
s['volume']['muted'] = args[2] == 'toggle' and not s['volume']['muted'] or args[2] == '1'
|
||||
else:
|
||||
sys.exit(f'wpctl stub: unsupported {args}')
|
||||
@@ -0,0 +1,157 @@
|
||||
// The fake Steam client's JavaScript context ("SharedJSContext"), for fakesteam's
|
||||
// DevTools server. fakesteam sends one JSON request per line on stdin:
|
||||
// {"id": N, "expression": "...", "awaitPromise": true, "steam": {...state...}}
|
||||
// and gets one line back: {"id": N, "result": <CDP Runtime.evaluate result>, "steam": {...}}.
|
||||
// The expression runs for real in a V8 context holding the objects below, so
|
||||
// whatever JavaScript Frame Control sends (async functions, optional chaining,
|
||||
// Map) behaves as it would in Steam's CEF; only the objects are stand-ins.
|
||||
//
|
||||
// Shapes copy what was seen through the Frame's DevTools port on 2026-09-25
|
||||
// (docs/steam-games.md and docs/apks.md, BUILD_ID 20260922.6101926):
|
||||
// appStore.allApps, a Map in downloadsStore.m_DownloadOverview keyed by client
|
||||
// id with "0" for this machine, SteamClient.Installs.GetInstallManagerInfo /
|
||||
// ContinueInstall, SteamClient.User.GetIPCountry, and SteamClient.Apps.AddShortcut
|
||||
// + SetShortcutName / SetShortcutStartDir for non-Steam shortcuts.
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const readline = require('readline');
|
||||
|
||||
const SHORTCUT_TYPE = 1073741824; // app_type of a non-Steam shortcut, as steam_shortcuts.py filters
|
||||
|
||||
function newShortcutId(steam) {
|
||||
// Real shortcut ids are 32-bit with the top bit set (T3 Code's was 3130509679).
|
||||
steam.next_shortcut = (steam.next_shortcut || 0) + 1;
|
||||
return (0x80000000 + ((steam.next_shortcut * 2654435761) >>> 1)) >>> 0;
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
local_per_client_data: {
|
||||
installed: !!a.installed, display_status: a.display_status ?? (a.installed ? 1 : 0),
|
||||
status_percentage: a.status_percentage ?? 0,
|
||||
},
|
||||
});
|
||||
const shortcutOverview = s => ({
|
||||
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE,
|
||||
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
|
||||
});
|
||||
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
|
||||
|
||||
const im = () => steam.install_manager;
|
||||
const queue = appid => {
|
||||
// State 14: Steam queued the download (Balatro on the Frame, docs/steam-games.md).
|
||||
const app = steam.apps.find(a => a.appid === appid);
|
||||
Object.assign(im(), { eInstallState: 14, currentAppID: appid });
|
||||
if (app) {
|
||||
steam.download = { update_appid: appid, update_state: 'Downloading', paused: false,
|
||||
update_is_install: true, overall_percent_complete: 0,
|
||||
overall_estimated_time_remaining_sec: 7, update_network_bytes_per_second: 9500000 };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
appStore: {
|
||||
get allApps() { return allApps(); },
|
||||
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
||||
},
|
||||
downloadsStore: {
|
||||
get m_DownloadOverview() { return steam.download ? new Map([['0', { ...steam.download }]]) : new Map(); },
|
||||
},
|
||||
SteamClient: {
|
||||
Apps: {
|
||||
async AddShortcut(name, exe, launchOptions, cmdLine) {
|
||||
const appid = newShortcutId(steam);
|
||||
const base = String(exe).split('/').pop();
|
||||
steam.shortcuts.push({ appid, name: base, exe: String(exe), start_dir: '', icon: '',
|
||||
launch_options: String(launchOptions || ''), devkit_gameid: null });
|
||||
return appid;
|
||||
},
|
||||
SetShortcutName(id, name) { const s = findShortcut(id); if (s) s.name = String(name); },
|
||||
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); },
|
||||
RemoveShortcut(id) {
|
||||
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
||||
delete steam.compat_tools[String(id)];
|
||||
},
|
||||
},
|
||||
Installs: {
|
||||
async GetInstallManagerInfo() {
|
||||
const i = im();
|
||||
return { eInstallState: i.eInstallState, currentAppID: i.currentAppID,
|
||||
nDiskSpaceRequired: i.nDiskSpaceRequired, nDiskSpaceAvailable: i.nDiskSpaceAvailable,
|
||||
eAppError: i.eAppError ?? 0, errorDetail: i.errorDetail ?? '' };
|
||||
},
|
||||
// Broforce stopped at state 7 and ContinueInstall() queued it (docs/steam-games.md).
|
||||
ContinueInstall() { if (im().eInstallState === 7) queue(im().currentAppID); },
|
||||
CancelInstall() { Object.assign(im(), { eInstallState: 16 }); },
|
||||
// Calling OpenInstallWizard directly did nothing on the Frame: the state stayed 0.
|
||||
OpenInstallWizard() {},
|
||||
},
|
||||
User: {
|
||||
async GetIPCountry() { return steam.country; },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// What CDP's Runtime.evaluate returns with returnByValue.
|
||||
function remote(value) {
|
||||
if (value === undefined) return { type: 'undefined' };
|
||||
if (value === null) return { type: 'object', subtype: 'null', value: null };
|
||||
const type = typeof value;
|
||||
if (type === 'object') return { type: 'object', value: JSON.parse(JSON.stringify(value)) };
|
||||
if (type === 'function') return { type: 'function', description: String(value) };
|
||||
return { type, value, description: String(value) };
|
||||
}
|
||||
|
||||
function exception(err, inPromise) {
|
||||
// Chrome puts the stack in description; its first line is enough here.
|
||||
const description = err && err.name ? `${err.name}: ${err.message}` : String(err);
|
||||
return {
|
||||
result: { type: 'object', subtype: 'error', className: (err && err.name) || 'Error', description },
|
||||
exceptionDetails: {
|
||||
exceptionId: 1, text: inPromise ? 'Uncaught (in promise)' : 'Uncaught', lineNumber: 0, columnNumber: 0,
|
||||
exception: { type: 'object', subtype: 'error', className: (err && err.name) || 'Error', description },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluate(req) {
|
||||
const steam = req.steam;
|
||||
const ctx = vm.createContext(build(steam));
|
||||
let value;
|
||||
try {
|
||||
value = vm.runInContext(req.expression, ctx, { timeout: 5000 });
|
||||
} catch (err) {
|
||||
return exception(err, false);
|
||||
}
|
||||
if (req.awaitPromise && value && typeof value.then === 'function') {
|
||||
try {
|
||||
value = await value;
|
||||
} catch (err) {
|
||||
return exception(err, true);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return { result: remote(value) };
|
||||
} catch (err) {
|
||||
return exception(err, false);
|
||||
}
|
||||
}
|
||||
|
||||
// One request at a time: fakesteam holds the state lock around each.
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
let chain = Promise.resolve();
|
||||
rl.on('line', line => {
|
||||
chain = chain.then(async () => {
|
||||
const req = JSON.parse(line);
|
||||
const result = await evaluate(req);
|
||||
process.stdout.write(JSON.stringify({ id: req.id, result, steam: req.steam }) + '\n');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Shared state of the fake Frame: one JSON file plus a log of stub calls.
|
||||
|
||||
Every fake part (the supervisor, fakesteam, the command stubs) reads and
|
||||
writes /var/lib/fakeframe/state.json through update(), which holds an
|
||||
exclusive flock, so separate processes never lose each other's changes.
|
||||
Tests read the file over SSH (`fakeframe-ctl state`) or the control port.
|
||||
"""
|
||||
import contextlib
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
DIR = '/var/lib/fakeframe'
|
||||
STATE = os.path.join(DIR, 'state.json')
|
||||
CALLS = os.path.join(DIR, 'calls.jsonl')
|
||||
LOCK = os.path.join(DIR, 'state.lock')
|
||||
|
||||
HOME = '/home/steamos'
|
||||
STEAM_ROOT = HOME + '/.local/share/Steam'
|
||||
DEVKIT_GAMES = HOME + '/devkit-game'
|
||||
LEPTON = STEAM_ROOT + '/steamapps/common/Lepton/lepton'
|
||||
|
||||
# Compat tools and the Steam app ids of their depots. 4183110 is the id Steam
|
||||
# printed on the Frame for "Steam Linux Runtime 4.0" (docs/sideloading.md,
|
||||
# BUILD_ID 20260922.6101926); Lepton Development is 3056000 (docs/apks.md).
|
||||
# The others are placeholders: nothing in Frame Control reads them.
|
||||
RUNTIME_APPIDS = {'proton-experimental': 1493710, 'proton-stable': 3658110,
|
||||
'SteamLinuxRuntime_4-arm64': 4183120, 'SteamLinuxRuntime_4': 4183110,
|
||||
'lepton': 3056000}
|
||||
RUNTIME_NAMES = {'proton-experimental': 'Proton Experimental', 'proton-stable': 'Proton 11.0',
|
||||
'SteamLinuxRuntime_4-arm64': 'Steam Linux Runtime 4.0 (arm64)',
|
||||
'SteamLinuxRuntime_4': 'Steam Linux Runtime 4.0', 'lepton': 'Lepton Development'}
|
||||
|
||||
|
||||
def default_state():
|
||||
return {
|
||||
'switches': {
|
||||
'pairing_mode': False, # Steam Settings > Developer > Pair new host open or not
|
||||
'pairing_answer': 'approve', # approve | deny | timeout
|
||||
'steam': True, # Steam client running
|
||||
'asleep': False, # headset asleep: ports 22 and 32000 accept but never answer
|
||||
'sshd': True,
|
||||
'devkit_service': True,
|
||||
'disk_full': False,
|
||||
},
|
||||
# Which compat tools are installed. On the Frame the x86-64 Steam Linux
|
||||
# Runtime 4.0 wasn't, and a devkit title didn't install it (docs/sideloading.md,
|
||||
# 2026-09-26, BUILD_ID 20260922.6101926).
|
||||
'runtimes': {'proton-experimental': True, 'proton-stable': True,
|
||||
'SteamLinuxRuntime_4-arm64': True, 'SteamLinuxRuntime_4': False, 'lepton': True},
|
||||
# /sys/class/power_supply values, in the units the kernel uses (µV, µA, tenths
|
||||
# of °C), as frame_status.py reads them (paths verified on build 20260922; its
|
||||
# docstring gives the charger type 'C PD [PD_PPS]' at 20 W as seen on the Frame).
|
||||
'battery': {'capacity': 76, 'status': 'Charging', 'voltage_now': 7700000, 'current_now': 1250000,
|
||||
'time_to_full_now': 2520, 'temp': 312, 'health': 'Good',
|
||||
'charger': {'online': 1, 'usb_type': 'C PD [PD_PPS]', 'voltage_now': 9000000,
|
||||
'current_now': 2220000}},
|
||||
'thermal_mc': [41500, 38250], # thermal_zone*/temp, millidegrees C
|
||||
'volume': {'level': 0.4, 'muted': False},
|
||||
'flatpaks': [],
|
||||
'clipboard': [],
|
||||
'steam': {
|
||||
'country': 'AU', # GetIPCountry() answered "AU" (docs/steam-games.md)
|
||||
'next_shortcut': 0,
|
||||
# A few owned games. Balatro went straight to install state 14 and
|
||||
# Broforce stopped at 7 on the Frame (docs/steam-games.md, 2026-09-25,
|
||||
# BUILD_ID 20260922.6101926); `wizard` makes the fake do the same.
|
||||
'apps': [
|
||||
{'appid': 2379780, 'display_name': 'Balatro', 'installed': False, 'size': 67000000,
|
||||
'packed': 3 << 8 | 3, 'vr': False, 'wizard': 14},
|
||||
{'appid': 274190, 'display_name': 'Broforce', 'installed': False, 'size': 600000000,
|
||||
'packed': 0 << 8 | 2, 'vr': False, 'wizard': 7},
|
||||
{'appid': 620980, 'display_name': 'Beat Saber', 'installed': True, 'size': 4200000000,
|
||||
'packed': 3 << 8 | 1, 'vr': True, 'vr_only': True, 'wizard': 14},
|
||||
],
|
||||
'shortcuts': [], # {appid, name, exe, start_dir, icon, devkit_gameid}
|
||||
'compat_tools': {}, # shortcut appid (str) -> compat tool alias (CompatToolMapping)
|
||||
'install_manager': {'eInstallState': 0, 'currentAppID': 0, 'nDiskSpaceRequired': 0,
|
||||
'nDiskSpaceAvailable': 0},
|
||||
'download': None,
|
||||
'pages': [], # extra DevTools targets, e.g. a store page
|
||||
},
|
||||
'devkit_games': {}, # gameid -> what create-shortcut registered
|
||||
'launches': [], # every launch Steam was asked for, and what it did
|
||||
'pairing_requests': [],
|
||||
'lepton': {}, # container name -> {port, pid, package dir}
|
||||
}
|
||||
|
||||
|
||||
def _share(fd):
|
||||
# Files are made by root or steamos, whichever comes first; both write them (umask aside).
|
||||
try:
|
||||
os.fchmod(fd, 0o666)
|
||||
except OSError:
|
||||
pass # not ours: whoever made it already did this
|
||||
|
||||
|
||||
def _ensure_dir():
|
||||
os.makedirs(DIR, exist_ok=True)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _locked(kind):
|
||||
_ensure_dir()
|
||||
fd = os.open(LOCK, os.O_RDWR | os.O_CREAT, 0o666)
|
||||
_share(fd)
|
||||
try:
|
||||
fcntl.flock(fd, kind)
|
||||
yield
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _load():
|
||||
try:
|
||||
with open(STATE) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return default_state()
|
||||
|
||||
|
||||
def _save(state):
|
||||
tmp = f'{STATE}.{os.getpid()}.tmp'
|
||||
with open(tmp, 'w') as f:
|
||||
json.dump(state, f, indent=1, sort_keys=True)
|
||||
os.chmod(tmp, 0o666) # the supervisor (root) and steamos both write it
|
||||
os.replace(tmp, STATE)
|
||||
|
||||
|
||||
def read():
|
||||
with _locked(fcntl.LOCK_SH):
|
||||
return _load()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def update():
|
||||
"""with update() as s: change s; it's written back when the block ends without an error."""
|
||||
with _locked(fcntl.LOCK_EX):
|
||||
state = _load()
|
||||
yield state
|
||||
_save(state)
|
||||
|
||||
|
||||
def reset():
|
||||
with _locked(fcntl.LOCK_EX):
|
||||
_save(default_state())
|
||||
with open(CALLS, 'w'):
|
||||
pass
|
||||
os.chmod(CALLS, 0o666)
|
||||
|
||||
|
||||
def log(tool, **fields):
|
||||
"""Append one call record to calls.jsonl."""
|
||||
_ensure_dir()
|
||||
line = json.dumps({'time': round(time.time(), 3), 'tool': tool, **fields}) + '\n'
|
||||
fd = os.open(CALLS, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o666)
|
||||
_share(fd)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
os.write(fd, line.encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def calls(tool=None):
|
||||
try:
|
||||
with open(CALLS) as f:
|
||||
out = [json.loads(line) for line in f if line.strip()]
|
||||
except OSError:
|
||||
return []
|
||||
return [c for c in out if tool is None or c['tool'] == tool]
|
||||
|
||||
|
||||
def steam_pid():
|
||||
"""The fake Steam client's pid if it's running, as devkit_utils.validate_steam_client checks."""
|
||||
try:
|
||||
with open(HOME + '/.steam/steam.pid') as f:
|
||||
pid = int(f.read())
|
||||
os.kill(pid, 0)
|
||||
return pid
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,506 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A stand-in for the Frame's Steam client, run as steamos by the supervisor.
|
||||
|
||||
What it copies, and from where (BUILD_ID 20260922.6101926 unless noted):
|
||||
- The devkit IPC Valve's devkit-utils and the devkit service's hooks use:
|
||||
~/.steam/steam.pid (validate_steam_client), ~/.steam/steam.token, and
|
||||
"devkit-1 steam://devkit-1/<token>/<command>?<query>" lines on the
|
||||
~/.steam/steam.pipe FIFO, answered by writing <response> or
|
||||
<response>.error (with <response>.lock while writing), as
|
||||
devkit_utils.wait_on_file_response describes. Commands: approve-ssh-key,
|
||||
create-shortcut, run-game, list-shortcuts and delete-shortcut (all that
|
||||
devkit-utils and the hooks send).
|
||||
- steam:// URLs that the `steam` wrapper forwards (rungameid, install, store).
|
||||
- The DevTools endpoint on 127.0.0.1:8080 with a SharedJSContext target
|
||||
(docs/steam-games.md, docs/apks.md). Expressions run in node against
|
||||
cef_shim.js.
|
||||
|
||||
State lives in fakeframe_state (the "steam", "devkit_games", "launches" and
|
||||
"pairing_requests" keys). Anything not seen on a headset is marked "guess".
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import fakeframe_state as fs # noqa: E402
|
||||
|
||||
HOME = fs.HOME
|
||||
STEAM_DIR = HOME + '/.steam'
|
||||
PID_FILE, TOKEN_FILE, PIPE = (f'{STEAM_DIR}/steam.{n}' for n in ('pid', 'token', 'pipe'))
|
||||
CONSOLE_LOG = fs.STEAM_ROOT + '/logs/console_log.txt' # guess: which file Steam logs devkit launches to
|
||||
# Steam logs compat tool lookups here, and on the Frame the file has binary bytes
|
||||
# in it, so plain grep only says "binary file matches" (headset smoke test,
|
||||
# 2026-09-27, BUILD_ID 20260922.6101926). The fake starts it with a NUL to match.
|
||||
COMPAT_LOG = fs.STEAM_ROOT + '/logs/compat_log.txt'
|
||||
DEVTOOLS_PORT = 8080
|
||||
TARGET_ID = 'F0CA11ED5EA4ED0000000000000000AB'
|
||||
GAME_LOGS = '/var/log/fakeframe'
|
||||
|
||||
# The 403 text /register returned while Steam wasn't on "Pair new host"
|
||||
# (docs/ssh.md, verified 2026-09-26). approve-ssh-key passes the Steam
|
||||
# client's error file through as {"error": ...}, so this is what Steam writes.
|
||||
PAIRING_MODE_ERROR = 'please put the Steam client in pairing mode: Settings -> Developer -> Pair new host'
|
||||
# Guess: what Steam writes when the prompt is declined hasn't been seen.
|
||||
PAIRING_DENIED = 'the pairing request was denied on the device'
|
||||
# Steam refused create-shortcut for ids like "fc-smoke-exe" with this text, and
|
||||
# took the same program as "FCSmokeProbe" (headset smoke test, 2026-09-27,
|
||||
# BUILD_ID 20260922.6101926). Valve's client only allows ids matching
|
||||
# GAMEID_ALLOWED_PATTERN (devkit_client/gui2/gui2.py), so the fake checks that.
|
||||
INVALID_ARGUMENTS = 'missing/invalid arguments\n'
|
||||
GAMEID_ALLOWED = re.compile(r'[A-Za-z_][A-Za-z0-9_.]+')
|
||||
|
||||
_lock = threading.RLock() # one state change at a time within this process (the file lock covers others)
|
||||
TOKEN = secrets.token_hex(16)
|
||||
|
||||
|
||||
def console(line):
|
||||
os.makedirs(os.path.dirname(CONSOLE_LOG), exist_ok=True)
|
||||
with open(CONSOLE_LOG, 'a') as f:
|
||||
f.write(time.strftime('[%Y-%m-%d %H:%M:%S] ') + line + '\n')
|
||||
|
||||
|
||||
def compat(line):
|
||||
os.makedirs(os.path.dirname(COMPAT_LOG), exist_ok=True)
|
||||
with open(COMPAT_LOG, 'ab') as f:
|
||||
if f.tell() == 0:
|
||||
f.write(b'\0\n')
|
||||
f.write((time.strftime('[%Y-%m-%d %H:%M:%S] ') + line + '\n').encode())
|
||||
|
||||
|
||||
def respond(path, text=None, error=None):
|
||||
"""Answer a devkit request the way devkit_utils.wait_on_file_response expects."""
|
||||
lock = path + '.lock'
|
||||
open(lock, 'w').close()
|
||||
with open(path + '.error' if error is not None else path, 'w') as f:
|
||||
f.write(error if error is not None else text)
|
||||
os.unlink(lock)
|
||||
|
||||
|
||||
# ---- the Node side of the DevTools endpoint ----------------------------------
|
||||
|
||||
class JS:
|
||||
def __init__(self):
|
||||
self.proc = None
|
||||
self.next = 0
|
||||
|
||||
def _start(self):
|
||||
shim = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cef_shim.js')
|
||||
self.proc = subprocess.Popen(['node', shim], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
|
||||
|
||||
def evaluate(self, expression, await_promise):
|
||||
with _lock:
|
||||
if not self.proc or self.proc.poll() is not None:
|
||||
self._start()
|
||||
self.next += 1
|
||||
with fs.update() as state:
|
||||
self.proc.stdin.write(json.dumps({'id': self.next, 'expression': expression,
|
||||
'awaitPromise': await_promise, 'steam': state['steam']}) + '\n')
|
||||
self.proc.stdin.flush()
|
||||
reply = json.loads(self.proc.stdout.readline())
|
||||
state['steam'] = reply['steam']
|
||||
return reply['result']
|
||||
|
||||
|
||||
JS_WORKER = JS()
|
||||
|
||||
|
||||
# ---- devkit commands ----------------------------------------------------------
|
||||
|
||||
def shortcut_for(state, gameid):
|
||||
return next((s for s in state['steam']['shortcuts'] if s.get('devkit_gameid') == gameid), None)
|
||||
|
||||
|
||||
def new_shortcut_id(steam):
|
||||
steam['next_shortcut'] = steam.get('next_shortcut', 0) + 1
|
||||
return (0x80000000 + ((steam['next_shortcut'] * 2654435761 & 0xFFFFFFFF) >> 1)) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def read_json(path, default=None):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def cmd_approve_ssh_key(q):
|
||||
with fs.update() as state:
|
||||
sw = state['switches']
|
||||
answer = sw['pairing_answer'] if sw['pairing_mode'] else 'not in pairing mode'
|
||||
state['pairing_requests'].append({'time': time.time(), 'request': q.get('request', ''), 'answer': answer})
|
||||
if answer == 'not in pairing mode':
|
||||
respond(q['response'], error=PAIRING_MODE_ERROR)
|
||||
elif answer == 'approve':
|
||||
respond(q['response'], text='approved') # guess: the hook only checks that the file appears
|
||||
elif answer == 'deny':
|
||||
respond(q['response'], error=PAIRING_DENIED)
|
||||
# 'timeout': never answer; the hook gives up after 30 s.
|
||||
|
||||
|
||||
def cmd_create_shortcut(q):
|
||||
gameid = q.get('gameid', '')
|
||||
folder = q.get('directory') or fs.DEVKIT_GAMES
|
||||
path = os.path.join(folder, gameid)
|
||||
if not GAMEID_ALLOWED.fullmatch(gameid):
|
||||
respond(q['response'], error=INVALID_ARGUMENTS)
|
||||
return
|
||||
if not os.path.isdir(path):
|
||||
respond(q['response'], error=f'no devkit game folder {path}') # guess: wording
|
||||
return
|
||||
argv = read_json(f'{folder}/{gameid}-argv.json', [])
|
||||
settings = read_json(f'{folder}/{gameid}-settings.json', {})
|
||||
env = read_json(f'{folder}/{gameid}-env.json', {})
|
||||
with fs.update() as state:
|
||||
steam = state['steam']
|
||||
sc = shortcut_for(state, gameid)
|
||||
if not sc:
|
||||
sc = {'appid': new_shortcut_id(steam), 'name': gameid, 'exe': '', 'start_dir': path, 'icon': '',
|
||||
'launch_options': '', 'devkit_gameid': gameid}
|
||||
steam['shortcuts'].append(sc)
|
||||
sc['exe'] = argv[0] if argv else ''
|
||||
tool = settings.get('compat_tool')
|
||||
# Steam maps the title to the chosen compat tool (CompatToolMapping and
|
||||
# compat_log.txt on the Frame, docs/sideloading.md, 2026-09-26).
|
||||
if tool:
|
||||
steam['compat_tools'][str(sc['appid'])] = tool
|
||||
else:
|
||||
steam['compat_tools'].pop(str(sc['appid']), None)
|
||||
state['devkit_games'][gameid] = {'appid': sc['appid'], 'directory': path, 'argv': argv,
|
||||
'settings': settings, 'env': env, 'registered': time.time()}
|
||||
console(f'devkit create-shortcut: registered devkit game "{gameid}"')
|
||||
respond(q['response'], text='') # Steam's answer was empty on the Frame (2026-09-27)
|
||||
|
||||
|
||||
def cmd_list_shortcuts(q):
|
||||
# The reply format devkit_utils.resolve.resolve_shortcuts asserts.
|
||||
with fs.update() as state:
|
||||
ids = sorted(state['devkit_games'])
|
||||
respond(q['response'], text=json.dumps({'version': 2, 'gameids': ids}))
|
||||
|
||||
|
||||
def cmd_delete_shortcut(q):
|
||||
gameid = q.get('gameid', '')
|
||||
with fs.update() as state:
|
||||
sc = shortcut_for(state, gameid)
|
||||
state['devkit_games'].pop(gameid, None)
|
||||
if sc:
|
||||
state['steam']['shortcuts'].remove(sc)
|
||||
state['steam']['compat_tools'].pop(str(sc['appid']), None)
|
||||
# Remove also deleted the title's Proton prefix on the Frame (docs/sideloading.md).
|
||||
subprocess.run(['rm', '-rf', f"{fs.STEAM_ROOT}/steamapps/compatdata/{sc['appid']}"])
|
||||
console(f'devkit delete-shortcut: removed devkit game "{gameid}"')
|
||||
respond(q['response'], text=f'deleted {gameid}\n')
|
||||
|
||||
|
||||
def cmd_run_game(q):
|
||||
gameid = q.get('gameid', '')
|
||||
with fs.update() as state:
|
||||
game = state['devkit_games'].get(gameid)
|
||||
tool = game and state['steam']['compat_tools'].get(str(game['appid']))
|
||||
runtimes = state['runtimes']
|
||||
if not game:
|
||||
respond(q['response'], error=f'unknown devkit game "{gameid}"') # guess: wording
|
||||
return
|
||||
target = game['argv'][0] if game['argv'] else ''
|
||||
full = os.path.join(game['directory'], target.strip('"'))
|
||||
record = {'kind': 'devkit', 'gameid': gameid, 'appid': game['appid'], 'tool': tool, 'time': time.time()}
|
||||
if tool and not runtimes.get(tool, False):
|
||||
# Seen for the x86-64 runtime (docs/sideloading.md, 2026-09-26): Steam
|
||||
# logs this and the game doesn't start.
|
||||
name = fs.RUNTIME_NAMES.get(tool, tool)
|
||||
record.update(started=False, message=f'Tool {fs.RUNTIME_APPIDS.get(tool, 0)} "{name}" is found for '
|
||||
f'appID {game["appid"]}, but is not installed')
|
||||
compat(record['message'])
|
||||
elif tool and tool.startswith('proton'):
|
||||
# How Steam ran a sideloaded .exe on the Frame (docs/sideloading.md).
|
||||
prefix = f"{fs.STEAM_ROOT}/steamapps/compatdata/{game['appid']}/pfx"
|
||||
os.makedirs(prefix, exist_ok=True)
|
||||
record.update(started=True, command=f'proton waitforexitandrun "{full}"', prefix=prefix)
|
||||
else:
|
||||
# An aarch64 title ran natively, without SteamLinuxRuntime_4-arm64's
|
||||
# _v2-entry-point prefix, even though Steam recorded the mapping
|
||||
# (docs/sideloading.md, 2026-09-26). So does the fake, for real.
|
||||
record.update(started=True, command=full)
|
||||
record['run'] = (full, game['directory'], {**game.get('env', {})}, f'devkit-{gameid}')
|
||||
add_launch(record)
|
||||
console(record['message'] if not record['started'] else f'devkit run-game: started devkit game "{gameid}"')
|
||||
respond(q['response'], text='OK\n') # guess: steam-devkit-rpc only checks that it arrives
|
||||
|
||||
|
||||
DEVKIT = {'approve-ssh-key': cmd_approve_ssh_key, 'create-shortcut': cmd_create_shortcut,
|
||||
'list-shortcuts': cmd_list_shortcuts, 'delete-shortcut': cmd_delete_shortcut,
|
||||
'run-game': cmd_run_game}
|
||||
|
||||
|
||||
def add_launch(record):
|
||||
"""Log a launch in the state. record['run'] = (path, cwd, env, log name) also starts the
|
||||
program the way Steam would, and notes its pid and, once it ends, its exit status."""
|
||||
run, proc = record.pop('run', None), None
|
||||
if run:
|
||||
path, cwd, env, label = run
|
||||
os.makedirs(GAME_LOGS, exist_ok=True)
|
||||
with open(f'{GAME_LOGS}/{label}.log', 'ab') as log:
|
||||
try:
|
||||
proc = subprocess.Popen([path], cwd=cwd if os.path.isdir(cwd) else HOME, env={**os.environ, **env},
|
||||
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
|
||||
record['pid'] = proc.pid
|
||||
except OSError as e:
|
||||
record.update(pid=None, exec_error=str(e))
|
||||
with fs.update() as state: # before the reaper looks for it, however fast the program is
|
||||
record['n'] = len(state['launches'])
|
||||
state['launches'].append(record)
|
||||
if proc:
|
||||
def reap():
|
||||
code = proc.wait()
|
||||
with fs.update() as state:
|
||||
mine = state['launches'][record['n']:record['n'] + 1]
|
||||
if mine and mine[0].get('pid') == proc.pid: # not a reset's fresh list
|
||||
mine[0]['exit'] = code
|
||||
threading.Thread(target=reap, daemon=True).start()
|
||||
|
||||
|
||||
# ---- steam:// URLs from the `steam` wrapper ----------------------------------
|
||||
|
||||
def url_rungameid(gid):
|
||||
gid = int(gid)
|
||||
record = {'kind': 'rungameid', 'gameid': gid, 'time': time.time()}
|
||||
if gid >= 1 << 32:
|
||||
# A non-Steam shortcut: (appid << 32) | 0x02000000 (docs/apks.md).
|
||||
appid = gid >> 32
|
||||
with fs.update() as state:
|
||||
sc = next((s for s in state['steam']['shortcuts'] if s['appid'] == appid), None)
|
||||
if not sc:
|
||||
record.update(started=False, message=f'no shortcut {appid}')
|
||||
else:
|
||||
# Steam sets STEAM_FOSSILIZE_DUMP_PATH for shortcut launches but not
|
||||
# STEAM_COMPAT_SHADER_PATH (docs/apks.md, 2026-09-25).
|
||||
env = {'SteamAppId': str(appid), 'SteamGameId': str(gid),
|
||||
'STEAM_FOSSILIZE_DUMP_PATH': f'{fs.STEAM_ROOT}/steamapps/shadercache/{appid}/fozpipelinesv6'}
|
||||
record.update(appid=appid, started=True, command=sc['exe'],
|
||||
run=(sc['exe'], sc.get('start_dir') or HOME, env, f'shortcut-{appid}'))
|
||||
else:
|
||||
with fs.update() as state:
|
||||
app = next((a for a in state['steam']['apps'] if a['appid'] == gid), None)
|
||||
record.update(appid=gid, started=bool(app and app['installed']),
|
||||
message=None if app and app['installed'] else 'not installed')
|
||||
add_launch(record)
|
||||
|
||||
|
||||
def url_install(appid):
|
||||
appid = int(appid)
|
||||
free = os.statvfs(HOME)
|
||||
with fs.update() as state:
|
||||
steam = state['steam']
|
||||
app = next((a for a in steam['apps'] if a['appid'] == appid), None)
|
||||
im = steam['install_manager']
|
||||
if not app:
|
||||
return # not owned: Steam shows a store or license dialog, state stays 0
|
||||
im.update(currentAppID=appid, nDiskSpaceRequired=app['size'],
|
||||
nDiskSpaceAvailable=free.f_bavail * free.f_frsize, eInstallState=app.get('wizard', 14))
|
||||
if im['eInstallState'] == 14:
|
||||
steam['download'] = {'update_appid': appid, 'update_state': 'Downloading', 'paused': False,
|
||||
'update_is_install': True, 'overall_percent_complete': 0,
|
||||
'overall_estimated_time_remaining_sec': 7,
|
||||
'update_network_bytes_per_second': 9500000}
|
||||
|
||||
|
||||
def url_store(appid):
|
||||
# A store page showed up in the DevTools page list (docs/steam-games.md).
|
||||
names = {1145360: 'Hades'}
|
||||
with fs.update() as state:
|
||||
state['steam']['pages'].append({'title': f"{names.get(int(appid), 'App ' + appid)} on Steam",
|
||||
'url': f'https://store.steampowered.com/app/{appid}/'})
|
||||
|
||||
|
||||
URLS = [(re.compile(r'steam://rungameid/(\d+)$'), url_rungameid),
|
||||
(re.compile(r'steam://install/(\d+)$'), url_install),
|
||||
(re.compile(r'steam://store/(\d+)$'), url_store)]
|
||||
|
||||
|
||||
def handle_line(line):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return
|
||||
fs.log('steam.pipe', line=line)
|
||||
try:
|
||||
if line.startswith('devkit-1 '):
|
||||
m = re.fullmatch(r'steam://devkit-1/([^/]*)/([^?]*)\??(.*)', line.split(' ', 1)[1])
|
||||
if not m or m[1] != TOKEN:
|
||||
console('devkit-1: rejected a command with a bad token')
|
||||
return
|
||||
cmd = m[2].rstrip('/') # steam-devkit-rpc sends "run-game/?..."
|
||||
q = {k: v[0] for k, v in parse_qs(m[3], keep_blank_values=True).items()}
|
||||
handler = DEVKIT.get(cmd)
|
||||
if not handler:
|
||||
console(f'devkit-1: unknown command {cmd}')
|
||||
if 'response' in q:
|
||||
respond(q['response'], error=f'unknown command {cmd}')
|
||||
return
|
||||
handler(q)
|
||||
return
|
||||
for pat, fn in URLS:
|
||||
m = pat.match(line.split()[-1])
|
||||
if m:
|
||||
fn(*m.groups())
|
||||
return
|
||||
console(f'ignored: {line}')
|
||||
except Exception as e: # keep reading the pipe whatever one command did
|
||||
console(f'error handling {line!r}: {type(e).__name__}: {e}')
|
||||
|
||||
|
||||
def read_pipe():
|
||||
# O_RDWR: there is always a writer, so reads never hit EOF between clients.
|
||||
fd = os.open(PIPE, os.O_RDWR)
|
||||
with os.fdopen(fd, 'rb', buffering=0) as f:
|
||||
buf = b''
|
||||
while True:
|
||||
chunk = f.read(4096)
|
||||
buf += chunk
|
||||
while b'\n' in buf:
|
||||
line, buf = buf.split(b'\n', 1)
|
||||
threading.Thread(target=handle_line, args=(line.decode('utf-8', 'replace'),), daemon=True).start()
|
||||
|
||||
|
||||
# ---- DevTools HTTP + WebSocket -------------------------------------------------
|
||||
|
||||
def targets():
|
||||
base = {'type': 'page', 'description': '', 'faviconUrl': ''}
|
||||
out = [{**base, 'id': TARGET_ID, 'title': 'SharedJSContext',
|
||||
'url': 'https://steamloopback.host/index.html',
|
||||
'devtoolsFrontendUrl': f'/devtools/inspector.html?ws=127.0.0.1:{DEVTOOLS_PORT}/devtools/page/{TARGET_ID}',
|
||||
'webSocketDebuggerUrl': f'ws://127.0.0.1:{DEVTOOLS_PORT}/devtools/page/{TARGET_ID}'}]
|
||||
for i, p in enumerate(fs.read()['steam'].get('pages', [])):
|
||||
tid = f'{i + 1:032X}'
|
||||
out.append({**base, 'id': tid, 'title': p['title'], 'url': p['url'],
|
||||
'webSocketDebuggerUrl': f'ws://127.0.0.1:{DEVTOOLS_PORT}/devtools/page/{tid}'})
|
||||
return out
|
||||
|
||||
|
||||
class DevTools(BaseHTTPRequestHandler):
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split('?')[0].rstrip('/')
|
||||
if self.headers.get('Upgrade', '').lower() == 'websocket':
|
||||
if path != f'/devtools/page/{TARGET_ID}':
|
||||
self.send_error(404)
|
||||
return
|
||||
self.websocket()
|
||||
return
|
||||
if path in ('/json', '/json/list'):
|
||||
body = json.dumps(targets(), indent=2).encode()
|
||||
elif path == '/json/version':
|
||||
body = json.dumps({'Browser': 'Chrome/126.0.6478.183', 'Protocol-Version': '1.3',
|
||||
'User-Agent': 'Valve Steam Client (fakeframe)'}).encode()
|
||||
else:
|
||||
self.send_error(404)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json; charset=UTF-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def websocket(self):
|
||||
key = self.headers.get('Sec-WebSocket-Key', '')
|
||||
accept = base64.b64encode(hashlib.sha1((key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest())
|
||||
self.wfile.write(b'HTTP/1.1 101 WebSocket Protocol Handshake\r\nUpgrade: WebSocket\r\n'
|
||||
b'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + b'\r\n\r\n')
|
||||
self.wfile.flush()
|
||||
self.close_connection = True
|
||||
try:
|
||||
while True:
|
||||
op, data = self.read_frame()
|
||||
if op == 8:
|
||||
return
|
||||
if op == 9:
|
||||
self.send_frame(data, 10)
|
||||
continue
|
||||
if op != 1:
|
||||
continue
|
||||
msg = json.loads(data)
|
||||
reply = {'id': msg.get('id')}
|
||||
if msg.get('method') == 'Runtime.evaluate':
|
||||
params = msg.get('params') or {}
|
||||
reply['result'] = JS_WORKER.evaluate(str(params.get('expression', '')),
|
||||
bool(params.get('awaitPromise')))
|
||||
else:
|
||||
reply['error'] = {'code': -32601, 'message': f"'{msg.get('method')}' wasn't found"}
|
||||
self.send_frame(json.dumps(reply).encode(), 1)
|
||||
except (EOFError, OSError, ValueError):
|
||||
return
|
||||
|
||||
def read_exact(self, n):
|
||||
data = self.rfile.read(n)
|
||||
if len(data) < n:
|
||||
raise EOFError
|
||||
return data
|
||||
|
||||
def read_frame(self):
|
||||
b0, b1 = self.read_exact(2)
|
||||
n = b1 & 0x7F
|
||||
if n == 126:
|
||||
n = struct.unpack('>H', self.read_exact(2))[0]
|
||||
elif n == 127:
|
||||
n = struct.unpack('>Q', self.read_exact(8))[0]
|
||||
mask = self.read_exact(4) if b1 & 0x80 else None
|
||||
data = self.read_exact(n)
|
||||
if mask:
|
||||
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
||||
return b0 & 0x0F, data
|
||||
|
||||
def send_frame(self, data, op):
|
||||
n = len(data)
|
||||
head = bytes([0x80 | op]) + (bytes([n]) if n < 126 else
|
||||
bytes([126]) + struct.pack('>H', n) if n < 1 << 16 else
|
||||
bytes([127]) + struct.pack('>Q', n))
|
||||
self.wfile.write(head + data)
|
||||
self.wfile.flush()
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(STEAM_DIR, exist_ok=True)
|
||||
if not os.path.exists(PIPE):
|
||||
os.mkfifo(PIPE, 0o600)
|
||||
with open(TOKEN_FILE, 'w') as f:
|
||||
f.write(TOKEN)
|
||||
with open(PID_FILE, 'w') as f:
|
||||
f.write(str(os.getpid()))
|
||||
|
||||
def stop(*_):
|
||||
# Guess: whether Steam removes steam.pid on exit. Either way
|
||||
# validate_steam_client then says Steam isn't running.
|
||||
try:
|
||||
os.unlink(PID_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
if JS_WORKER.proc:
|
||||
JS_WORKER.proc.kill()
|
||||
os._exit(0)
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
|
||||
httpd = ThreadingHTTPServer(('127.0.0.1', DEVTOOLS_PORT), DevTools)
|
||||
httpd.daemon_threads = True
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
console('fakesteam: started (-cef-enable-debugging on 127.0.0.1:8080)')
|
||||
read_pipe()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The fake Frame's supervisor, run as root under docker's init.
|
||||
|
||||
It starts and stops sshd, Valve's steamos-devkit-service (as steamos),
|
||||
fakesteam (as steamos) and a few named placeholder processes the status page
|
||||
looks for, following the switches in the state file. It also serves the
|
||||
control port (9999) that fakeframe-ctl and the e2e tests use, so a test can
|
||||
flip a fault switch even while SSH is down.
|
||||
|
||||
Control: GET /state, GET /calls, GET /ping, POST /ctl {"args": ["pairing", "on"]}.
|
||||
See `fakeframe-ctl help` for the commands.
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import fakeframe_state as fs # noqa: E402
|
||||
|
||||
LIB = os.path.dirname(os.path.abspath(__file__))
|
||||
USER = 'steamos'
|
||||
PW = pwd.getpwnam(USER)
|
||||
HOME = fs.HOME
|
||||
KEYS = '/keys' # shared with the host container
|
||||
HARNESS_KEY = KEYS + '/id_ed25519_frame'
|
||||
AUTH_KEYS = HOME + '/.ssh/authorized_keys'
|
||||
BALLAST = fs.DEVKIT_GAMES + '/.fakeframe-ballast'
|
||||
# Written here; compose mounts the same volumes over /sys/class/power_supply and /sys/class/thermal.
|
||||
POWER = '/var/lib/fakeframe/sys/power_supply'
|
||||
THERMAL = '/var/lib/fakeframe/sys/thermal'
|
||||
CONTROL_PORT = 9999
|
||||
LOGS = '/var/log/fakeframe'
|
||||
USAGE = """fakeframe-ctl COMMAND
|
||||
pairing on|off Steam's "Pair new host" screen open or not
|
||||
answer approve|deny|timeout how the pairing prompt is answered
|
||||
steam on|off Steam client running (steam.pid, pipe, DevTools on 8080)
|
||||
sleep on|off headset asleep: 22 and 32000 accept but never answer
|
||||
sshd on|off sshd running (off: connection refused)
|
||||
devkit-service on|off steamos-devkit-service on 32000
|
||||
disk-full on|off fill the small ~/devkit-game filesystem
|
||||
runtime NAME installed|missing NAME: proton-experimental, proton-stable,
|
||||
SteamLinuxRuntime_4-arm64, SteamLinuxRuntime_4, lepton
|
||||
battery KEY=VALUE... e.g. capacity=15 status=Discharging current_now=-900000
|
||||
keys harness|none authorized_keys: only the harness key, or empty
|
||||
authorized-keys what ~/.ssh/authorized_keys holds now
|
||||
reset default state, nothing installed, harness key only
|
||||
state | calls [TOOL] | ping"""
|
||||
|
||||
_lock = threading.RLock()
|
||||
_procs = {}
|
||||
_blackhole = {'socks': [], 'conns': []}
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(time.strftime('%H:%M:%S ') + msg, flush=True)
|
||||
|
||||
|
||||
def as_user():
|
||||
env = {'HOME': HOME, 'USER': USER, 'LOGNAME': USER, 'SHELL': '/bin/bash',
|
||||
'PATH': '/usr/local/bin:/usr/bin:/bin', 'XDG_RUNTIME_DIR': f'/run/user/{PW.pw_uid}',
|
||||
'LANG': 'C.UTF-8'}
|
||||
return {'user': PW.pw_uid, 'group': PW.pw_gid, 'extra_groups': [], 'env': env, 'cwd': HOME}
|
||||
|
||||
|
||||
def chown(path):
|
||||
os.chown(path, PW.pw_uid, PW.pw_gid)
|
||||
|
||||
|
||||
# ---- processes ------------------------------------------------------------------
|
||||
|
||||
def spawn(name, argv, user=True, env=None):
|
||||
os.makedirs(LOGS, exist_ok=True)
|
||||
out = open(f'{LOGS}/{name}.log', 'ab')
|
||||
kw = as_user() if user else {'env': dict(os.environ)}
|
||||
kw['env'].update(env or {})
|
||||
_procs[name] = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=out, stderr=out,
|
||||
start_new_session=True, **kw)
|
||||
out.close()
|
||||
log(f'started {name} (pid {_procs[name].pid})')
|
||||
|
||||
|
||||
def stop(name, sig=15):
|
||||
p = _procs.pop(name, None)
|
||||
if p and p.poll() is None:
|
||||
p.send_signal(sig)
|
||||
try:
|
||||
p.wait(5)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
p.wait()
|
||||
log(f'stopped {name}')
|
||||
|
||||
|
||||
def running(name):
|
||||
p = _procs.get(name)
|
||||
return bool(p and p.poll() is None)
|
||||
|
||||
|
||||
def kill_ssh_sessions():
|
||||
"""Drop every SSH connection, as losing Wi-Fi does."""
|
||||
for comm_file in glob.glob('/proc/[0-9]*/comm'):
|
||||
try:
|
||||
with open(comm_file) as f:
|
||||
comm = f.read().strip()
|
||||
if comm.startswith('sshd'):
|
||||
os.kill(int(comm_file.split('/')[2]), 9)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class Blackhole:
|
||||
"""Accept on a port and never answer, so ssh waits and times out. An unreachable
|
||||
Frame times out rather than refusing (seen over Tailscale on 2026-09-27);
|
||||
a closed port would fail at once, which hides timeout bugs."""
|
||||
|
||||
@staticmethod
|
||||
def start(port):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(('0.0.0.0', port))
|
||||
s.listen(64)
|
||||
_blackhole['socks'].append(s)
|
||||
|
||||
def accept():
|
||||
while True:
|
||||
try:
|
||||
c, _ = s.accept()
|
||||
except OSError:
|
||||
return
|
||||
_blackhole['conns'].append(c)
|
||||
threading.Thread(target=accept, daemon=True).start()
|
||||
|
||||
@staticmethod
|
||||
def stop():
|
||||
for s in _blackhole['socks'] + _blackhole['conns']:
|
||||
try:
|
||||
s.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
s.close()
|
||||
_blackhole['socks'].clear()
|
||||
_blackhole['conns'].clear()
|
||||
|
||||
|
||||
def reconcile():
|
||||
"""Make the running processes match the switches."""
|
||||
with _lock:
|
||||
sw = fs.read()['switches']
|
||||
asleep = sw['asleep']
|
||||
if asleep and not _blackhole['socks']:
|
||||
stop('sshd')
|
||||
stop('devkit-service')
|
||||
kill_ssh_sessions()
|
||||
Blackhole.start(22)
|
||||
Blackhole.start(32000)
|
||||
if not asleep and _blackhole['socks']:
|
||||
Blackhole.stop()
|
||||
want = {'sshd': sw['sshd'] and not asleep, 'devkit-service': sw['devkit_service'] and not asleep,
|
||||
'steam': sw['steam'], 'vrserver': True, 'plasmashell': True}
|
||||
for name, on in want.items():
|
||||
if on and not running(name):
|
||||
start(name)
|
||||
elif not on and running(name):
|
||||
stop(name)
|
||||
|
||||
|
||||
def start(name):
|
||||
if name == 'sshd':
|
||||
spawn('sshd', ['/usr/bin/sshd', '-D', '-e'], user=False)
|
||||
elif name == 'devkit-service':
|
||||
# Valve's service, unmodified, with a stand-in dbus module (no systemd-resolved here).
|
||||
spawn('devkit-service', ['python3', '/usr/lib/steamos-devkit/steamos-devkit-service.py'],
|
||||
env={'PYTHONPATH': f'{LIB}/pystubs'})
|
||||
elif name == 'steam':
|
||||
spawn('steam', ['python3', f'{LIB}/fakesteam.py'])
|
||||
else:
|
||||
# frame_status.py looks for these by process name (vrserver: SteamVR,
|
||||
# plasmashell: the headset desktop, which /api/clipboard also needs).
|
||||
spawn(name, [f'{LIB}/bin/{name}', 'infinity'],
|
||||
env={'DBUS_SESSION_BUS_ADDRESS': f'unix:path=/run/user/{PW.pw_uid}/bus'})
|
||||
|
||||
|
||||
# ---- the device's files -----------------------------------------------------------
|
||||
|
||||
def write(path, text, owner=True):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, 'w') as f:
|
||||
f.write(text)
|
||||
if owner:
|
||||
chown(path)
|
||||
|
||||
|
||||
def sysfs(state):
|
||||
"""Battery, charger and thermal zones, in the files frame_status.py reads.
|
||||
|
||||
/sys is read-only in a container, so compose mounts a volume over each of
|
||||
the two folders and again here, where it can be written
|
||||
(tests/fakeframe/compose.yaml); without those this does nothing.
|
||||
"""
|
||||
b = state['battery']
|
||||
if not os.path.isdir(POWER) or not os.path.isdir(THERMAL):
|
||||
log('no fake /sys: see the volumes in tests/fakeframe/compose.yaml')
|
||||
return
|
||||
try:
|
||||
for d in glob.glob(POWER + '/*') + glob.glob(THERMAL + '/thermal_zone*'):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
bat = POWER + '/max1720x_battery' # the fuel gauge frame_status.py was written against
|
||||
for k in ('capacity', 'status', 'voltage_now', 'current_now', 'time_to_full_now', 'temp', 'health'):
|
||||
write(f'{bat}/{k}', f'{b[k]}\n', owner=False)
|
||||
write(f'{bat}/type', 'Battery\n', owner=False)
|
||||
c = b.get('charger') or {}
|
||||
usb = POWER + '/usb'
|
||||
write(f'{usb}/type', 'USB\n', owner=False)
|
||||
write(f'{usb}/online', f"{c.get('online', 0)}\n", owner=False)
|
||||
for k in ('usb_type', 'voltage_now', 'current_now'):
|
||||
if k in c:
|
||||
write(f'{usb}/{k}', f'{c[k]}\n', owner=False)
|
||||
for i, t in enumerate(state['thermal_mc']):
|
||||
write(f'{THERMAL}/thermal_zone{i}/temp', f'{t}\n', owner=False)
|
||||
except OSError as e:
|
||||
log(f'no fake /sys ({e}); see the volumes in tests/fakeframe/compose.yaml')
|
||||
|
||||
|
||||
def runtimes(state):
|
||||
"""Installed compat tools as Steam app manifests, and the Lepton launcher itself."""
|
||||
apps = fs.STEAM_ROOT + '/steamapps'
|
||||
for alias, installed in state['runtimes'].items():
|
||||
acf = f'{apps}/appmanifest_{fs.RUNTIME_APPIDS[alias]}.acf'
|
||||
if installed:
|
||||
write(acf, '"AppState"\n{\n\t"appid"\t\t"%d"\n\t"name"\t\t"%s"\n\t"SizeOnDisk"\t\t"%d"\n}\n'
|
||||
% (fs.RUNTIME_APPIDS[alias], fs.RUNTIME_NAMES[alias], 900000000))
|
||||
elif os.path.exists(acf):
|
||||
os.unlink(acf)
|
||||
if state['runtimes'].get('lepton'):
|
||||
os.makedirs(os.path.dirname(fs.LEPTON), exist_ok=True)
|
||||
shutil.copy(f'{LIB}/lepton.py', fs.LEPTON)
|
||||
os.chmod(fs.LEPTON, 0o755)
|
||||
elif os.path.exists(fs.LEPTON):
|
||||
os.unlink(fs.LEPTON)
|
||||
for app in state['steam']['apps']:
|
||||
acf = f"{apps}/appmanifest_{app['appid']}.acf"
|
||||
if app['installed']:
|
||||
write(acf, '"AppState"\n{\n\t"appid"\t\t"%d"\n\t"name"\t\t"%s"\n\t"SizeOnDisk"\t\t"%d"\n}\n'
|
||||
% (app['appid'], app['display_name'], app['size']))
|
||||
subprocess.run(['chown', '-R', f'{USER}:{USER}', fs.STEAM_ROOT])
|
||||
|
||||
|
||||
def disk_full(on):
|
||||
if on:
|
||||
if os.path.ismount(fs.DEVKIT_GAMES) is False:
|
||||
raise ValueError(f'disk-full needs {fs.DEVKIT_GAMES} to be its own small filesystem (compose tmpfs)')
|
||||
st = os.statvfs(fs.DEVKIT_GAMES)
|
||||
size = max(0, st.f_bavail * st.f_frsize - 64 * 1024)
|
||||
fd = os.open(BALLAST, os.O_WRONLY | os.O_CREAT, 0o600)
|
||||
try:
|
||||
os.posix_fallocate(fd, 0, size)
|
||||
finally:
|
||||
os.close(fd)
|
||||
elif os.path.exists(BALLAST):
|
||||
os.unlink(BALLAST)
|
||||
|
||||
|
||||
def set_keys(which):
|
||||
os.makedirs(os.path.dirname(AUTH_KEYS), mode=0o700, exist_ok=True)
|
||||
chown(os.path.dirname(AUTH_KEYS))
|
||||
text = ''
|
||||
if which == 'harness':
|
||||
with open(HARNESS_KEY + '.pub') as f:
|
||||
text = f.read()
|
||||
write(AUTH_KEYS, text)
|
||||
os.chmod(AUTH_KEYS, 0o600)
|
||||
|
||||
|
||||
def harness_key():
|
||||
"""The key the host container logs in with, shared through the /keys volume."""
|
||||
os.makedirs(KEYS, exist_ok=True)
|
||||
if not os.path.exists(HARNESS_KEY):
|
||||
subprocess.run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', 'fakeframe-harness',
|
||||
'-f', HARNESS_KEY], check=True)
|
||||
os.chmod(HARNESS_KEY, 0o644) # the host container copies it into its own ~/.ssh with 0600
|
||||
|
||||
|
||||
def clean_home():
|
||||
"""Everything Frame Control or a test put on the "headset"."""
|
||||
for c in list(fs.read()['lepton'].values()):
|
||||
try:
|
||||
os.kill(c['pid'], 15)
|
||||
except (OSError, KeyError, TypeError):
|
||||
pass
|
||||
for pattern in (fs.DEVKIT_GAMES + '/*', fs.DEVKIT_GAMES + '/.[!.]*', HOME + '/devkit-utils',
|
||||
HOME + '/.devkit-utils.frame-control', HOME + '/Applications', HOME + '/Downloads/*',
|
||||
fs.STEAM_ROOT + '/steamapps/compatdata', fs.STEAM_ROOT + '/steamapps/shadercache',
|
||||
fs.STEAM_ROOT + '/logs', '/var/log/fakeframe/devkit-*', '/var/log/fakeframe/shortcut-*'):
|
||||
for p in glob.glob(pattern):
|
||||
subprocess.run(['rm', '-rf', p])
|
||||
os.makedirs(HOME + '/Downloads', exist_ok=True)
|
||||
chown(HOME + '/Downloads')
|
||||
chown(fs.DEVKIT_GAMES)
|
||||
|
||||
|
||||
def apply_files():
|
||||
state = fs.read()
|
||||
sysfs(state)
|
||||
runtimes(state)
|
||||
|
||||
|
||||
def reset():
|
||||
with _lock:
|
||||
stop('steam')
|
||||
clean_home()
|
||||
fs.reset()
|
||||
env_switches()
|
||||
set_keys('harness')
|
||||
disk_full(False)
|
||||
apply_files()
|
||||
reconcile()
|
||||
|
||||
|
||||
def env_switches():
|
||||
"""FAKEFRAME_PAIRING_MODE=1 and the like set a switch's value at start."""
|
||||
with fs.update() as s:
|
||||
for k in s['switches']:
|
||||
v = os.environ.get('FAKEFRAME_' + k.upper())
|
||||
if v is not None:
|
||||
s['switches'][k] = v if k == 'pairing_answer' else v in ('1', 'on', 'true', 'yes')
|
||||
|
||||
|
||||
# ---- commands ----------------------------------------------------------------------
|
||||
|
||||
ON_OFF = {'on': True, 'off': False}
|
||||
SWITCH = {'pairing': 'pairing_mode', 'steam': 'steam', 'sleep': 'asleep', 'sshd': 'sshd',
|
||||
'devkit-service': 'devkit_service'}
|
||||
|
||||
|
||||
def command(args):
|
||||
if not args or args[0] == 'help':
|
||||
return {'usage': USAGE}
|
||||
cmd, rest = args[0], args[1:]
|
||||
if cmd == 'state':
|
||||
return fs.read()
|
||||
if cmd == 'calls':
|
||||
return fs.calls(rest[0] if rest else None)
|
||||
if cmd == 'ping':
|
||||
return ping()
|
||||
if cmd == 'reset':
|
||||
reset()
|
||||
return {'ok': True}
|
||||
if cmd in SWITCH and len(rest) == 1 and rest[0] in ON_OFF:
|
||||
with fs.update() as s:
|
||||
s['switches'][SWITCH[cmd]] = ON_OFF[rest[0]]
|
||||
reconcile()
|
||||
return {'ok': True, SWITCH[cmd]: ON_OFF[rest[0]]}
|
||||
if cmd == 'answer' and rest and rest[0] in ('approve', 'deny', 'timeout'):
|
||||
with fs.update() as s:
|
||||
s['switches']['pairing_answer'] = rest[0]
|
||||
return {'ok': True}
|
||||
if cmd == 'disk-full' and len(rest) == 1 and rest[0] in ON_OFF:
|
||||
with _lock:
|
||||
disk_full(ON_OFF[rest[0]])
|
||||
with fs.update() as s:
|
||||
s['switches']['disk_full'] = ON_OFF[rest[0]]
|
||||
return {'ok': True}
|
||||
if cmd == 'runtime' and len(rest) == 2 and rest[1] in ('installed', 'missing'):
|
||||
with fs.update() as s:
|
||||
if rest[0] not in s['runtimes']:
|
||||
raise ValueError(f'unknown runtime {rest[0]}')
|
||||
s['runtimes'][rest[0]] = rest[1] == 'installed'
|
||||
apply_files()
|
||||
return {'ok': True}
|
||||
if cmd == 'battery' and rest:
|
||||
with fs.update() as s:
|
||||
for kv in rest:
|
||||
k, _, v = kv.partition('=')
|
||||
if k not in s['battery'] or k == 'charger':
|
||||
raise ValueError(f'unknown battery field {k}')
|
||||
s['battery'][k] = int(v) if v.lstrip('-').isdigit() else v
|
||||
apply_files()
|
||||
return {'ok': True}
|
||||
if cmd == 'keys' and rest and rest[0] in ('harness', 'none'):
|
||||
set_keys(rest[0])
|
||||
return {'ok': True}
|
||||
if cmd == 'authorized-keys':
|
||||
with open(AUTH_KEYS) as f:
|
||||
return {'text': f.read()}
|
||||
raise ValueError(f'unknown command {" ".join(args)!r}; try help')
|
||||
|
||||
|
||||
def port_open(port):
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=1):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def ping():
|
||||
sw = fs.read()['switches']
|
||||
checks = {}
|
||||
if sw['sshd'] and not sw['asleep']:
|
||||
checks['sshd'] = port_open(22)
|
||||
if sw['devkit_service'] and not sw['asleep']:
|
||||
checks['devkit_service'] = port_open(32000)
|
||||
if sw['steam']:
|
||||
checks['devtools'] = port_open(8080) and fs.steam_pid() is not None
|
||||
return {'ok': all(checks.values()), 'checks': checks}
|
||||
|
||||
|
||||
class Control(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def reply(self, obj, status=200):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(status)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
path, _, query = self.path.partition('?')
|
||||
args = {'/state': ['state'], '/calls': ['calls'] + ([query] if query else []), '/ping': ['ping']}.get(path)
|
||||
if not args:
|
||||
self.reply({'error': 'not found'}, 404)
|
||||
return
|
||||
self.reply(command(args))
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != '/ctl':
|
||||
self.reply({'error': 'not found'}, 404)
|
||||
return
|
||||
try:
|
||||
body = json.loads(self.rfile.read(int(self.headers.get('Content-Length') or 0)) or b'{}')
|
||||
self.reply(command([str(a) for a in body.get('args', [])]))
|
||||
except (ValueError, OSError) as e:
|
||||
self.reply({'error': str(e)}, 400)
|
||||
|
||||
|
||||
def main():
|
||||
os.umask(0o022)
|
||||
harness_key()
|
||||
os.makedirs(fs.DIR, mode=0o777, exist_ok=True)
|
||||
os.chmod(fs.DIR, 0o777)
|
||||
os.makedirs(f'/run/user/{PW.pw_uid}', exist_ok=True)
|
||||
chown(f'/run/user/{PW.pw_uid}')
|
||||
reset()
|
||||
httpd = ThreadingHTTPServer(('0.0.0.0', CONTROL_PORT), Control)
|
||||
httpd.daemon_threads = True
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
log(f'fake Frame up; control on :{CONTROL_PORT}')
|
||||
while True: # restart anything that died unasked, as systemd would
|
||||
time.sleep(1)
|
||||
try:
|
||||
reconcile()
|
||||
except Exception as e: # keep supervising
|
||||
log(f'reconcile: {type(e).__name__}: {e}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||