Merge origin/main: background install jobs and tabbed pages
Flatpak installs record their outcome inside main's background job; failed jobs are diagnostics too. The Privacy panel lives on the Tools page (#privacy opens it), tab analytics use the four page names, and "Test it now?" reads the install job's result. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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,7 +29,7 @@ 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
|
||||
@@ -61,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
|
||||
@@ -4,3 +4,4 @@ apk-catalog/data/cache/
|
||||
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,6 +103,9 @@ 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
|
||||
@@ -205,6 +208,9 @@ 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 |
|
||||
| [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>
|
||||
@@ -231,6 +237,7 @@ 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
|
||||
```
|
||||
|
||||
|
||||
@@ -248,6 +248,7 @@ 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(); });
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 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.
|
||||
@@ -9,6 +10,7 @@ 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: {
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -51,7 +51,13 @@ 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 |
|
||||
| **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 +85,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)
|
||||
|
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,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.
|
||||
@@ -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,150 @@
|
||||
# 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.
|
||||
@@ -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,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,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()
|
||||
@@ -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,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,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()
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stand-in for Lepton's launcher (~/.local/share/Steam/steamapps/common/Lepton/lepton).
|
||||
|
||||
frame/android/lepton-app.sh runs it as `lepton waitforexitandrun -- APK` with
|
||||
the Steam compat variables set. Like the real one (docs/apks.md, verified
|
||||
2026-09-25, BUILD_ID 20260922.6101926) it:
|
||||
- dies with "unbound variable" when STEAM_COMPAT_SHADER_PATH isn't set;
|
||||
- needs STEAM_COMPAT_DATA_PATH under ~/.local/share/Steam (only that tree is
|
||||
mounted in the container; elsewhere the app crashes with ENOENT);
|
||||
- runs a "steamlaunch" container named lepton-steamlaunch-<SteamAppId> when
|
||||
SteamAppId is set, else Lepton Development (lepton-dev);
|
||||
- opens ADB on 0.0.0.0: 5555 for Lepton Development, the next free port for
|
||||
each own instance (README security notes, 2026-09-25);
|
||||
- shows a flat window only when lepton-show-flatscreen sits next to the APK.
|
||||
There's no Android inside: it records the launch and holds the port until
|
||||
`podman stop` (the podman stub) ends it.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, '/usr/local/lib/fakeframe')
|
||||
import fakeframe_state as fs # noqa: E402
|
||||
|
||||
ENV = ('SteamAppId', 'STEAM_COMPAT_INSTALL_PATH', 'STEAM_COMPAT_DATA_PATH', 'STEAM_COMPAT_SHADER_PATH',
|
||||
'STEAM_FOSSILIZE_DUMP_PATH', 'IS_PARENT')
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
env = {k: os.environ.get(k) for k in ENV}
|
||||
fs.log('lepton', args=args, env=env)
|
||||
for k in ('STEAM_COMPAT_SHADER_PATH', 'STEAM_COMPAT_DATA_PATH', 'STEAM_COMPAT_INSTALL_PATH'):
|
||||
if not env[k]:
|
||||
sys.exit(f'{sys.argv[0]}: line 1: {k}: unbound variable')
|
||||
if not os.path.realpath(env['STEAM_COMPAT_DATA_PATH']).startswith(fs.STEAM_ROOT + '/'):
|
||||
sys.exit('ENOENT: STEAM_COMPAT_DATA_PATH is outside ~/.local/share/Steam, which is all the container sees')
|
||||
apk = args[-1] if args else ''
|
||||
if not apk.endswith('.apk') or not os.path.isfile(apk):
|
||||
sys.exit(f'lepton: no APK at {apk!r}')
|
||||
steamlaunch = bool(env['SteamAppId'])
|
||||
name = f"lepton-steamlaunch-{env['SteamAppId']}" if steamlaunch else 'lepton-dev'
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
with fs.update() as state:
|
||||
if name in state['lepton']:
|
||||
sys.exit(f'lepton: {name} is already running')
|
||||
used = {c['port'] for c in state['lepton'].values()}
|
||||
for port in ([5555] if not steamlaunch else range(5556, 5600)):
|
||||
if port in used:
|
||||
continue
|
||||
try:
|
||||
sock.bind(('0.0.0.0', port))
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
else:
|
||||
sys.exit('lepton: no free ADB port')
|
||||
sock.listen(8)
|
||||
os.makedirs(os.path.join(env['STEAM_COMPAT_DATA_PATH'], 'internal'), exist_ok=True)
|
||||
state['lepton'][name] = {'port': port, 'pid': os.getpid(), 'apk': apk, 'started': time.time(),
|
||||
'flatscreen': os.path.exists(os.path.join(os.path.dirname(apk),
|
||||
'lepton-show-flatscreen'))}
|
||||
|
||||
def stop(*_):
|
||||
with fs.update() as state:
|
||||
state['lepton'].pop(name, None)
|
||||
fs.log('lepton', stopped=name)
|
||||
os._exit(0)
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
print(json.dumps({'container': name, 'adb_port': port}), flush=True)
|
||||
while True:
|
||||
conn, _ = sock.accept() # nothing speaks ADB here; just close
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Stand-in for dbus-python, just enough for Valve's steamos-devkit-service.
|
||||
|
||||
The service advertises _steamos-devkit._tcp through systemd-resolved's
|
||||
RegisterService over the system bus (on the Frame, `frame` answered mDNS,
|
||||
docs/ssh.md, 2026-09-26). A container has no system bus or resolved, so
|
||||
this records the call in the fake Frame's log instead.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from . import exceptions # noqa: F401
|
||||
|
||||
sys.path.insert(0, '/usr/local/lib/fakeframe')
|
||||
import fakeframe_state as fs # noqa: E402
|
||||
|
||||
|
||||
class Array(list):
|
||||
def __init__(self, items=(), signature=None):
|
||||
super().__init__(items)
|
||||
|
||||
|
||||
class Dictionary(dict):
|
||||
def __init__(self, items=(), signature=None):
|
||||
super().__init__(items)
|
||||
|
||||
|
||||
def UInt16(v):
|
||||
return int(v)
|
||||
|
||||
|
||||
def _plain(v):
|
||||
if isinstance(v, dict):
|
||||
return {k: _plain(x) for k, x in v.items()}
|
||||
if isinstance(v, list):
|
||||
if all(isinstance(x, int) for x in v):
|
||||
return bytes(v).decode('utf-8', 'replace')
|
||||
return [_plain(x) for x in v]
|
||||
return v
|
||||
|
||||
|
||||
class _Object:
|
||||
def get_dbus_method(self, name, interface=None):
|
||||
def call(*args):
|
||||
fs.log('resolve1', method=name, args=_plain(list(args)))
|
||||
return f'/org/freedesktop/resolve1/dnssd/{args[0]}' if name == 'RegisterService' else None
|
||||
return call
|
||||
|
||||
|
||||
class SystemBus:
|
||||
def get_object(self, bus_name, path):
|
||||
return _Object()
|
||||
@@ -0,0 +1,3 @@
|
||||
class DBusException(Exception):
|
||||
def get_dbus_name(self):
|
||||
return None
|
||||
@@ -0,0 +1,504 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 2.1, February 1999
|
||||
|
||||
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
[This is the first released version of the Lesser GPL. It also counts
|
||||
as the successor of the GNU Library Public License, version 2, hence
|
||||
the version number 2.1.]
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
Licenses are intended to guarantee your freedom to share and change
|
||||
free software--to make sure the software is free for all its users.
|
||||
|
||||
This license, the Lesser General Public License, applies to some
|
||||
specially designated software packages--typically libraries--of the
|
||||
Free Software Foundation and other authors who decide to use it. You
|
||||
can use it too, but we suggest you first think carefully about whether
|
||||
this license or the ordinary General Public License is the better
|
||||
strategy to use in any particular case, based on the explanations below.
|
||||
|
||||
When we speak of free software, we are referring to freedom of use,
|
||||
not price. Our General Public Licenses are designed to make sure that
|
||||
you have the freedom to distribute copies of free software (and charge
|
||||
for this service if you wish); that you receive source code or can get
|
||||
it if you want it; that you can change the software and use pieces of
|
||||
it in new free programs; and that you are informed that you can do
|
||||
these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
distributors to deny you these rights or to ask you to surrender these
|
||||
rights. These restrictions translate to certain responsibilities for
|
||||
you if you distribute copies of the library or if you modify it.
|
||||
|
||||
For example, if you distribute copies of the library, whether gratis
|
||||
or for a fee, you must give the recipients all the rights that we gave
|
||||
you. You must make sure that they, too, receive or can get the source
|
||||
code. If you link other code with the library, you must provide
|
||||
complete object files to the recipients, so that they can relink them
|
||||
with the library after making changes to the library and recompiling
|
||||
it. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with a two-step method: (1) we copyright the
|
||||
library, and (2) we offer you this license, which gives you legal
|
||||
permission to copy, distribute and/or modify the library.
|
||||
|
||||
To protect each distributor, we want to make it very clear that
|
||||
there is no warranty for the free library. Also, if the library is
|
||||
modified by someone else and passed on, the recipients should know
|
||||
that what they have is not the original version, so that the original
|
||||
author's reputation will not be affected by problems that might be
|
||||
introduced by others.
|
||||
|
||||
Finally, software patents pose a constant threat to the existence of
|
||||
any free program. We wish to make sure that a company cannot
|
||||
effectively restrict the users of a free program by obtaining a
|
||||
restrictive license from a patent holder. Therefore, we insist that
|
||||
any patent license obtained for a version of the library must be
|
||||
consistent with the full freedom of use specified in this license.
|
||||
|
||||
Most GNU software, including some libraries, is covered by the
|
||||
ordinary GNU General Public License. This license, the GNU Lesser
|
||||
General Public License, applies to certain designated libraries, and
|
||||
is quite different from the ordinary General Public License. We use
|
||||
this license for certain libraries in order to permit linking those
|
||||
libraries into non-free programs.
|
||||
|
||||
When a program is linked with a library, whether statically or using
|
||||
a shared library, the combination of the two is legally speaking a
|
||||
combined work, a derivative of the original library. The ordinary
|
||||
General Public License therefore permits such linking only if the
|
||||
entire combination fits its criteria of freedom. The Lesser General
|
||||
Public License permits more lax criteria for linking other code with
|
||||
the library.
|
||||
|
||||
We call this license the "Lesser" General Public License because it
|
||||
does Less to protect the user's freedom than the ordinary General
|
||||
Public License. It also provides other free software developers Less
|
||||
of an advantage over competing non-free programs. These disadvantages
|
||||
are the reason we use the ordinary General Public License for many
|
||||
libraries. However, the Lesser license provides advantages in certain
|
||||
special circumstances.
|
||||
|
||||
For example, on rare occasions, there may be a special need to
|
||||
encourage the widest possible use of a certain library, so that it becomes
|
||||
a de-facto standard. To achieve this, non-free programs must be
|
||||
allowed to use the library. A more frequent case is that a free
|
||||
library does the same job as widely used non-free libraries. In this
|
||||
case, there is little to gain by limiting the free library to free
|
||||
software only, so we use the Lesser General Public License.
|
||||
|
||||
In other cases, permission to use a particular library in non-free
|
||||
programs enables a greater number of people to use a large body of
|
||||
free software. For example, permission to use the GNU C Library in
|
||||
non-free programs enables many more people to use the whole GNU
|
||||
operating system, as well as its variant, the GNU/Linux operating
|
||||
system.
|
||||
|
||||
Although the Lesser General Public License is Less protective of the
|
||||
users' freedom, it does ensure that the user of a program that is
|
||||
linked with the Library has the freedom and the wherewithal to run
|
||||
that program using a modified version of the Library.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow. Pay close attention to the difference between a
|
||||
"work based on the library" and a "work that uses the library". The
|
||||
former contains code derived from the library, whereas the latter must
|
||||
be combined with the library in order to run.
|
||||
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License Agreement applies to any software library or other
|
||||
program which contains a notice placed by the copyright holder or
|
||||
other authorized party saying it may be distributed under the terms of
|
||||
this Lesser General Public License (also called "this License").
|
||||
Each licensee is addressed as "you".
|
||||
|
||||
A "library" means a collection of software functions and/or data
|
||||
prepared so as to be conveniently linked with application programs
|
||||
(which use some of those functions and data) to form executables.
|
||||
|
||||
The "Library", below, refers to any such software library or work
|
||||
which has been distributed under these terms. A "work based on the
|
||||
Library" means either the Library or any derivative work under
|
||||
copyright law: that is to say, a work containing the Library or a
|
||||
portion of it, either verbatim or with modifications and/or translated
|
||||
straightforwardly into another language. (Hereinafter, translation is
|
||||
included without limitation in the term "modification".)
|
||||
|
||||
"Source code" for a work means the preferred form of the work for
|
||||
making modifications to it. For a library, complete source code means
|
||||
all the source code for all modules it contains, plus any associated
|
||||
interface definition files, plus the scripts used to control compilation
|
||||
and installation of the library.
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running a program using the Library is not restricted, and output from
|
||||
such a program is covered only if its contents constitute a work based
|
||||
on the Library (independent of the use of the Library in a tool for
|
||||
writing it). Whether that is true depends on what the Library does
|
||||
and what the program that uses the Library does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Library's
|
||||
complete source code as you receive it, in any medium, provided that
|
||||
you conspicuously and appropriately publish on each copy an
|
||||
appropriate copyright notice and disclaimer of warranty; keep intact
|
||||
all the notices that refer to this License and to the absence of any
|
||||
warranty; and distribute a copy of this License along with the
|
||||
Library.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy,
|
||||
and you may at your option offer warranty protection in exchange for a
|
||||
fee.
|
||||
|
||||
2. You may modify your copy or copies of the Library or any portion
|
||||
of it, thus forming a work based on the Library, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) The modified work must itself be a software library.
|
||||
|
||||
b) You must cause the files modified to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
c) You must cause the whole of the work to be licensed at no
|
||||
charge to all third parties under the terms of this License.
|
||||
|
||||
d) If a facility in the modified Library refers to a function or a
|
||||
table of data to be supplied by an application program that uses
|
||||
the facility, other than as an argument passed when the facility
|
||||
is invoked, then you must make a good faith effort to ensure that,
|
||||
in the event an application does not supply such function or
|
||||
table, the facility still operates, and performs whatever part of
|
||||
its purpose remains meaningful.
|
||||
|
||||
(For example, a function in a library to compute square roots has
|
||||
a purpose that is entirely well-defined independent of the
|
||||
application. Therefore, Subsection 2d requires that any
|
||||
application-supplied function or table used by this function must
|
||||
be optional: if the application does not supply it, the square
|
||||
root function must still compute square roots.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Library,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Library, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote
|
||||
it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Library.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Library
|
||||
with the Library (or with a work based on the Library) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may opt to apply the terms of the ordinary GNU General Public
|
||||
License instead of this License to a given copy of the Library. To do
|
||||
this, you must alter all the notices that refer to this License, so
|
||||
that they refer to the ordinary GNU General Public License, version 2,
|
||||
instead of to this License. (If a newer version than version 2 of the
|
||||
ordinary GNU General Public License has appeared, then you can specify
|
||||
that version instead if you wish.) Do not make any other change in
|
||||
these notices.
|
||||
|
||||
Once this change is made in a given copy, it is irreversible for
|
||||
that copy, so the ordinary GNU General Public License applies to all
|
||||
subsequent copies and derivative works made from that copy.
|
||||
|
||||
This option is useful when you wish to copy part of the code of
|
||||
the Library into a program that is not a library.
|
||||
|
||||
4. You may copy and distribute the Library (or a portion or
|
||||
derivative of it, under Section 2) in object code or executable form
|
||||
under the terms of Sections 1 and 2 above provided that you accompany
|
||||
it with the complete corresponding machine-readable source code, which
|
||||
must be distributed under the terms of Sections 1 and 2 above on a
|
||||
medium customarily used for software interchange.
|
||||
|
||||
If distribution of object code is made by offering access to copy
|
||||
from a designated place, then offering equivalent access to copy the
|
||||
source code from the same place satisfies the requirement to
|
||||
distribute the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
5. A program that contains no derivative of any portion of the
|
||||
Library, but is designed to work with the Library by being compiled or
|
||||
linked with it, is called a "work that uses the Library". Such a
|
||||
work, in isolation, is not a derivative work of the Library, and
|
||||
therefore falls outside the scope of this License.
|
||||
|
||||
However, linking a "work that uses the Library" with the Library
|
||||
creates an executable that is a derivative of the Library (because it
|
||||
contains portions of the Library), rather than a "work that uses the
|
||||
library". The executable is therefore covered by this License.
|
||||
Section 6 states terms for distribution of such executables.
|
||||
|
||||
When a "work that uses the Library" uses material from a header file
|
||||
that is part of the Library, the object code for the work may be a
|
||||
derivative work of the Library even though the source code is not.
|
||||
Whether this is true is especially significant if the work can be
|
||||
linked without the Library, or if the work is itself a library. The
|
||||
threshold for this to be true is not precisely defined by law.
|
||||
|
||||
If such an object file uses only numerical parameters, data
|
||||
structure layouts and accessors, and small macros and small inline
|
||||
functions (ten lines or less in length), then the use of the object
|
||||
file is unrestricted, regardless of whether it is legally a derivative
|
||||
work. (Executables containing this object code plus portions of the
|
||||
Library will still fall under Section 6.)
|
||||
|
||||
Otherwise, if the work is a derivative of the Library, you may
|
||||
distribute the object code for the work under the terms of Section 6.
|
||||
Any executables containing that work also fall under Section 6,
|
||||
whether or not they are linked directly with the Library itself.
|
||||
|
||||
6. As an exception to the Sections above, you may also combine or
|
||||
link a "work that uses the Library" with the Library to produce a
|
||||
work containing portions of the Library, and distribute that work
|
||||
under terms of your choice, provided that the terms permit
|
||||
modification of the work for the customer's own use and reverse
|
||||
engineering for debugging such modifications.
|
||||
|
||||
You must give prominent notice with each copy of the work that the
|
||||
Library is used in it and that the Library and its use are covered by
|
||||
this License. You must supply a copy of this License. If the work
|
||||
during execution displays copyright notices, you must include the
|
||||
copyright notice for the Library among them, as well as a reference
|
||||
directing the user to the copy of this License. Also, you must do one
|
||||
of these things:
|
||||
|
||||
a) Accompany the work with the complete corresponding
|
||||
machine-readable source code for the Library including whatever
|
||||
changes were used in the work (which must be distributed under
|
||||
Sections 1 and 2 above); and, if the work is an executable linked
|
||||
with the Library, with the complete machine-readable "work that
|
||||
uses the Library", as object code and/or source code, so that the
|
||||
user can modify the Library and then relink to produce a modified
|
||||
executable containing the modified Library. (It is understood
|
||||
that the user who changes the contents of definitions files in the
|
||||
Library will not necessarily be able to recompile the application
|
||||
to use the modified definitions.)
|
||||
|
||||
b) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (1) uses at run time a
|
||||
copy of the library already present on the user's computer system,
|
||||
rather than copying library functions into the executable, and (2)
|
||||
will operate properly with a modified version of the library, if
|
||||
the user installs one, as long as the modified version is
|
||||
interface-compatible with the version that the work was made with.
|
||||
|
||||
c) Accompany the work with a written offer, valid for at
|
||||
least three years, to give the same user the materials
|
||||
specified in Subsection 6a, above, for a charge no more
|
||||
than the cost of performing this distribution.
|
||||
|
||||
d) If distribution of the work is made by offering access to copy
|
||||
from a designated place, offer equivalent access to copy the above
|
||||
specified materials from the same place.
|
||||
|
||||
e) Verify that the user has already received a copy of these
|
||||
materials or that you have already sent this user a copy.
|
||||
|
||||
For an executable, the required form of the "work that uses the
|
||||
Library" must include any data and utility programs needed for
|
||||
reproducing the executable from it. However, as a special exception,
|
||||
the materials to be distributed need not include anything that is
|
||||
normally distributed (in either source or binary form) with the major
|
||||
components (compiler, kernel, and so on) of the operating system on
|
||||
which the executable runs, unless that component itself accompanies
|
||||
the executable.
|
||||
|
||||
It may happen that this requirement contradicts the license
|
||||
restrictions of other proprietary libraries that do not normally
|
||||
accompany the operating system. Such a contradiction means you cannot
|
||||
use both them and the Library together in an executable that you
|
||||
distribute.
|
||||
|
||||
7. You may place library facilities that are a work based on the
|
||||
Library side-by-side in a single library together with other library
|
||||
facilities not covered by this License, and distribute such a combined
|
||||
library, provided that the separate distribution of the work based on
|
||||
the Library and of the other library facilities is otherwise
|
||||
permitted, and provided that you do these two things:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work
|
||||
based on the Library, uncombined with any other library
|
||||
facilities. This must be distributed under the terms of the
|
||||
Sections above.
|
||||
|
||||
b) Give prominent notice with the combined library of the fact
|
||||
that part of it is a work based on the Library, and explaining
|
||||
where to find the accompanying uncombined form of the same work.
|
||||
|
||||
8. You may not copy, modify, sublicense, link with, or distribute
|
||||
the Library except as expressly provided under this License. Any
|
||||
attempt otherwise to copy, modify, sublicense, link with, or
|
||||
distribute the Library is void, and will automatically terminate your
|
||||
rights under this License. However, parties who have received copies,
|
||||
or rights, from you under this License will not have their licenses
|
||||
terminated so long as such parties remain in full compliance.
|
||||
|
||||
9. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Library or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Library (or any work based on the
|
||||
Library), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Library or works based on it.
|
||||
|
||||
10. Each time you redistribute the Library (or any work based on the
|
||||
Library), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute, link with or modify the Library
|
||||
subject to these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties with
|
||||
this License.
|
||||
|
||||
11. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Library at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Library by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Library.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any
|
||||
particular circumstance, the balance of the section is intended to apply,
|
||||
and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
12. If the distribution and/or use of the Library is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Library under this License may add
|
||||
an explicit geographical distribution limitation excluding those countries,
|
||||
so that distribution is permitted only in or among countries not thus
|
||||
excluded. In such case, this License incorporates the limitation as if
|
||||
written in the body of this License.
|
||||
|
||||
13. The Free Software Foundation may publish revised and/or new
|
||||
versions of the Lesser General Public License from time to time.
|
||||
Such new versions will be similar in spirit to the present version,
|
||||
but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Library
|
||||
specifies a version number of this License which applies to it and
|
||||
"any later version", you have the option of following the terms and
|
||||
conditions either of that version or of any later version published by
|
||||
the Free Software Foundation. If the Library does not specify a
|
||||
license version number, you may choose any version ever published by
|
||||
the Free Software Foundation.
|
||||
|
||||
14. If you wish to incorporate parts of the Library into other free
|
||||
programs whose distribution conditions are incompatible with these,
|
||||
write to the author to ask for permission. For software which is
|
||||
copyrighted by the Free Software Foundation, write to the Free
|
||||
Software Foundation; we sometimes make exceptions for this. Our
|
||||
decision will be guided by the two goals of preserving the free status
|
||||
of all derivatives of our free software and of promoting the sharing
|
||||
and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
|
||||
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
||||
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
|
||||
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
|
||||
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
|
||||
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
|
||||
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
|
||||
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
|
||||
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
|
||||
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
|
||||
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
|
||||
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
|
||||
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
|
||||
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Libraries
|
||||
|
||||
If you develop a new library, and you want it to be of the greatest
|
||||
possible use to the public, we recommend making it free software that
|
||||
everyone can redistribute and change. You can do so by permitting
|
||||
redistribution under these terms (or, alternatively, under the terms of the
|
||||
ordinary General Public License).
|
||||
|
||||
To apply these terms, attach the following notices to the library. It is
|
||||
safest to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
SteamOS Devkit Service
|
||||
Copyright (C) 2022 Valve Software inc.
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
|
||||
USA
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the library, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the
|
||||
library `Frob' (a library for tweaking knobs) written by James Random
|
||||
Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1990
|
||||
Ty Coon, President of Vice
|
||||
|
||||
That's all there is to it!
|
||||
@@ -0,0 +1,21 @@
|
||||
# Valve's steamos-devkit-service (vendored, for the fake Frame only)
|
||||
|
||||
Unmodified copy of [steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service):
|
||||
the HTTP service on port 32000 (`src/`) and its hooks (`hooks/`), which the
|
||||
fake Frame image installs at `/usr/lib/steamos-devkit/` and
|
||||
`/usr/share/steamos-devkit/hooks/`, where the service looks for them.
|
||||
|
||||
- Source: commit `74cf8fe` (2025-09-16, tag `v0.20250916.0`).
|
||||
- Licence: the repository's `LICENSE` is the LGPL 2.1 (copied here).
|
||||
`src/steamos-devkit-service.py` itself carries an MIT header, and
|
||||
`hooks/devkit-1-identify` and `hooks/install-ssh-key` carry LGPL-2.1+
|
||||
headers. Nothing here ships in Frame Control; it's only built into the
|
||||
test image.
|
||||
|
||||
The service imports `dbus` to advertise itself over mDNS through
|
||||
systemd-resolved, which a container doesn't have. The image puts a small
|
||||
stand-in `dbus` module on its `PYTHONPATH` (`rootfs/usr/local/lib/fakeframe/pystubs`)
|
||||
that records the registration instead. Everything else runs as Valve wrote it.
|
||||
|
||||
To update: copy `src/`, `hooks/` and `LICENSE` from a newer checkout and update
|
||||
the commit line above.
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import tempfile
|
||||
from urllib.parse import quote_plus as urllib_quote_plus
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
ENABLE_SSHD = '/usr/bin/steamos-polkit-helpers/steamos-enable-sshd'
|
||||
|
||||
logger = logging.getLogger(os.path.realpath(__file__))
|
||||
|
||||
import devkit_utils
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.setLevel(logging.INFO)
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(logging.INFO)
|
||||
ch.setFormatter(logging.Formatter('%(name)s:%(message)s'))
|
||||
logger.addHandler(ch)
|
||||
|
||||
request = open(sys.argv[1], 'rt').read()
|
||||
requestIP = 'Unknown'
|
||||
try:
|
||||
requestIP = sys.argv[2]
|
||||
except NameError:
|
||||
logger.warning('request IP not given as argument to hook')
|
||||
pass
|
||||
|
||||
key_identifier = request.split(' ')[2]
|
||||
request = f'Development host at IP {requestIP} key: {key_identifier!r}'
|
||||
|
||||
ret = {}
|
||||
|
||||
try:
|
||||
devkit_utils.validate_steam_client()
|
||||
except devkit_utils.SteamClientNotRunningException as e:
|
||||
msg = 'Steam is not running'
|
||||
logger.warning(msg)
|
||||
ret['error'] = msg
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(prefix='approve-ssh-key') as tempdir:
|
||||
logger.info('Sending pairing request to Steam')
|
||||
response = os.path.join(tempdir, 'response')
|
||||
cmd = 'approve-ssh-key?response={}&request={}'.format(
|
||||
urllib_quote_plus(response),
|
||||
urllib_quote_plus(request),
|
||||
)
|
||||
devkit_utils.execute_steam_client_command(cmd)
|
||||
try:
|
||||
with devkit_utils.wait_on_file_response(response, timeout=30) as f:
|
||||
logger.debug('Got response from Steam client')
|
||||
# Make sure sshd is enabled to support development features
|
||||
if os.path.exists(ENABLE_SSHD):
|
||||
subprocess.check_call(ENABLE_SSHD)
|
||||
else:
|
||||
subprocess.check_call('sudo systemctl enable --now sshd', shell=True)
|
||||
except devkit_utils.SteamResponse_Timeout:
|
||||
ret['error'] = 'timeout - Steam did not respond to the pairing request'
|
||||
except devkit_utils.SteamResponse_Error as e:
|
||||
ret['error'] = e.error_response
|
||||
|
||||
# json dictionary response gets written to stdout
|
||||
# NOTE: the devkit service unfortunately smashes stdout/stderr all together in a text response, but we can still work with that
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
print(json.dumps(ret))
|
||||
retcode = 1 if 'error' in ret else 0
|
||||
sys.exit(retcode)
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
# encoding: utf-8
|
||||
|
||||
# This file is part of steamos-devkit
|
||||
# SPDX-License-Identifier: LGPL-2.1+
|
||||
#
|
||||
# Copyright 2017-2018 Collabora Ltd
|
||||
#
|
||||
# This package is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU Lesser General Public
|
||||
# License as published by the Free Software Foundation; either
|
||||
# version 2.1 of the License, or (at your option) any later version.
|
||||
#
|
||||
# This package is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this package. If not, see
|
||||
# <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Sample script run to identify the machine we are running on. This
|
||||
# is used by the devkit daemon to choose an identity for the machine,
|
||||
# and can also be run directly.
|
||||
#
|
||||
# A script or binary (written in any language) named devkit-1-identify
|
||||
# can be placed in the same directory as this sample, and it will be
|
||||
# used preferentially.
|
||||
#
|
||||
# Execution environment:
|
||||
# - Runs as root, or as an ordinary user who can run games
|
||||
# - Part of a non-interactive secure shell session
|
||||
# Input:
|
||||
# - None
|
||||
# Output:
|
||||
# - Exit 0 on success or nonzero on error
|
||||
# - On success, must print JSON to stdout containing one object with
|
||||
# the following keys and string values, all of which are optional:
|
||||
# - "machine_id": The hexadecimal systemd/D-Bus machine-id(5)
|
||||
# (in the case of a conflict between systemd's /etc/machine-id and
|
||||
# D-Bus' traditional /var/lib/dbus/machine-id, the systemd version
|
||||
# should be preferred)
|
||||
# - "hostname": The machine-readable hostname as set by sethostname(2)
|
||||
# - "pretty_hostname": A human-friendly version of the hostname as
|
||||
# found in systemd's machine-info(5)
|
||||
# - "product_family", "product_name", "product_serial", "product_uuid",
|
||||
# "sys_vendor": as for /sys/devices/virtual/dmi/id
|
||||
# - "product": The best human-friendly description of the machine
|
||||
# hardware we can devise, for example "Alienware ASM100"
|
||||
# - "serial": The best hardware serial number we can devise
|
||||
# - "machine_name": The best unique name for the machine that we can
|
||||
# devise
|
||||
# - Any diagnostic messages must be printed to stderr only
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
_MACHINE_ID_RE = re.compile(r'^[0-9A-Fa-f]{32,32}$')
|
||||
_USELESS_HOSTNAMES = frozenset((
|
||||
'debian',
|
||||
'host',
|
||||
'localhost',
|
||||
'steamos',
|
||||
'ubuntu',
|
||||
))
|
||||
# The case combination sometimes varies, so this is lowercased and we
|
||||
# use lower() to compare.
|
||||
_USELESS_DMI_NAMES = frozenset((
|
||||
'to be filled by o.e.m.',
|
||||
))
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
info = {}
|
||||
steam_serialnumber = None
|
||||
|
||||
for k in ('product_family', 'product_name', 'product_serial',
|
||||
'product_uuid', 'sys_vendor'):
|
||||
try:
|
||||
with open('/sys/devices/virtual/dmi/id/{}'.format(k)) as reader:
|
||||
v = reader.read().strip()
|
||||
|
||||
if v and v.lower() not in _USELESS_DMI_NAMES:
|
||||
info[k] = v
|
||||
except (IOError, OSError, UnicodeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
for f in ('/etc/machine-id', '/var/lib/dbus/machine-id'):
|
||||
with open(f) as reader:
|
||||
v = reader.read().strip()
|
||||
|
||||
if _MACHINE_ID_RE.match(v):
|
||||
info['machine_id'] = v
|
||||
except (IOError, OSError, UnicodeError):
|
||||
pass
|
||||
|
||||
if 'product_family' in info and 'sys_vendor' in info:
|
||||
info['product'] = '{sys_vendor} {product_family}'.format(**info)
|
||||
elif 'product_name' in info and 'sys_vendor' in info:
|
||||
info['product'] = '{sys_vendor} {product_name}'.format(**info)
|
||||
elif 'product_family' in info:
|
||||
info['product'] = info['product_family']
|
||||
elif 'product_name' in info:
|
||||
info['product'] = info['product_name']
|
||||
|
||||
if os.access('/usr/bin/hostnamectl', os.X_OK):
|
||||
try:
|
||||
v = subprocess.check_output([
|
||||
'hostnamectl', '--pretty',
|
||||
]).decode('utf-8').strip()
|
||||
except (subprocess.CalledProcessError, UnicodeError):
|
||||
pass
|
||||
else:
|
||||
if v:
|
||||
info['pretty_hostname'] = v
|
||||
|
||||
v = subprocess.check_output([
|
||||
'hostnamectl', '--static',
|
||||
]).decode('utf-8').strip()
|
||||
|
||||
if v:
|
||||
info['hostname'] = v
|
||||
|
||||
if 'hostname' not in info:
|
||||
try:
|
||||
info['hostname'] = socket.gethostname()
|
||||
except (IOError, OSError, UnicodeError):
|
||||
pass
|
||||
|
||||
if steam_serialnumber:
|
||||
info['serial'] = steam_serialnumber
|
||||
elif 'product_serial' in info:
|
||||
info['serial'] = info['product_serial']
|
||||
|
||||
if 'product' in info:
|
||||
info['machine_name'] = info['product']
|
||||
|
||||
if 'hostname' in info and info['hostname'] not in _USELESS_HOSTNAMES:
|
||||
info['machine_name'] = info['hostname']
|
||||
|
||||
if 'pretty_hostname' in info:
|
||||
info['machine_name'] = info['pretty_hostname']
|
||||
|
||||
json.dump(info, sys.stdout, indent=4, sort_keys=True)
|
||||
print()
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python
|
||||
# encoding: utf-8
|
||||
"""Utility functions for the Steam client hook scripts"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import traceback
|
||||
import tempfile
|
||||
import json
|
||||
import logging
|
||||
import fcntl
|
||||
import errno
|
||||
import contextlib
|
||||
import time
|
||||
import fcntl
|
||||
|
||||
|
||||
import logging as logging_module
|
||||
logger = logging_module.getLogger(__name__)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def wrap_outputs(stderr_prefix):
|
||||
# capture stderr to file to support debugging
|
||||
stderr_fd = sys.stderr.fileno()
|
||||
tf = tempfile.NamedTemporaryFile(
|
||||
mode='w+',
|
||||
prefix=stderr_prefix,
|
||||
delete=True)
|
||||
if sys.version_info >= (3, 4):
|
||||
# this API in the os module is only available for python3
|
||||
# but it does not seem to work with subprocess anyway
|
||||
os.set_inheritable(tf.file.fileno(), True)
|
||||
assert os.get_inheritable(tf.file.fileno())
|
||||
sys.stderr = tf.file
|
||||
|
||||
# we can only write out a json response to stdout,
|
||||
# so redirect stdout to stderr,
|
||||
# and keep a handle on the original stdout for the response
|
||||
stdout_fd = os.dup(sys.stdout.fileno())
|
||||
os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
|
||||
|
||||
ctx = {}
|
||||
try:
|
||||
yield ctx
|
||||
except:
|
||||
logger.error(traceback.format_exc())
|
||||
finally:
|
||||
tf.flush()
|
||||
tf.seek(0)
|
||||
os.write(stderr_fd, tf.read().encode('utf-8'))
|
||||
if 'ret' in ctx:
|
||||
os.write(stdout_fd, json.dumps(ctx['ret']).encode('utf-8'))
|
||||
|
||||
|
||||
class SteamClientNotRunningException(Exception):
|
||||
def __init__(self, error_message):
|
||||
self.error_message = error_message
|
||||
|
||||
def __str__(self):
|
||||
return self.error_message
|
||||
|
||||
|
||||
def validate_steam_client():
|
||||
"""Verify that the steam client is running, and permissions are adequate"""
|
||||
pid_path = os.path.normpath(
|
||||
os.path.realpath(
|
||||
os.path.expanduser('~/.steam/steam.pid')))
|
||||
if not os.path.exists(pid_path):
|
||||
raise SteamClientNotRunningException('{0} does not exist'.format(pid_path))
|
||||
try:
|
||||
pid = int(open(pid_path, 'rt').read())
|
||||
except Exception:
|
||||
raise SteamClientNotRunningException('{0} is invalid'.format(pid_path))
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
raise SteamClientNotRunningException('{0} does not refer to a valid process'.format(pid_path))
|
||||
logger.info('Found steam client pid %s', pid)
|
||||
|
||||
|
||||
def execute_steam_client_command(cmd):
|
||||
"""Send a command to the steam client over the IPC pipe"""
|
||||
pipe_path = os.path.normpath(
|
||||
os.path.realpath(
|
||||
os.path.expanduser('~/.steam/steam.pipe')))
|
||||
try:
|
||||
pipe = open(pipe_path, 'wb+', 0)
|
||||
except IOError:
|
||||
raise Exception('cannot open steam client pipe')
|
||||
session_token = open(os.path.expanduser('~/.steam/steam.token')).read()
|
||||
pipe_cmd = 'devkit-1 steam://devkit-1/{0}/{1}'.format(
|
||||
session_token,
|
||||
cmd
|
||||
)
|
||||
logger.debug('Sending command line:')
|
||||
logger.debug(pipe_cmd)
|
||||
pipe.write('{0}\n'.format(pipe_cmd).encode('utf-8'))
|
||||
pipe.close()
|
||||
|
||||
|
||||
def save_argv(gameid, argv):
|
||||
"""Save command line and arguments if provided"""
|
||||
|
||||
if argv is None:
|
||||
return
|
||||
|
||||
argvfile = os.path.join(os.getenv("HOME"), "devkit-game",
|
||||
gameid + "-argv.json")
|
||||
try:
|
||||
with open(argvfile, "w") as argvf:
|
||||
fcntl.flock(argvf, fcntl.LOCK_EX)
|
||||
json.dump(argv, argvf)
|
||||
fcntl.flock(argvf, fcntl.LOCK_UN)
|
||||
except IOError:
|
||||
raise Exception(
|
||||
"Unable to open argv file for writing: {0}".format(argvfile))
|
||||
|
||||
|
||||
def obtain_argv(gameid, argv):
|
||||
"""Obtain command line with arguments"""
|
||||
|
||||
# If present and not None or [], just return the local arguments
|
||||
if argv:
|
||||
return argv
|
||||
|
||||
# From here, expect arguments to have been saved previously
|
||||
argvfile = os.path.join(os.getenv("HOME"), "devkit-game",
|
||||
gameid + "-argv.json")
|
||||
try:
|
||||
with open(argvfile, "r") as argvf:
|
||||
fcntl.flock(argvf, fcntl.LOCK_EX)
|
||||
argv = json.load(argvf)
|
||||
fcntl.flock(argvf, fcntl.LOCK_UN)
|
||||
except IOError:
|
||||
raise Exception(
|
||||
"Unable to open argv file for reading: {0}".format(argvfile))
|
||||
return argv
|
||||
|
||||
|
||||
def save_settings(gameid, data):
|
||||
"""Save settings"""
|
||||
settingsfile = os.path.join(os.getenv("HOME"), "devkit-game",
|
||||
gameid + "-settings.json")
|
||||
settings = dict()
|
||||
|
||||
if data.get('clear_settings', False):
|
||||
settings = {}
|
||||
else:
|
||||
try:
|
||||
with open(settingsfile, "r") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
settings = json.load(f)
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
except IOError as e:
|
||||
if (e.errno != errno.ENOENT):
|
||||
raise
|
||||
|
||||
# Merge settings from new json
|
||||
if 'settings' in data:
|
||||
settings.update(data['settings'])
|
||||
|
||||
try:
|
||||
with open(settingsfile, "w") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
json.dump(settings, f)
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
except (IOError):
|
||||
raise Exception(
|
||||
"Unable to open settings file for writing: {0}".format(
|
||||
settingsfile
|
||||
))
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def load_settings(gameid):
|
||||
settingsfile = os.path.join(os.getenv("HOME"), "devkit-game", gameid + '-settings.json')
|
||||
|
||||
if not os.path.isfile(settingsfile):
|
||||
return None
|
||||
|
||||
with open(settingsfile, "r") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
settings = json.load(f)
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
class SteamResponse_Timeout(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SteamResponse_Error(Exception):
|
||||
def __init__(self, error_response):
|
||||
self.error_response = error_response
|
||||
|
||||
def __str__(self):
|
||||
return self.error_response
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def wait_on_file_response(path, timeout=5):
|
||||
"""
|
||||
The pipe to the Steam Client is one way.
|
||||
Responses from the Steam Client are written to filesystem.
|
||||
Protocol is as follows:
|
||||
- Steam Client creates a 'path.lock' file
|
||||
- Steam Client writes either 'path' or 'path.error' to indicate a problem
|
||||
- Steam Client deletes 'path.lock'
|
||||
- Caller (us) can then read the response
|
||||
|
||||
NOTE 1: this function is used as a context manager and will block until a response comes in or timeout.
|
||||
|
||||
NOTE 2: the files are created by Steam when responding to a command. If the files already exist the response protocol will break.
|
||||
"""
|
||||
lock_path = '{0}.lock'.format(path)
|
||||
error_path = '{0}.error'.format(path)
|
||||
max_count = timeout
|
||||
while True:
|
||||
time.sleep(1)
|
||||
if os.path.exists(error_path) or os.path.exists(path) and not os.path.exists(lock_path):
|
||||
if os.path.exists(error_path):
|
||||
with open(error_path, 'r') as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
error_response = f.read()
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
raise SteamResponse_Error(error_response)
|
||||
with open(path, 'r') as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
success_response = f.read()
|
||||
yield success_response
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
return
|
||||
max_count -= 1
|
||||
if max_count > 0:
|
||||
continue
|
||||
raise SteamResponse_Timeout()
|
||||
|
||||
|
||||
# Setting up as a context manager so we never miss the deletion
|
||||
# Creating a temporary .lock file to guard the create operation
|
||||
@contextlib.contextmanager
|
||||
def create_pid(pid_path):
|
||||
os.makedirs(os.path.dirname(pid_path), exist_ok=True)
|
||||
lock_path = '{0}.lock'.format(pid_path)
|
||||
try:
|
||||
lock_file = os.open(lock_path, os.O_CREAT | os.O_EXCL)
|
||||
except IOError as e:
|
||||
logger.error('cannot create lock file %s for pid file %s', lock_path, pid_path)
|
||||
logger.error('remove the lock file manually and run again if you are confident no other instance is active')
|
||||
raise
|
||||
|
||||
pid_file = open(pid_path,'w')
|
||||
pid_file.write(str(os.getpid()))
|
||||
pid_file.flush()
|
||||
os.close(lock_file)
|
||||
os.unlink(lock_path)
|
||||
try:
|
||||
yield pid_file
|
||||
finally:
|
||||
pid_file.close()
|
||||
# Assume that's atomic and all is well, no need for another .lock
|
||||
os.unlink(pid_path)
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# This file is part of steamos-devkit
|
||||
# SPDX-License-Identifier: LGPL-2.1+
|
||||
#
|
||||
# Copyright © 2017-2018 Collabora Ltd
|
||||
#
|
||||
# This package is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU Lesser General Public
|
||||
# License as published by the Free Software Foundation; either
|
||||
# version 2.1 of the License, or (at your option) any later version.
|
||||
#
|
||||
# This package is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this package. If not, see
|
||||
# <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Sample script to install ssh keys that were approved by the
|
||||
# approve-ssh-key script.
|
||||
#
|
||||
# A script or binary (written in any language) named install-ssh-key
|
||||
# can be placed in the same directory as this sample, and it will be
|
||||
# used preferentially.
|
||||
#
|
||||
# Execution environment:
|
||||
# - Runs as root
|
||||
# Input:
|
||||
# - The public key is in a temporary file accessible via argv[1], in
|
||||
# OpenSSH format
|
||||
# - argv[2], ... are the users to which we should grant access
|
||||
# Output:
|
||||
# - Exit 0 to accept the key, or nonzero to reject or on error
|
||||
#
|
||||
# This sample implementation should be suitable for any machine that
|
||||
# has the specified users. A production implementation would be similar
|
||||
# or even identical.
|
||||
|
||||
set -e
|
||||
|
||||
public_key="$1"
|
||||
shift
|
||||
|
||||
echo "Installing public key '$public_key' for users: $*"
|
||||
|
||||
for user in "$@"
|
||||
do
|
||||
if pwent="$(getent passwd "$user")"
|
||||
then
|
||||
home="$(echo "$pwent" | cut -d: -f6)"
|
||||
group="$(id -gn "$user")"
|
||||
install -d -m 0755 -o "$user" -g "$group" "$home/.ssh"
|
||||
if ! [ -e "$home/.ssh/authorized_keys" ]
|
||||
then
|
||||
install -m 0600 -o "$user" -g "$group" "$public_key" "$home/.ssh/authorized_keys"
|
||||
else
|
||||
if ! ( grep -v '^#' "$home/.ssh/authorized_keys" | grep -qF "$(cut -d " " -f2 "$public_key")" )
|
||||
then
|
||||
if [ -n "$(tail -1c "$home/.ssh/authorized_keys")" ]
|
||||
then
|
||||
echo >> "$home/.ssh/authorized_keys"
|
||||
fi
|
||||
cat "$public_key" >> "$home/.ssh/authorized_keys"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Public key installed"
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,522 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# MIT License
|
||||
#
|
||||
# Copyright (c) 2022 Valve Software inc., Collabora Ltd
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
import configparser
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import socketserver
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import argparse
|
||||
import threading
|
||||
import sys
|
||||
import builtins
|
||||
import signal
|
||||
|
||||
import dbus
|
||||
|
||||
# Print to stderr, which is unbuffered and easier to read in journalctl
|
||||
STDOUT_PRINT = builtins.print
|
||||
def stderr_print(*args, **kwargs):
|
||||
if 'file' not in kwargs:
|
||||
kwargs['file'] = sys.stderr
|
||||
global STDOUT_PRINT
|
||||
return STDOUT_PRINT(*args, **kwargs)
|
||||
builtins.print = stderr_print
|
||||
|
||||
# Expect a SIGUSR1 signal to exit
|
||||
# Could come at any time so we keep a global flag, but also support a callback
|
||||
SHOULD_EXIT = False
|
||||
EXIT_CALLBACK = None
|
||||
def handle_sigusr1(signum, frame):
|
||||
""" Handle SIGUSR1 signal
|
||||
"""
|
||||
global SHOULD_EXIT
|
||||
global EXIT_CALLBACK
|
||||
print("Received SIGUSR1, exiting.")
|
||||
SHOULD_EXIT = True
|
||||
if EXIT_CALLBACK:
|
||||
EXIT_CALLBACK()
|
||||
def set_exit_callback(callback):
|
||||
""" Set a callback to be called when we receive a SIGUSR1 signal
|
||||
"""
|
||||
global SHOULD_EXIT
|
||||
global EXIT_CALLBACK
|
||||
EXIT_CALLBACK = callback
|
||||
#print(f'Exit callback set to {EXIT_CALLBACK}')
|
||||
if SHOULD_EXIT and EXIT_CALLBACK:
|
||||
EXIT_CALLBACK()
|
||||
signal.signal(signal.SIGUSR1, handle_sigusr1)
|
||||
|
||||
SERVICE_PORT = 32000
|
||||
PACKAGE = "steamos-devkit-service"
|
||||
DEVKIT_HOOKS_DIR = "/usr/share/steamos-devkit/hooks"
|
||||
CURRENT_TXTVERS = '1'
|
||||
|
||||
ENTRY_POINT = "devkit-1"
|
||||
# root until config is loaded and told otherwise, etc.
|
||||
ENTRY_POINT_USER = "root"
|
||||
DEVICE_USERS = []
|
||||
PROPERTIES = {"txtvers": 1,
|
||||
"login": ENTRY_POINT_USER,
|
||||
"settings": "",
|
||||
"devkit1": [
|
||||
ENTRY_POINT
|
||||
]}
|
||||
|
||||
# Forced mDNS republish currently disabled, was causing a bad interaction with org.freedesktop.resolve1
|
||||
FORCE_PUBLISH_INTERVAL = 0
|
||||
|
||||
def write_file(data: bytes) -> str:
|
||||
""" Write given bytes to a temporary file and return the filename
|
||||
|
||||
Return the empty string if unable to open temp file for some reason
|
||||
"""
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', prefix='devkit-1', encoding='utf-8',
|
||||
delete=False) as file:
|
||||
file.write(data.decode())
|
||||
|
||||
return file.name
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def write_key(post_body: bytes) -> str:
|
||||
""" Write key to temp file and return filename if valid
|
||||
|
||||
Return the empty string if invalid
|
||||
"""
|
||||
length = len(post_body)
|
||||
found_name = False
|
||||
|
||||
if length >= 64 * 1024:
|
||||
print("Key length too long")
|
||||
return ''
|
||||
if not post_body.decode().startswith('ssh-rsa '):
|
||||
print("Key doesn't start with ssh-rsa ")
|
||||
return ''
|
||||
|
||||
# Get to the base64 bits
|
||||
index = 8
|
||||
while index < length and post_body[index] == ' ':
|
||||
index = index + 1
|
||||
|
||||
# Make sure key is base64
|
||||
body_decoded = post_body.decode()
|
||||
while index < length:
|
||||
if ((body_decoded[index] == '+') or (body_decoded[index] == '/') or
|
||||
(body_decoded[index].isdigit()) or
|
||||
(body_decoded[index].isalpha())):
|
||||
index = index + 1
|
||||
continue
|
||||
if body_decoded[index] == '=':
|
||||
index = index + 1
|
||||
if (index < length) and (body_decoded[index] == ' '):
|
||||
break
|
||||
if (index < length) and (body_decoded[index] == '='):
|
||||
index = index + 1
|
||||
if (index < length) and (body_decoded[index] == ' '):
|
||||
break
|
||||
print("Found = but no space or = next, invalid key")
|
||||
return ''
|
||||
if body_decoded[index] == ' ':
|
||||
break
|
||||
|
||||
print("Found invalid data, invalid key at "
|
||||
f"index: {index} data: {body_decoded[index]}")
|
||||
return ''
|
||||
|
||||
print(f"Key is valid base64, writing to temp file index: {index}")
|
||||
while index < length:
|
||||
if body_decoded[index] == ' ':
|
||||
# it's a space, the rest is name or magic phrase, don't write to disk
|
||||
if found_name:
|
||||
print(f"Found name ending at index {index}")
|
||||
length = index
|
||||
else:
|
||||
print(f"Found name ending index {index}")
|
||||
found_name = True
|
||||
if body_decoded[index] == '\0':
|
||||
print("Found null terminator before expected")
|
||||
return ''
|
||||
if body_decoded[index] == '\n' and index != length - 1:
|
||||
print("Found newline before expected")
|
||||
return ''
|
||||
index = index + 1
|
||||
|
||||
# write data to the file
|
||||
data = body_decoded[:length]
|
||||
filename = write_file(data.encode())
|
||||
|
||||
if filename:
|
||||
print(f"Filename key written to: {filename}")
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def find_hook(name: str) -> str:
|
||||
""" Find a hook with the given name
|
||||
|
||||
Return the path to the hook if found. '' if not found
|
||||
"""
|
||||
test_path = f"{DEVKIT_HOOKS_DIR}/{name}"
|
||||
if os.path.exists(test_path) and os.access(test_path, os.X_OK):
|
||||
return test_path
|
||||
|
||||
print(f"Error:: Unable to find hook for {name}")
|
||||
return ''
|
||||
|
||||
|
||||
def get_machine_name() -> str:
|
||||
""" Get the machine name and return it in a string
|
||||
|
||||
Use identify hook first, and if that fails just get the hostname.
|
||||
"""
|
||||
machine_name = ''
|
||||
# Run devkit-1-identify hook to get hostname, otherwise use default platform.node()
|
||||
identify_hook = find_hook("devkit-1-identify")
|
||||
if identify_hook:
|
||||
# Run hook and parse machine_name out
|
||||
process = subprocess.Popen(identify_hook, shell=False, stdout=subprocess.PIPE)
|
||||
output = ''
|
||||
for line in process.stdout:
|
||||
textline = line.decode(encoding='utf-8', errors="ignore")
|
||||
output += textline
|
||||
process.wait()
|
||||
output_object = json.loads(output)
|
||||
if 'machine_name' in output_object:
|
||||
machine_name = output_object["machine_name"]
|
||||
|
||||
if not machine_name:
|
||||
machine_name = platform.node()
|
||||
|
||||
return machine_name
|
||||
|
||||
|
||||
class DevkitHandler(BaseHTTPRequestHandler):
|
||||
""" Class to handle http requests on selected port for registration, getting properties.
|
||||
"""
|
||||
def _send_headers(self, code, content_type):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-type", content_type)
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
""" Handle GET requests
|
||||
"""
|
||||
print(f"GET request to path {self.path} from {self.client_address[0]}")
|
||||
|
||||
if self.path == "/login-name":
|
||||
self._send_headers(200, "text/plain")
|
||||
self.wfile.write(ENTRY_POINT_USER.encode())
|
||||
return
|
||||
|
||||
if self.path == "/properties.json":
|
||||
self._send_headers(200, "application/json")
|
||||
self.wfile.write(json.dumps(PROPERTIES, indent=2).encode())
|
||||
return
|
||||
|
||||
query = urllib.parse.parse_qs(self.path[2:])
|
||||
print(f"query is {query}")
|
||||
|
||||
if len(query) > 0 and query["command"]:
|
||||
command = query["command"][0]
|
||||
|
||||
if command == "ping":
|
||||
self._send_headers(200, "text/plain")
|
||||
self.wfile.write("pong\n".encode())
|
||||
return
|
||||
|
||||
self._send_headers(404, "")
|
||||
return
|
||||
|
||||
self._send_headers(404, "")
|
||||
self.wfile.write("Unknown request\n".encode())
|
||||
|
||||
def do_POST(self):
|
||||
""" Handle POST requests
|
||||
"""
|
||||
if self.path == "/register":
|
||||
from_ip = self.client_address[0]
|
||||
content_len = int(self.headers.get('Content-Length'))
|
||||
post_body = self.rfile.read(content_len)
|
||||
print(f"register request from {from_ip}")
|
||||
filename = write_key(post_body)
|
||||
|
||||
if not filename:
|
||||
self._send_headers(403, "text/plain")
|
||||
self.wfile.write(json.dumps({'error':'Failed to write the ssh key'}).encode())
|
||||
return
|
||||
|
||||
# Run approve script
|
||||
approve_hook = find_hook("approve-ssh-key")
|
||||
if not approve_hook:
|
||||
self._send_headers(403, "text/plain")
|
||||
self.wfile.write(json.dumps({'error':'Failed to find approve hook'}).encode())
|
||||
os.unlink(filename)
|
||||
return
|
||||
|
||||
# Run hook and parse output
|
||||
approve_process = subprocess.Popen([approve_hook, filename, from_ip],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE)
|
||||
approve_output = ''
|
||||
for approve_line in approve_process.stdout:
|
||||
approve_textline = approve_line.decode(encoding='utf-8', errors="ignore")
|
||||
approve_output += approve_textline
|
||||
|
||||
approve_process.wait()
|
||||
approve_object = json.loads(approve_output)
|
||||
if "error" in approve_object:
|
||||
self._send_headers(403, "text/plain")
|
||||
self.wfile.write(approve_output.encode()) # is already a json {'error':} response
|
||||
os.unlink(filename)
|
||||
return
|
||||
|
||||
# Otherwise, assume it passed
|
||||
install_hook = find_hook("install-ssh-key")
|
||||
if not install_hook:
|
||||
self._send_headers(403, "text-plain")
|
||||
self.wfile.write(json.dumps({'error':'Failed to find install-ssh-key hook'}).encode())
|
||||
os.unlink(filename)
|
||||
return
|
||||
|
||||
command = [install_hook, filename]
|
||||
# Append each user to command as separate arguments
|
||||
for user in DEVICE_USERS:
|
||||
command.append(user)
|
||||
|
||||
install_process = subprocess.Popen(command, shell=False, stdout=subprocess.PIPE)
|
||||
install_output = ''
|
||||
for install_line in install_process.stdout:
|
||||
install_textline = install_line.decode(encoding='utf-8', errors="ignore")
|
||||
install_output += install_textline
|
||||
install_process.wait()
|
||||
|
||||
exit_code = install_process.returncode
|
||||
if exit_code != 0:
|
||||
self._send_headers(500, "text/plain")
|
||||
self.wfile.write("install-ssh-key:\n".encode())
|
||||
self.wfile.write(install_output.encode())
|
||||
os.unlink(filename)
|
||||
return
|
||||
|
||||
self._send_headers(200, "text/plain")
|
||||
self.wfile.write("Registered\n".encode())
|
||||
os.unlink(filename)
|
||||
|
||||
|
||||
class DevkitService:
|
||||
""" Class to run as service.
|
||||
|
||||
Parses configuration, creates handler, registers an entry in dynamic DNS, etc.
|
||||
"""
|
||||
def __init__(self):
|
||||
global ENTRY_POINT_USER
|
||||
global DEVICE_USERS
|
||||
|
||||
self.port = SERVICE_PORT
|
||||
self.name = get_machine_name()
|
||||
self.stype = "_steamos-devkit._tcp"
|
||||
|
||||
self.service_path = None
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
# Use str form to preserve case
|
||||
config.optionxform = str
|
||||
config.read(["/etc/steamos-devkit/steamos-devkit.conf",
|
||||
"/usr/share/steamos-devkit/steamos-devkit.conf",
|
||||
os.path.join(os.path.expanduser('~'), '.config', PACKAGE, PACKAGE + '.conf')])
|
||||
|
||||
self.settings = {}
|
||||
if 'Settings' in config:
|
||||
settings = config["Settings"]
|
||||
self.settings = dict(settings)
|
||||
if 'Port' in settings:
|
||||
self.port = int(settings["Port"])
|
||||
|
||||
PROPERTIES["settings"] = json.dumps(self.settings)
|
||||
|
||||
# Parse users from configs
|
||||
if os.geteuid() == 0:
|
||||
# Running as root, maybe warn?
|
||||
print("Running as root, Probably shouldn't be\n")
|
||||
if 'Users' in config:
|
||||
users = config["Users"]
|
||||
if 'ShellUsers' in users:
|
||||
DEVICE_USERS = users["ShellUsers"]
|
||||
else:
|
||||
if 'Users' in config:
|
||||
users = config["Users"]
|
||||
if 'ShellUsers' in users:
|
||||
DEVICE_USERS = users["ShellUsers"]
|
||||
else:
|
||||
username = getpass.getuser()
|
||||
print(f'Username: {username}')
|
||||
DEVICE_USERS = []
|
||||
DEVICE_USERS.append(username)
|
||||
|
||||
# If only one user, that's the entry point user
|
||||
# Otherwise entry_point_user needs to be root to be able to switch between users
|
||||
if len(DEVICE_USERS) == 1:
|
||||
ENTRY_POINT_USER = DEVICE_USERS[0]
|
||||
PROPERTIES["login"] = ENTRY_POINT_USER
|
||||
|
||||
# "an array of dictionaries mapping strings to byte arrays" .. biggest eyeroll
|
||||
py_dict = {}
|
||||
for key, value in [
|
||||
('txtvers', CURRENT_TXTVERS),
|
||||
('settings', json.dumps(self.settings)),
|
||||
('login', ENTRY_POINT_USER),
|
||||
('devkit1', ENTRY_POINT)
|
||||
]:
|
||||
py_dict[key] = dbus.Array([ord(c) for c in value], signature='y')
|
||||
self.txt_records = dbus.Array( [dbus.Dictionary(py_dict, signature='say' )], signature='a{say}' )
|
||||
|
||||
self.dbus_bus = dbus.SystemBus()
|
||||
self.resolve1_register = self.dbus_bus.get_object(
|
||||
'org.freedesktop.resolve1',
|
||||
'/org/freedesktop/resolve1'
|
||||
).get_dbus_method(
|
||||
'RegisterService',
|
||||
'org.freedesktop.resolve1.Manager'
|
||||
)
|
||||
self.resolve1_unregister = self.dbus_bus.get_object(
|
||||
'org.freedesktop.resolve1',
|
||||
'/org/freedesktop/resolve1'
|
||||
).get_dbus_method(
|
||||
'UnregisterService',
|
||||
'org.freedesktop.resolve1.Manager'
|
||||
)
|
||||
|
||||
self.httpd = socketserver.TCPServer(("", self.port), DevkitHandler, bind_and_activate=False)
|
||||
print(f"serving at port: {self.port}")
|
||||
print(f"machine name: {self.name}")
|
||||
self.httpd.allow_reuse_address = True
|
||||
self.httpd.server_bind()
|
||||
self.httpd.server_activate()
|
||||
|
||||
def publish(self, recursed=False, silent=False):
|
||||
""" Publish ourselves on mdns as an available devkit device.
|
||||
"""
|
||||
# https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.resolve1.html
|
||||
|
||||
if not silent:
|
||||
print(f'RegisterService {self.name} {self.stype} {self.port}')
|
||||
self.unpublish(silent)
|
||||
|
||||
try:
|
||||
self.service_path = self.resolve1_register(
|
||||
# Passing '%H' should amount to the same thing
|
||||
# (is expanded based on specifiers, see https://www.man7.org/linux/man-pages/man5/systemd.dnssd.5.html)
|
||||
self.name,
|
||||
# 'name_template' .. what is this?
|
||||
# also referred to as 'service instance name' in the implementation
|
||||
# can't be an empty string, gets expanded with the same specifier rules as name,
|
||||
# gets random numbers appended to it for a new instance name in case of service collisions?
|
||||
self.name,
|
||||
self.stype,
|
||||
dbus.UInt16(int(self.port)),
|
||||
dbus.UInt16(10), # priority (see https://en.wikipedia.org/wiki/SRV_record)
|
||||
dbus.UInt16(0), # weight
|
||||
self.txt_records,
|
||||
)
|
||||
except dbus.exceptions.DBusException as e:
|
||||
# services will persist, it's bad if we didn't properly unregister, but we can recover this
|
||||
if not recursed and e.get_dbus_name() == 'org.freedesktop.resolve1.DnssdServiceExists':
|
||||
print('Service is already registered! Trying to recover')
|
||||
self.service_path = f'/org/freedesktop/resolve1/dnssd/{self.name}'
|
||||
self.unpublish()
|
||||
self.publish(True)
|
||||
else:
|
||||
raise e
|
||||
|
||||
def unpublish(self, silent=False):
|
||||
""" Remove publishing of ourselves as devkit device since we are quitting.
|
||||
"""
|
||||
if self.service_path:
|
||||
if not silent:
|
||||
print(f'UnregisterService {self.service_path}')
|
||||
self.resolve1_unregister(self.service_path)
|
||||
self.service_path = None
|
||||
|
||||
def force_publish(self, exit_event):
|
||||
while True:
|
||||
exit_event.wait(timeout=FORCE_PUBLISH_INTERVAL)
|
||||
if exit_event.is_set():
|
||||
break
|
||||
self.publish(False, True)
|
||||
|
||||
def on_signal_shutdown(self):
|
||||
print('Shutting down the httpd server')
|
||||
# This is blocking and needs to run in a thread, otherwise we'll deadlock
|
||||
threading.Thread(target=self.httpd.shutdown, daemon=True).start()
|
||||
|
||||
def run_server(self):
|
||||
""" Run server until keyboard interrupt or we are killed
|
||||
"""
|
||||
|
||||
thread = None
|
||||
exit_event = None
|
||||
global FORCE_PUBLISH_INTERVAL
|
||||
if 'ForcePublishInterval' in self.settings:
|
||||
FORCE_PUBLISH_INTERVAL = int(self.settings['ForcePublishInterval'])
|
||||
if FORCE_PUBLISH_INTERVAL != 0:
|
||||
exit_event = threading.Event()
|
||||
thread = threading.Thread(target=self.force_publish, args=[exit_event,], daemon=True).start()
|
||||
|
||||
set_exit_callback(self.on_signal_shutdown)
|
||||
try:
|
||||
self.httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
set_exit_callback(None)
|
||||
|
||||
if thread:
|
||||
exit_event.set()
|
||||
thread.join()
|
||||
|
||||
self.httpd.server_close()
|
||||
print(f"done serving at port: {self.port}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--hooks', required=False, action='store', help='hooks directory')
|
||||
conf = parser.parse_args()
|
||||
|
||||
if conf.hooks is not None:
|
||||
DEVKIT_HOOKS_DIR = conf.hooks
|
||||
|
||||
service = DevkitService()
|
||||
|
||||
service.publish()
|
||||
service.run_server()
|
||||
service.unpublish()
|
||||
@@ -0,0 +1,13 @@
|
||||
# A stand-in for the Frame's SSH surface, on Valve's Holo Core aarch64 base (the
|
||||
# Arch Linux ARM64 port the Frame's SteamOS is built on).
|
||||
FROM registry.gitlab.steamos.cloud/holo/holo-core-aarch64-preview/base-devel:latest
|
||||
RUN pacman -Sy --noconfirm --needed openssh sudo python rsync procps-ng && pacman -Scc --noconfirm
|
||||
# The Frame's user, with a Developer Mode style password and sudo, as on SteamOS.
|
||||
RUN useradd -m -s /bin/bash steamos && echo 'steamos:frame-test-pw' | chpasswd \
|
||||
&& echo 'steamos ALL=(ALL) ALL' > /etc/sudoers.d/steamos && chmod 440 /etc/sudoers.d/steamos
|
||||
# SteamOS's sshd: keys and passwords, no keyboard-interactive.
|
||||
RUN ssh-keygen -A && printf 'PasswordAuthentication yes\nKbdInteractiveAuthentication no\nUsePAM yes\n' > /etc/ssh/sshd_config.d/10-frame.conf
|
||||
# No systemd here: a systemctl that records power requests instead of acting.
|
||||
RUN printf '#!/bin/sh\necho "systemctl $*" >> /tmp/power-requests.log\n' > /usr/local/bin/systemctl && chmod +x /usr/local/bin/systemctl
|
||||
EXPOSE 22
|
||||
CMD ["/usr/sbin/sshd", "-D", "-e"]
|
||||
@@ -0,0 +1,41 @@
|
||||
# Testing without the headset
|
||||
|
||||
## Valve's own Frame OS, from its recovery image
|
||||
|
||||
Valve publishes a Steam Frame recovery image at
|
||||
https://steamdeck-images.steamos.cloud/recovery/ (`steamframe-oobe-repair-*.img.bz2`,
|
||||
~4 GB). `frame-image.sh` extracts its `rootfs-A` partition (btrfs), mounts it
|
||||
read-only with a throwaway writable layer, and starts the image's own sshd on
|
||||
port 2223, so the iPhone app can pair with, and run its server on, the real
|
||||
SteamOS for Frame userland (Python, sudo, sshd, PAM). It needs Linux with btrfs,
|
||||
e.g. Colima's VM on a Mac:
|
||||
|
||||
```sh
|
||||
colima start --arch aarch64 --vm-type vz
|
||||
colima ssh -- sudo sh tests/frame-container/frame-image.sh ~/Downloads/steamframe-oobe-repair-<build>.img.bz2
|
||||
# pair with 127.0.0.1:2223, user steamos, password frame-test-pw
|
||||
```
|
||||
|
||||
The image's kernel is built for the Frame's Qualcomm chip, so this runs its
|
||||
userland, not the whole OS: no SteamVR, Steam client, battery or Lepton.
|
||||
|
||||
## Holo Core stand-in
|
||||
|
||||
A lighter option: Valve and
|
||||
Collabora's [Holo Core aarch64 preview](https://www.collabora.com/news-and-blog/news-and-events/building-an-arch-linux-aarch64-port-for-holo-core.html)
|
||||
(the Arch Linux ARM64 base the Frame's SteamOS is built on) with the Frame's SSH
|
||||
surface: a `steamos` user with a password and sudo, OpenSSH taking keys and
|
||||
passwords, Python and rsync. `systemctl` only records what it's asked to do.
|
||||
|
||||
It exercises pairing with the password, the host-key pin, the server running on
|
||||
the "Frame" (FRAME_LOCAL=1) and the power password check. It has no SteamVR,
|
||||
Steam, battery, cameras or Lepton, so those panels are empty.
|
||||
|
||||
```sh
|
||||
docker build --platform linux/arm64 -t frame-holo-test tests/frame-container
|
||||
docker run -d --name frame-holo -p 127.0.0.1:2222:22 frame-holo-test
|
||||
# password: frame-test-pw. In the iPhone app (Simulator), pair with 127.0.0.1:2222.
|
||||
docker exec frame-holo cat /tmp/power-requests.log # what power actions asked for
|
||||
```
|
||||
|
||||
On a Mac without Docker: `brew install colima docker && colima start --arch aarch64 --vm-type vz`.
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Run Valve's own Steam Frame OS, from its recovery image, as an SSH target for
|
||||
# testing (see README.md). Run on a Linux host or VM with btrfs, as root:
|
||||
# frame-image.sh steamframe-oobe-repair-<build>.img.bz2
|
||||
# It extracts the rootfs-A partition, mounts it read-only, adds a throwaway
|
||||
# writable layer, and starts the image's own sshd on port 2223 (user steamos,
|
||||
# password frame-test-pw). systemctl only records what it's asked.
|
||||
set -e
|
||||
command -v bzcat >/dev/null && command -v python3 >/dev/null || {
|
||||
command -v apt-get >/dev/null && apt-get install -y -qq bzip2 python3 >/dev/null; }
|
||||
IMG=${1:?recovery .img.bz2}; RAW=${RAW:-$(dirname "$IMG")/frame-rootfs-A.img}
|
||||
if [ ! -f "$RAW" ]; then
|
||||
# Partition 3 (rootfs-A) from the image's GPT: start and size in 512-byte sectors.
|
||||
set -- $(bzcat "$IMG" | head -c 1048576 | python3 -c '
|
||||
import struct,sys; d=sys.stdin.buffer.read(); e=d[1024+2*128:1024+3*128]
|
||||
a,b=struct.unpack("<QQ",e[32:48]); print(a, b-a+1)')
|
||||
bzcat "$IMG" | tail -c +$(( $1 * 512 + 1 )) | head -c $(( $2 * 512 )) > "$RAW"
|
||||
fi
|
||||
R=/mnt/frame; O=/var/lib/frame-ovl; M=/srv/frame
|
||||
mkdir -p $R $O/upper $O/work $M
|
||||
mountpoint -q $R || mount -o ro -t btrfs "$(losetup -f --show -r "$RAW")" $R
|
||||
mountpoint -q $M || mount -t overlay overlay -o lowerdir=$R,upperdir=$O/upper,workdir=$O/work $M
|
||||
for d in proc sys dev dev/pts; do mountpoint -q $M/$d || mount --rbind /$d $M/$d; done
|
||||
mountpoint -q $M/run || mount -t tmpfs tmpfs $M/run
|
||||
mountpoint -q $M/tmp || mount -t tmpfs tmpfs $M/tmp
|
||||
mkdir -p $M/run/sshd $M/home/steamos $M/usr/local/bin
|
||||
chroot $M chown 1000:1000 /home/steamos
|
||||
echo 'steamos:frame-test-pw' | chroot $M chpasswd
|
||||
chroot $M ssh-keygen -A >/dev/null
|
||||
# No systemd here: systemctl records power requests instead of acting.
|
||||
printf '#!/bin/sh\necho "systemctl $*" >> /tmp/power-requests.log\n' > $M/usr/local/bin/systemctl
|
||||
chmod +x $M/usr/local/bin/systemctl
|
||||
pkill -f "[s]shd -p 2223" 2>/dev/null || true
|
||||
chroot $M /usr/bin/sshd -p 2223 -E /tmp/sshd.log
|
||||
echo up
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Headset smoke test: the fake Frame's core cases against a real Frame.
|
||||
|
||||
Runs on your computer through the `frame` SSH alias (or FRAME_ALIAS), with
|
||||
Frame Control's own modules, and records what happened with the headset's
|
||||
BUILD_ID in tests/smoke/results/<time>-<BUILD_ID>.json (git-ignored):
|
||||
|
||||
- properties.json from Valve's devkit service names the login user;
|
||||
- status (ui/frame_status.py) reads the build, battery and storage;
|
||||
- three tiny titles (tests/smoke/tiny_programs.py: ARM64 and x86-64 static
|
||||
Linux programs, an x86-64 .exe) are installed, launched and removed with
|
||||
ui/frame_titles.py, and Steam's logs about each are kept;
|
||||
- with --pair, a throwaway RSA key is paired through the devkit service
|
||||
(someone has to open Settings > Developer > Pair new host and approve it in
|
||||
the headset), checked, and taken out of authorized_keys again.
|
||||
|
||||
Everything it installs is removed again, also when a step fails: the titles
|
||||
(named fc_smoke_*, and leftovers of an interrupted run first), their Steam
|
||||
shortcuts, the 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 is a failed step.
|
||||
|
||||
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
|
||||
Env: FRAME_ALIAS (default frame), FRAME_SMOKE_RESULTS (default tests/smoke/results)
|
||||
Exit status: 0 all passed, 1 a step failed, 2 the headset isn't reachable.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent
|
||||
sys.path[:0] = [str(ROOT / 'ui'), str(HERE)]
|
||||
ALIAS = os.environ.setdefault('FRAME_ALIAS', 'frame') # read by frame_android at import
|
||||
|
||||
import frame_android # noqa: E402
|
||||
import frame_connect # noqa: E402
|
||||
import frame_titles # noqa: E402
|
||||
import tiny_programs # noqa: E402
|
||||
|
||||
RESULTS = Path(os.environ.get('FRAME_SMOKE_RESULTS') or HERE / 'results')
|
||||
PREFIX = 'fc_smoke_'
|
||||
# Earlier runs named titles fc-smoke*, which Steam refused to register but left on disk.
|
||||
OLD_PREFIX = 'fc-smoke'
|
||||
|
||||
|
||||
class Smoke:
|
||||
def __init__(self):
|
||||
self.steps = []
|
||||
self.installed = set()
|
||||
|
||||
def step(self, name, fn, *args):
|
||||
"""Run one step; record ok/failed, its detail and how long it took."""
|
||||
t0 = time.monotonic()
|
||||
rec = {'step': name}
|
||||
try:
|
||||
detail = fn(*args)
|
||||
rec.update(ok=True, detail=detail)
|
||||
except Exception as e: # a failed step is a result, not the end of the run
|
||||
rec.update(ok=False, error=f'{type(e).__name__}: {e}', trace=traceback.format_exc(limit=3))
|
||||
rec['seconds'] = round(time.monotonic() - t0, 1)
|
||||
self.steps.append(rec)
|
||||
print(f" {'ok ' if rec['ok'] else 'FAIL'} {name} ({rec['seconds']} s)"
|
||||
+ ('' if rec['ok'] else f": {rec['error']}"), flush=True)
|
||||
return rec
|
||||
|
||||
|
||||
def ssh(cmd, timeout=60):
|
||||
return frame_android.ssh(cmd, timeout=timeout)
|
||||
|
||||
|
||||
def ssh_config(key):
|
||||
out = subprocess.run(['ssh', '-G', ALIAS], capture_output=True, text=True, timeout=10).stdout
|
||||
for line in out.splitlines():
|
||||
k, _, v = line.partition(' ')
|
||||
if k == key:
|
||||
return v.strip()
|
||||
return None
|
||||
|
||||
|
||||
def os_release():
|
||||
fields = {}
|
||||
for line in ssh('cat /etc/os-release').splitlines():
|
||||
k, _, v = line.partition('=')
|
||||
fields[k] = v.strip('"')
|
||||
return {k: fields.get(k) for k in ('VERSION_ID', 'VARIANT_ID', 'BUILD_ID')}
|
||||
|
||||
|
||||
def properties():
|
||||
host = ssh_config('hostname') or ALIAS
|
||||
with urllib.request.urlopen(frame_connect.devkit_url(host, 32000, '/properties.json'), timeout=5) as r:
|
||||
props = json.load(r)
|
||||
user = ssh_config('user')
|
||||
if props.get('login') != user:
|
||||
raise AssertionError(f"properties.json says login {props.get('login')!r}; the alias logs in as {user!r}")
|
||||
return props
|
||||
|
||||
|
||||
def status(build):
|
||||
s = json.loads(frame_android.ssh('python3 -', input=(ROOT / 'ui' / 'frame_status.py').read_text(), timeout=30))
|
||||
if s['os']['build'] != build:
|
||||
raise AssertionError(f"status says build {s['os']['build']}, /etc/os-release {build}")
|
||||
for key in ('battery', 'disk', 'memory'):
|
||||
if not s.get(key):
|
||||
raise AssertionError(f'status has no {key}')
|
||||
return {k: s.get(k) for k in ('os', 'battery', 'power', 'temp', 'services', 'volume')}
|
||||
|
||||
|
||||
def steam_log_lines(*patterns):
|
||||
"""Steam log lines holding any of the fixed strings (which files: not verified, so all).
|
||||
|
||||
-a: compat_log.txt has binary bytes in it, and without it grep only says
|
||||
"binary file matches" (seen on the Frame, 2026-09-27).
|
||||
"""
|
||||
pats = ' '.join(f'-e {shlex.quote(p)}' for p in patterns)
|
||||
out = ssh(f"grep -arshF {pats} ~/.local/share/Steam/logs/ 2>/dev/null || true")
|
||||
return out.strip().splitlines()
|
||||
|
||||
|
||||
# What a launch must show, per kind. The x86-64 runtime isn't installed on the
|
||||
# Frame, so Steam acknowledges that launch and logs "... is not installed"
|
||||
# instead (docs/sideloading.md, 2026-09-26): recorded, not a failure.
|
||||
EXPECTED = {'arm64': ('running',), 'x86_64': ('running', 'runtime missing'), 'exe': ('running', 'started')}
|
||||
|
||||
|
||||
def title_cycle(smoke, kind, folder):
|
||||
path = tiny_programs.write(folder, kind)
|
||||
gid = PREFIX + kind # named outright: the file names would share one id
|
||||
|
||||
def install():
|
||||
smoke.installed.add(gid)
|
||||
meta = frame_titles.install(path, name=gid)
|
||||
listed = {t['id']: t for t in frame_titles.list_titles()}
|
||||
if gid not in listed:
|
||||
raise AssertionError(f'{gid} is not in the title list after installing')
|
||||
return {'id': gid, 'runtime': meta['runtime'], 'steam': meta.get('steam')}
|
||||
|
||||
def launch():
|
||||
mine = (f'devkit-game/{gid}', f'"{gid}"')
|
||||
# The missing-runtime line names Steam's app id, which we don't know, so
|
||||
# any new one counts; nothing else is launching during the test.
|
||||
seen, seen_missing = len(steam_log_lines(*mine)), len(steam_log_lines('but is not installed'))
|
||||
frame_titles.launch(gid) # raises unless Steam confirmed it
|
||||
outcome, procs, new = 'no evidence', [], []
|
||||
deadline = time.monotonic() + 12
|
||||
while time.monotonic() < deadline:
|
||||
# [d]: the pattern mustn't match the shell running pgrep, whose command line holds it.
|
||||
procs = ssh(f"pgrep -af -- '[d]evkit-game/{gid}/' || true").strip().splitlines()
|
||||
new = steam_log_lines(*mine)[seen:]
|
||||
missing = steam_log_lines('but is not installed')[seen_missing:]
|
||||
if procs:
|
||||
outcome = 'running'
|
||||
elif missing:
|
||||
outcome, new = 'runtime missing', new + missing
|
||||
elif any('started devkit game' in line or 'chdir' in line for line in new):
|
||||
outcome = 'started'
|
||||
if outcome in EXPECTED[kind]: # else keep looking: the log can come before the process
|
||||
break
|
||||
time.sleep(0.5)
|
||||
detail = {'outcome': outcome, 'processes': procs, 'steam_log': new[-12:]}
|
||||
if outcome not in EXPECTED[kind]:
|
||||
raise AssertionError(f"Steam acknowledged the launch, but {outcome} (expected "
|
||||
f"{' or '.join(EXPECTED[kind])}): {json.dumps(detail)[:400]}")
|
||||
return detail
|
||||
|
||||
def remove():
|
||||
frame_titles.remove(gid)
|
||||
left = ssh(f'ls -d ~/devkit-game/{gid} ~/devkit-game/{gid}-*.json 2>/dev/null || true').strip()
|
||||
if left:
|
||||
raise AssertionError(f'left behind: {left}')
|
||||
if gid in {t['id'] for t in frame_titles.list_titles()}:
|
||||
raise AssertionError(f'{gid} is still listed')
|
||||
smoke.installed.discard(gid)
|
||||
return {'removed': gid}
|
||||
|
||||
if smoke.step(f'{kind}: install', install)['ok']:
|
||||
smoke.step(f'{kind}: launch', launch)
|
||||
smoke.step(f'{kind}: remove', remove)
|
||||
|
||||
|
||||
def cleanup(smoke):
|
||||
"""Remove this run's titles and any fc_smoke_* an interrupted run left: the folder,
|
||||
its json files, and (through steamos-delete) Steam's shortcut. Raises if anything stays."""
|
||||
problems = []
|
||||
try:
|
||||
ids = {t['id'] for t in frame_titles.list_titles() if t['id'].startswith((PREFIX, OLD_PREFIX))}
|
||||
except frame_android.FrameError as e:
|
||||
ids = set()
|
||||
problems.append(f'could not list titles: {e}')
|
||||
ids |= smoke.installed
|
||||
gone = set()
|
||||
for gid in sorted(ids):
|
||||
try:
|
||||
if ssh(f'test -d ~/devkit-game/{gid} && echo yes || true').strip() == 'yes':
|
||||
frame_titles.remove(gid)
|
||||
# Also when remove() stopped part-way, or only the json files are left.
|
||||
ssh(f"rm -f {' '.join(f'~/devkit-game/{gid}-{k}.json' for k in ('argv', 'env', 'settings', 'framecontrol'))}")
|
||||
if ssh(f'ls -d ~/devkit-game/{gid} ~/devkit-game/{gid}-*.json 2>/dev/null || true').strip():
|
||||
problems.append(f'{gid} is still on the Frame')
|
||||
else:
|
||||
gone.add(gid)
|
||||
except frame_android.FrameError as e:
|
||||
problems.append(f'{gid}: {e}')
|
||||
if gone:
|
||||
# steamos-delete with no title only syncs Steam's shortcuts with ~/devkit-game.
|
||||
# It logs a failed sync and exits 0, so read its log; a second run lists
|
||||
# what Steam still has registered, which must not include ours.
|
||||
try:
|
||||
first = ssh('python3 ~/devkit-utils/steamos-delete 2>&1', timeout=120)
|
||||
second = ssh('python3 ~/devkit-utils/steamos-delete 2>&1', timeout=120)
|
||||
for out in (first, second):
|
||||
if 'sync of devkit games failed' in out:
|
||||
raise AssertionError(out.strip().splitlines()[-1])
|
||||
for gid in sorted(gone):
|
||||
if f'registered with Steam Client: {gid!r}' in second:
|
||||
problems.append(f'{gid} is still registered with Steam')
|
||||
else:
|
||||
smoke.installed.discard(gid)
|
||||
except (frame_android.FrameError, AssertionError) as e:
|
||||
problems.append(f"syncing Steam's shortcuts: {e}")
|
||||
if problems:
|
||||
raise AssertionError('; '.join(problems))
|
||||
return {'removed': sorted(ids)}
|
||||
|
||||
|
||||
# Runs on the Frame: drop the lines holding one key from authorized_keys, writing a
|
||||
# copy with the same mode and swapping it in, so a failure can't truncate the file.
|
||||
DROP_KEY = r"""
|
||||
import os, sys, tempfile
|
||||
path = os.path.expanduser('~/.ssh/authorized_keys')
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
keep = [line for line in lines if sys.argv[1] not in line]
|
||||
if len(keep) < len(lines):
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix='.authorized_keys.')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
f.writelines(keep)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.chmod(tmp, os.stat(path).st_mode & 0o777)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
print(len(lines) - len(keep))
|
||||
"""
|
||||
|
||||
|
||||
def pair(folder):
|
||||
"""Pair a throwaway RSA key through the devkit service, check it, then remove it."""
|
||||
key = os.path.join(folder, 'id_rsa_smoke')
|
||||
subprocess.run(['ssh-keygen', '-q', '-t', 'rsa', '-b', '3072', '-N', '', '-C', 'frame-control-smoke',
|
||||
'-f', key], check=True)
|
||||
pub = Path(key + '.pub').read_text()
|
||||
host, user, port = ssh_config('hostname') or ALIAS, ssh_config('user'), ssh_config('port') or '22'
|
||||
known = ssh_config('userknownhostsfile') or '~/.ssh/known_hosts'
|
||||
comment = frame_connect.key_comment(platform.node()).replace('frame-control@', 'frame-control-smoke@')
|
||||
print(' In the headset: Steam Settings > Developer > Pair new host, then approve '
|
||||
f'"{comment}" (waits up to {frame_connect.PAIRING_MODE_WAIT} s)', flush=True)
|
||||
b64 = pub.split()[1]
|
||||
try:
|
||||
reason = frame_connect.devkit_pair(host, pub, comment)
|
||||
if reason:
|
||||
raise AssertionError(reason)
|
||||
# Only this key: no ssh_config (whose IdentityFile lines IdentitiesOnly would
|
||||
# still offer), no agent, no passwords.
|
||||
r = subprocess.run(['ssh', '-F', '/dev/null', '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes',
|
||||
'-o', 'IdentityAgent=none', '-o', 'PasswordAuthentication=no',
|
||||
'-o', 'KbdInteractiveAuthentication=no', '-o', 'ConnectTimeout=8',
|
||||
'-o', 'StrictHostKeyChecking=yes', '-o', f'UserKnownHostsFile={known}',
|
||||
'-p', port, '-i', key, f'{user}@{host}', 'true'], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
raise AssertionError(f'paired, but the key does not log in: {r.stderr.strip()}')
|
||||
return {'comment': comment}
|
||||
finally:
|
||||
dropped = frame_android.ssh(f'python3 - {shlex.quote(b64)}', input=DROP_KEY).strip()
|
||||
if b64 in ssh('cat ~/.ssh/authorized_keys'):
|
||||
raise AssertionError('the smoke key is still in authorized_keys')
|
||||
print(f' removed the smoke key from authorized_keys ({dropped} line(s))', flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument('--pair', action='store_true', help='also pair through the devkit service (needs you in the headset)')
|
||||
args = ap.parse_args()
|
||||
|
||||
r = subprocess.run(['ssh', '-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes', ALIAS, 'true'],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(f'{ALIAS} is not reachable over SSH: {r.stderr.strip()}', file=sys.stderr)
|
||||
return 2
|
||||
|
||||
smoke = Smoke()
|
||||
started = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
release = os_release()
|
||||
build = release['BUILD_ID'] or 'unknown'
|
||||
print(f'==> Frame smoke test on {ALIAS}: SteamOS {release["VERSION_ID"]} ({release["VARIANT_ID"]}), build {build}')
|
||||
# Frame Control copies Valve's devkit tools to ~/devkit-utils on the first install
|
||||
# (as Valve's client does). If they weren't there before, they go again at the end.
|
||||
had_utils = ssh('test -d ~/devkit-utils && echo yes || true').strip() == 'yes'
|
||||
smoke.step('cleanup: leftovers from earlier runs', cleanup, smoke)
|
||||
folder = tempfile.mkdtemp(prefix='frame-smoke-')
|
||||
try:
|
||||
smoke.step('devkit service: properties.json', properties)
|
||||
smoke.step('status', status, build)
|
||||
for kind in ('arm64', 'x86_64', 'exe'):
|
||||
title_cycle(smoke, kind, folder)
|
||||
if args.pair:
|
||||
smoke.step('devkit pairing (throwaway key)', pair, folder)
|
||||
finally:
|
||||
smoke.step('cleanup: everything this run installed', cleanup, smoke)
|
||||
if not had_utils:
|
||||
smoke.step('cleanup: ~/devkit-utils (not there before)', ssh,
|
||||
'rm -rf ~/devkit-utils ~/.devkit-utils.frame-control && echo removed')
|
||||
subprocess.run(['rm', '-rf', folder])
|
||||
passed = sum(s['ok'] for s in smoke.steps)
|
||||
RESULTS.mkdir(exist_ok=True)
|
||||
out = RESULTS / f"{started.replace(':', '')}-{build}.json"
|
||||
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'os_release': release, 'paired': args.pair,
|
||||
'passed': passed, 'failed': len(smoke.steps) - passed,
|
||||
'devkit_utils_there_before': had_utils, 'steps': smoke.steps}, indent=1))
|
||||
print(f'==> {passed}/{len(smoke.steps)} steps passed on build {build}; results in {out}')
|
||||
return 0 if passed == len(smoke.steps) else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tiny test programs for sideloading, built from bytes: no compiler needed.
|
||||
|
||||
- elf_arm64(), elf_x86_64(): static Linux executables that sleep for a few
|
||||
seconds (nanosleep) and exit 0, so a launch can be seen in `ps`.
|
||||
- pe_x86_64(): a Windows x86-64 .exe with no imports whose entry point
|
||||
returns 0 straight away, which ends the process (Windows and Wine both
|
||||
exit when the main thread returns).
|
||||
|
||||
Each is a single loadable segment or section; they are what the headset
|
||||
smoke test and the fake Frame's e2e tests install. Python stdlib only.
|
||||
"""
|
||||
import struct
|
||||
|
||||
SLEEP_SECONDS = 10
|
||||
BASE = 0x400000
|
||||
|
||||
|
||||
def _elf(machine, code):
|
||||
"""ELF64 little-endian ET_EXEC with one PT_LOAD (R+X) covering the whole file."""
|
||||
ehsize, phsize = 64, 56
|
||||
entry = BASE + ehsize + phsize
|
||||
size = ehsize + phsize + len(code)
|
||||
ident = b'\x7fELF' + bytes([2, 1, 1, 0]) + b'\0' * 8
|
||||
header = ident + struct.pack('<HHIQQQIHHHHHH', 2, machine, 1, entry, ehsize, 0, 0,
|
||||
ehsize, phsize, 1, 0, 0, 0)
|
||||
phdr = struct.pack('<IIQQQQQQ', 1, 5, 0, BASE, BASE, size, size, 0x1000)
|
||||
return header + phdr + code
|
||||
|
||||
|
||||
def elf_arm64(seconds=SLEEP_SECONDS):
|
||||
words = [
|
||||
0x10000100, # adr x0, timespec (32 bytes ahead)
|
||||
0xD2800001, # mov x1, #0
|
||||
0xD2800CA8, # mov x8, #101 (nanosleep)
|
||||
0xD4000001, # svc #0
|
||||
0xD2800000, # mov x0, #0
|
||||
0xD2800BA8, # mov x8, #93 (exit)
|
||||
0xD4000001, # svc #0
|
||||
0xD503201F, # nop (pads the timespec to offset 32)
|
||||
]
|
||||
return _elf(0xB7, struct.pack('<8I', *words) + struct.pack('<qq', seconds, 0))
|
||||
|
||||
|
||||
def elf_x86_64(seconds=SLEEP_SECONDS):
|
||||
code = (b'\x48\x8d\x3d\x12\x00\x00\x00' # lea rdi, [rip+18] (the timespec)
|
||||
b'\x31\xf6' # xor esi, esi
|
||||
b'\xb8\x23\x00\x00\x00' # mov eax, 35 (nanosleep)
|
||||
b'\x0f\x05' # syscall
|
||||
b'\x31\xff' # xor edi, edi
|
||||
b'\xb8\x3c\x00\x00\x00' # mov eax, 60 (exit)
|
||||
b'\x0f\x05') # syscall
|
||||
assert len(code) == 25
|
||||
return _elf(0x3E, code + struct.pack('<qq', seconds, 0))
|
||||
|
||||
|
||||
def pe_x86_64():
|
||||
"""PE32+ console program: one .text section holding `xor eax, eax; ret`."""
|
||||
file_align, sect_align, image_base = 0x200, 0x1000, 0x140000000
|
||||
dos = b'MZ' + b'\0' * 0x3A + struct.pack('<I', 0x40)
|
||||
coff = b'PE\0\0' + struct.pack('<HHIIIHH', 0x8664, 1, 0, 0, 0, 240, 0x0022)
|
||||
opt = struct.pack('<HBBIIIII', 0x20B, 14, 0, file_align, 0, 0, 0x1000, 0x1000)
|
||||
opt += struct.pack('<QIIHHHHHHIIIIHHQQQQII', image_base, sect_align, file_align,
|
||||
6, 0, 0, 0, 6, 0, 0, # OS, image and subsystem versions, Win32VersionValue
|
||||
0x2000, file_align, 0, # SizeOfImage, SizeOfHeaders, CheckSum
|
||||
3, 0x8100, # console subsystem; NX compatible, terminal-server aware
|
||||
0x100000, 0x1000, 0x100000, 0x1000, 0, 16)
|
||||
opt += b'\0' * (16 * 8) # no data directories: no imports, no relocations
|
||||
assert len(opt) == 240
|
||||
text = b'.text\0\0\0' + struct.pack('<IIIIIIHHI', 3, 0x1000, file_align, file_align, 0, 0, 0, 0, 0x60000020)
|
||||
headers = (dos + coff + opt + text).ljust(file_align, b'\0')
|
||||
return headers + b'\x31\xc0\xc3'.ljust(file_align, b'\xcc')
|
||||
|
||||
|
||||
PROGRAMS = { # kind -> (file name, builder)
|
||||
'arm64': ('fc-smoke-arm64', elf_arm64),
|
||||
'x86_64': ('fc-smoke-x86_64', elf_x86_64),
|
||||
'exe': ('fc-smoke-exe.exe', pe_x86_64),
|
||||
}
|
||||
|
||||
|
||||
def write(folder, kind):
|
||||
"""Write one program into folder; returns its path."""
|
||||
import os
|
||||
name, build = PROGRAMS[kind]
|
||||
path = os.path.join(folder, name)
|
||||
with open(path, 'wb') as f:
|
||||
f.write(build())
|
||||
os.chmod(path, 0o755)
|
||||
return path
|
||||
@@ -347,14 +347,26 @@ class Zips(unittest.TestCase):
|
||||
class Names(unittest.TestCase):
|
||||
def test_title_id(self):
|
||||
self.assertEqual(frame_titles.title_id('Hollow Knight: Silksong!'), 'Hollow_Knight_Silksong')
|
||||
self.assertEqual(frame_titles.title_id('steam'), 'steam-game') # Valve's reserved sideload names
|
||||
self.assertEqual(frame_titles.title_id('steam'), 'steam_game') # Valve's reserved sideload names
|
||||
self.assertEqual(frame_titles.title_id('Devkit Steam'), 'Devkit_Steam')
|
||||
self.assertEqual(frame_titles.title_id('devkit-steam'), 'devkit-steam-game') # the trampoline file
|
||||
self.assertEqual(frame_titles.title_id('--rm -rf /'), 'rm_-rf')
|
||||
self.assertEqual(frame_titles.title_id('--rm -rf /'), 'rm_rf')
|
||||
self.assertEqual(len(frame_titles.title_id('x' * 200)), 64)
|
||||
with self.assertRaises(FrameError):
|
||||
frame_titles.title_id('!!!')
|
||||
|
||||
def test_title_id_is_one_steam_accepts(self):
|
||||
# The Frame's Steam refused "fc-smoke-exe" ("missing/invalid arguments") and took
|
||||
# "FCSmokeProbe" (2026-09-27): Valve's client allows ^[A-Za-z_][A-Za-z0-9_.]+$ only.
|
||||
self.assertEqual(frame_titles.title_id('Half-Life 2'), 'Half_Life_2')
|
||||
self.assertEqual(frame_titles.title_id('fc-smoke-exe'), 'fc_smoke_exe')
|
||||
self.assertEqual(frame_titles.title_id('2048'), '_2048')
|
||||
self.assertEqual(frame_titles.title_id('X'), 'X_game')
|
||||
self.assertEqual(frame_titles.title_id('devkit-steam'), 'devkit_steam')
|
||||
for name in ('Half-Life 2', '2048', 'X', 'steam', 'a' * 90, 'Ünïcödé game', '9' * 70):
|
||||
gid = frame_titles.title_id(name)
|
||||
self.assertRegex(gid, r'^[A-Za-z_][A-Za-z0-9_.]+$', name)
|
||||
self.assertTrue(frame_titles.NEW_ID_RE.match(gid) and frame_titles.ID_RE.match(gid), gid)
|
||||
|
||||
def test_display_name(self):
|
||||
self.assertEqual(frame_titles.display_name('MyGame-linux-arm64.zip'), 'MyGame')
|
||||
self.assertEqual(frame_titles.display_name('Portal 2.zip'), 'Portal 2')
|
||||
|
||||
@@ -185,11 +185,125 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(self.request("GET", "/api/webinstall/job?id=x", headers={"X-Frame-UI": "1"})[0], 404)
|
||||
self.assertEqual(self.post("/api/webinstall/cancel", {"job": "x"})[0], 404)
|
||||
|
||||
def test_unreachable_frame_is_one_clear_offline_error(self):
|
||||
status, _, payload = self.request("GET", "/api/status", headers={"X-Frame-UI": "1"})
|
||||
body = json.loads(payload)
|
||||
self.assertEqual(status, 503, body)
|
||||
self.assertTrue(body["offline"])
|
||||
self.assertIn("Can't find the Frame", body["error"])
|
||||
self.assertIn("frame-control-test.invalid", body["detail"]) # ssh's own words stay available
|
||||
|
||||
def test_flatpak_install_runs_as_a_job(self):
|
||||
status, started = self.post("/api/flatpak", {"id": "org.example.App", "action": "install"})
|
||||
self.assertEqual(status, 200, started)
|
||||
for _ in range(200):
|
||||
status, _, payload = self.request("GET", f"/api/job?id={started['job']}", headers={"X-Frame-UI": "1"})
|
||||
job = json.loads(payload)
|
||||
if job["done"]:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(job["done"])
|
||||
self.assertIn("Can't find the Frame", job["error"])
|
||||
self.assertEqual(self.request("GET", "/api/job?id=nope", headers={"X-Frame-UI": "1"})[0], 404)
|
||||
|
||||
def test_android_install_checks_the_package_before_starting(self):
|
||||
status, body = self.post("/api/android", {"action": "install", "package": "org.example.not.in.catalogue"})
|
||||
self.assertNotEqual(status, 200, body)
|
||||
self.assertNotIn("job", body)
|
||||
|
||||
def test_unknown_routes(self):
|
||||
self.assertEqual(self.request("GET", "/nope")[0], 404)
|
||||
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
|
||||
class LocalMode(unittest.TestCase):
|
||||
"""FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key
|
||||
guards /api/, and ssh goes to ui/local-bin/ssh, which runs commands here."""
|
||||
|
||||
KEY = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
env = {**os.environ, "FRAME_LOCAL": "1", "FRAME_UI_KEY": cls.KEY, "FRAME_DEVICE": "iPhone",
|
||||
"PYTHONDONTWRITEBYTECODE": "1"}
|
||||
cls.log = tempfile.TemporaryFile()
|
||||
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
|
||||
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=cls.log, text=True)
|
||||
line = cls.proc.stdout.readline()
|
||||
cls.port = int(line.split("127.0.0.1:")[1].split()[0]) # --port 0: the server prints the port it took
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.proc.stdin.close() # --exit-on-eof: the phone disconnecting
|
||||
cls.proc.wait(timeout=15)
|
||||
cls.proc.stdout.close()
|
||||
cls.log.close()
|
||||
|
||||
def request(self, method, path, body=None, key=KEY):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
|
||||
conn.request(method, path, body=json.dumps(body).encode() if body is not None else None,
|
||||
headers={"X-Frame-UI": key, "Content-Type": "application/json"})
|
||||
r = conn.getresponse()
|
||||
data = json.loads(r.read() or b"{}")
|
||||
conn.close()
|
||||
return r.status, data
|
||||
|
||||
def test_needs_the_session_key(self):
|
||||
self.assertEqual(self.request("GET", "/api/host", key="1")[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/host", key="")[0], 403)
|
||||
status, host = self.request("GET", "/api/host")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(host, {"os": "SteamOS", "fileManager": None, "computer": "iPhone", "mobile": True})
|
||||
|
||||
def test_commands_run_locally(self):
|
||||
# frame_titles lists ~/devkit-game here; with nothing there, the list is empty rather than an ssh error.
|
||||
status, body = self.request("GET", "/api/titles")
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertIsInstance(body["titles"], list)
|
||||
|
||||
def test_open_is_for_the_app_and_power_needs_a_password(self):
|
||||
self.assertEqual(self.request("POST", "/api/open", {"what": "terminal"})[0], 400)
|
||||
status, body = self.request("POST", "/api/open", {"what": "reboot"})
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn("password", body["error"])
|
||||
self.assertEqual(self.request("POST", "/api/open", {"what": "reboot", "password": "a\nb"})[0], 400)
|
||||
|
||||
|
||||
class UnreachableMessages(unittest.TestCase):
|
||||
"""Only ssh's own connection failures are reworded; other errors keep their text."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
import server
|
||||
cls.server = server
|
||||
|
||||
def test_ssh_connection_failures(self):
|
||||
cases = {
|
||||
"ssh: Could not resolve hostname frame: nodename nor servname provided": "Can't find",
|
||||
"ssh: connect to host frame.local port 22: Operation timed out": "isn't answering",
|
||||
"ssh: connect to host 192.168.1.9 port 22: Host is down": "isn't answering",
|
||||
"ssh: connect to host 192.168.1.9 port 22: No route to host": "isn't answering",
|
||||
"ssh: connect to host 192.168.1.9 port 22: Connection refused": "refused",
|
||||
"steamos@192.168.1.9: Permission denied (publickey,password).": "SSH key",
|
||||
"Host key verification failed.": "identity changed",
|
||||
"kex_exchange_identification: read: Connection reset by peer": "dropped",
|
||||
"Timed out talking to frame": "too long",
|
||||
}
|
||||
for raw, words in cases.items():
|
||||
body, status = self.server.error_body(raw)
|
||||
self.assertEqual(status, 503, raw)
|
||||
self.assertIn(words, body["error"], raw)
|
||||
self.assertTrue(body["offline"])
|
||||
|
||||
def test_other_errors_pass_through(self):
|
||||
for raw in ("bad Flatpak app ID", "error: No remote refs found for 'org.example.App'",
|
||||
"cp: cannot open 'x': Permission denied", "timed out waiting for Steam"):
|
||||
self.assertEqual(self.server.error_body(raw), ({"error": raw}, None), raw)
|
||||
|
||||
|
||||
class StatusProbe(unittest.TestCase):
|
||||
# frame_status.py only ever runs on the Frame (Linux); it needs os.statvfs.
|
||||
@unittest.skipIf(os.name == "nt", "Frame-side script; POSIX only")
|
||||
|
||||