Compare commits

..
Author SHA1 Message Date
saphid d6a0a3a639 PR #59 screenshot: report form replacing the saved address 2026-10-01 21:36:58 +10:00
saphid effe4c2c38 Screenshots for the contact email PR 2026-09-30 10:39:25 +10:00
113 changed files with 0 additions and 28281 deletions

No files matched your search

-47
View File
@@ -1,47 +0,0 @@
---
name: steam-frame
description: Operate the user's Valve Steam Frame headset from the Mac through this repo's helpers and field notes. Use for Steam Frame SSH, screen streaming, clipboard, file push, APK or Flatpak installs, launching apps on the headset, arranging floating windows or panels in VR space, or debugging SteamOS/gamescope/SteamVR on the Frame.
---
# Steam Frame
SSH works through the `frame` alias
(user `steamos`). The headset has to be awake for anything that touches its
desktop or panels.
## Start here
1. Read `docs/how-the-frame-works.md`. It's the map: the layer cake (SteamVR →
gamescope → nested Plasma), the verified facts, and debug recipes.
2. Open the topic doc for the task:
| Task | Doc | Script |
|---|---|---|
| Floating windows in the room, one panel per app | `docs/panels.md` | `scripts/panel-on-frame.sh` |
| First-time access, SSH keys | `docs/ssh.md` | `scripts/connect.sh` |
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
| 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` |
| What's still unverified | `docs/open-questions.md` | — |
Each script's usage is in its header comment. Read the header rather than
running `--help`: `paste-to-frame.sh`, `serve-bootstrap.sh` and
`bootstrap-on-frame.sh` act on any argument.
## Ground rules
- Label every claim **verified** (seen on the device, with the date and
SteamOS build) or **inferred**. The docs use this convention. Keep it, and
move items out of `docs/open-questions.md` once they're checked.
- When you learn something new about the Frame, record it in
`docs/how-the-frame-works.md` (or the topic doc) in the same change.
- The Frame's rootfs is read-only and SteamOS updates replace it. Put changes in
`~` (`--user` Flatpaks, `~/.config`) rather than `steamos-readonly disable`.
- `sudo` on the Frame asks for the user's Developer Mode password. Hand those
steps to the user (open Terminal) and keep automation to non-sudo commands.
- The Mac uses BSD userland and zsh (no `timeout`, use `head -n`).
-8
View File
@@ -1,8 +0,0 @@
# Scripts run on the Frame (Linux) and on macOS/Linux: keep LF even in Windows checkouts.
*.sh text eol=lf
*.py text eol=lf
*.js text eol=lf
*.html text eol=lf
*.json text eol=lf
*.md text eol=lf
*.bat text eol=crlf
-59
View File
@@ -1,59 +0,0 @@
name: checks
on:
push:
branches: [main]
pull_request:
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.9" # the oldest python3 the Mac app may pick up (Xcode CLT)
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Install zsh
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
- name: Script syntax
run: |
for f in scripts/*.sh frame/*/*.sh; do
case "$(head -n 1 "$f")" in
*zsh*) zsh -n "$f" ;;
*) bash -n "$f" ;;
esac
done
- name: Python compiles
run: |
python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py
# Valve's devkit-utils (vendored; run by the Frame's python3). Most have no .py suffix.
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
- name: Server tests
run: python -m unittest discover -s tests -v
- name: App syntax
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js
# The server runs on each desktop OS the app ships for, on the Python version
# the app bundles (app/build/fetch-deps.js) and, on Ubuntu, a newer one.
server-tests:
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
python: "3.12"
- os: macos-latest
python: "3.12"
- os: ubuntu-latest
python: "3.13"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Server tests
run: python -m unittest discover -s tests -v
-61
View File
@@ -1,61 +0,0 @@
name: release
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
# the installers to that tag's GitHub release (created as a draft if missing).
# Pull requests that touch the app build the same installers as artifacts.
on:
push:
tags: ["v*"]
pull_request:
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
script: dist
files: app/dist/*.dmg app/dist/*.zip
- os: windows-latest
script: dist:win
files: app/dist/*.exe app/dist/*.zip
- os: ubuntu-latest
script: dist:linux
files: app/dist/*.AppImage app/dist/*.deb
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Build
working-directory: app
shell: bash
run: npm ci && npm run ${{ matrix.script }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload to the release
if: startsWith(github.ref, 'refs/tags/')
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF_NAME}"
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
gh release upload "$tag" ${{ matrix.files }} --clobber
- uses: actions/upload-artifact@v4
with:
name: frame-control-${{ matrix.os }}
path: |
app/dist/*.dmg
app/dist/*.exe
app/dist/*.zip
app/dist/*.AppImage
app/dist/*.deb
if-no-files-found: ignore
-6
View File
@@ -1,6 +0,0 @@
.DS_Store
__pycache__/
apk-catalog/data/cache/
apk-catalog/data/index-v2.json*
compat-db/.env.lakebed.server
compat-db/.lakebed/
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 saphid
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.
-234
View File
@@ -1,234 +0,0 @@
<div align="center">
<img src="docs/img/icon.png" width="112" alt="Frame Control icon">
# Frame Control
**Manage your Valve Steam Frame from your computer.**<br>
See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
[![Latest release](https://img.shields.io/github/v/release/saphid/steam-frame?label=release&color=1a9fff)](https://github.com/saphid/steam-frame/releases/latest)
[![Platforms](https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-2a475e?label=runs%20on)](#install)
[![Checks](https://img.shields.io/github/actions/workflow/status/saphid/steam-frame/checks.yml?branch=main&label=checks)](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-66c0f4)](LICENSE)
[**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
<br>
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" 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>
<sub>The trailer: 66 seconds, with sound. Downloads the MP4 from the trailer release.</sub>
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
</div>
---
## Features
<table>
<tr>
<td width="50%" valign="top">
**👓 Headset view**<br>
Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
</td>
<td width="50%" valign="top">
**🔋 Battery and status**<br>
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
</td>
</tr>
<tr>
<td valign="top">
**🎮 Steam games**<br>
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
</td>
<td valign="top">
**🤖 Android apps**<br>
About 4,500 F-Droid apps rated for the Frame. One click installs each as its own app in your Steam library.
</td>
</tr>
<tr>
<td valign="top">
**📁 Files, games and clipboard**<br>
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
</td>
<td valign="top">
**📸 Screenshots**<br>
Browse the shots you take in the headset and save them to your Pictures folder.
</td>
</tr>
<tr>
<td valign="top">
**🧩 Flatpaks and display**<br>
Install desktop apps like Moonlight or VLC, and set each Android app's resolution and text size.
</td>
<td valign="top">
**⚡ One-click tools**<br>
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
</td>
</tr>
</table>
Nothing is installed on the Frame for any of this: the app uses what SteamOS
already ships (sideloading a game copies Valve's own devkit scripts to
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
## Install
| | Download | Needs |
|---|---|---|
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra |
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
Google doesn't publish `adb` for arm64 Linux, so that build uses your
distribution's. If you already have `adb`, the app uses yours.
<details>
<summary><b>macOS: the app isn't notarized</b></summary>
There's no paid Apple developer account behind it, so macOS says the app is
damaged or can't be checked. Drag it to Applications, then clear the download
quarantine once:
```sh
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
```
The first time, macOS also asks to allow local network access (for SSH) and
control of Terminal (for the password prompts).
</details>
<details>
<summary><b>Windows: SmartScreen warning</b></summary>
The installer isn't code-signed, so Windows SmartScreen may say it protected
your PC. Choose **More info → Run anyway**. The portable `.zip` avoids the
installer: unzip it anywhere and run `Frame Control.exe`.
</details>
<details>
<summary><b>Linux: running the AppImage</b></summary>
```sh
chmod +x Frame-Control-linux-*.AppImage && ./Frame-Control-linux-*.AppImage
```
If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
or run it with `--appimage-extract-and-run`.
</details>
## Set up the headset
You type one password on the headset, once. Everything else happens on your
computer.
1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
in the Developer section, **Set User Password**. Pick something short:
you'll type it once more on your computer and then never again.
2. **On your computer:** open Frame Control. It offers to **Set Up
Connection**, which finds the headset, creates an SSH key, and asks for that
password once in a terminal window. If it can't find the Frame, type the
IP address from the Frame's Quick Settings.
Before asking for the password it tries Valve's SteamOS devkit pairing: in
the headset, open Steam Settings → Developer → **Pair new host** and approve
the request, and no password is needed. (The service and the pairing-mode
step are verified on a Frame; the approval itself isn't yet. See
[SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).)
3. That's it. The app now reaches the headset whenever it's awake and on the
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
**What it changes:** only what you click. Installs go to your user account on
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
games in `~/devkit-game`), and nothing
needs `sudo` except the power buttons. On your computer it adds a `Host frame`
entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
`~/.ssh/id_rsa_frame_devkit` (the pairing service only takes RSA keys).
## Feedback
This is a first public test, so reports are really useful, especially from
Windows and Linux. Please [open an issue](https://github.com/saphid/steam-frame/issues/new)
with:
- what you tried and what happened
- your computer's OS and your SteamOS build (Steam Settings → System)
- the server log: **Frame → Show Server Log** in the app
## Going further
This repo also holds the scripts behind the app and field notes on how the
Frame's software fits together, all checked against a real headset and labelled
**verified** or **inferred**.
| | |
|---|---|
| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [Open questions](docs/open-questions.md) | What's still unchecked |
<details>
<summary><b>Security notes</b></summary>
- With Developer Mode on, `sshd`, ADB and xrdp are all reachable on your LAN.
Each running Lepton (Android) instance opens its own ADB port in 5555–5599,
listening on `0.0.0.0` rather than only loopback. This was seen on the
device on 2026-09-25, so anyone on the network can reach it. Use trusted
networks only, and turn Developer Mode off when you don't need it.
- Frame Control reaches ADB and the Steam client's DevTools port (Frame
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
database key (maintainer-only) is never written to the repo.
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
you've switched to key auth, a short password still protects `sudo` and
RDP, so pick one that isn't trivially guessable.
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access,
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
**every** Frame port is reachable from your tailnet, including Steam's DevTools
on loopback 8080; see [docs/tailscale.md](docs/tailscale.md).
</details>
## Development
```sh
python3 -m unittest discover -s tests # server tests; no headset needed
cd app && npm install && npm start # run the app from the checkout
```
The server is Python stdlib only; the app is Electron. GitHub Actions runs the
tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
into the release. See [building](docs/frame-control.md#building).
## License
[MIT](LICENSE). Steam, Steam Frame and SteamVR are trademarks of Valve
Corporation. This project isn't affiliated with or endorsed by Valve.
-58
View File
@@ -1,58 +0,0 @@
# Android app catalogue and compatibility reports
The data behind Frame Control's **Android apps** section: every app in the
F-Droid main repo, rated for Lepton (the Frame's Android container), plus our
own compatibility reports. No ProtonDB-style database for sideloaded Android
apps on the Frame existed as of 2026-09-25 (Steam Frame Hub and Valve's
"Great on Frame" cover Steam games only), so we keep our own.
```sh
scripts/frame-ui.sh # Frame Control → Android apps: search, Install, Test, Report
scripts/apk-catalog.sh # refresh the F-Droid data (only scans what changed)
```
## Verdicts
| Verdict | Meaning |
|---|---|
| Works on Frame | The latest report says it runs (automated Test or a person's rating) |
| Should work | No known blocker found in the APK |
| Might work | Something uncertain: Compose version unknown, Godot, Qt, Play Services, no launcher icon (widgets, tiles, keyboards), or a report of issues |
| Probably crashes | Compose UI < 1.11, SDL or Kivy |
| Won't work | Needs Android 12+ or has no 64-bit ARM build, or a report says it's broken |
"Should work" means the app opens. Features that need something Lepton lacks
(browser links, file picker, Play Services, camera app) can still fail. The
rules and the evidence behind them are in [docs/apks.md](../docs/apks.md).
## Compatibility reports
Reports are saved on your Mac. The maintainer's copy of Frame Control also
syncs them to a private Lakebed database (see
[compat-db/README.md](../compat-db/README.md), including backups). **Test**
records whether an app stays up in its own instance (`result`); **Report**
(on any installed app, catalogue card, or **+ Report an APK** for anything else, e.g. an
APK file or your own build) records `works`, `issues` or `broken`, how it was run
(own instance, Lepton Development, other), where the APK came from, and notes. Each report carries
the SteamOS `BUILD_ID` and the Lepton build id. Newest wins, and a person's
rating beats an automated result (`reports.py`).
## Files
| File | Role |
|---|---|
| `zipcd.py` | Reads an APK's zip directory and single entries with HTTP range requests |
| `scan.py` | Per app: native ABIs, frameworks (from `lib/*.so`), Compose/GMS/Firebase resource names from `resources.arsc`. Writes `data/scan.jsonl` |
| `scan2.py` | Per app: Compose UI version, launcher/IME/feature strings from `AndroidManifest.xml`. Writes `data/scan2.jsonl` |
| `pick.py` | Which version to rate and install: newest with an arm64 build (or no native code) and minSdk ≤ 30 |
| `pins.json` | Versions pinned by hand (F-Droid 1.17.2) |
| `reports.py` | How reports override predictions (the reports are in compat-db) |
| `build.py` | Applies the rules and writes `site/apps.js` (predictions; Frame Control adds reports at runtime) |
Frame Control's `ui/frame_catalog.py` loads `site/apps.js`, applies the
reports from `ui/frame_compat_db.py`, downloads APKs (SHA-256 checked against the
F-Droid index), and installs them with `ui/frame_android.py`.
Both scans skip apps whose version hasn't changed. Compose is detected by its
resource ids (`compose_view_saveable_id_tag`) because many apps strip the
`META-INF` version files; those apps are rated "Might work".
-158
View File
@@ -1,158 +0,0 @@
"""Merge the F-Droid index, both APK scans and the on-device results into
site/apps.js, applying the Lepton compatibility rules in docs/apks.md.
Usage: python3 build.py (run from apk-catalog/, after scan.py and scan2.py)
"""
import json, os, re, time
from pick import pick_version, LEPTON_SDK
import reports
HERE = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(HERE, 'data')
REPO = 'https://f-droid.org/repo'
# Compose UI below this crashes on any Compose screen: it casts the missing
# clipboard service to non-null while building AndroidComposeView.
COMPOSE_OK = (1, 11)
RANK = {'works': 0, 'likely': 1, 'maybe': 2, 'unlikely': 3, 'no': 4}
def jsonl(path):
if not os.path.exists(path):
return {}
return {r['pkg']: r for r in map(json.loads, open(path))}
def loc(d):
if not isinstance(d, dict):
return d or ''
return d.get('en-US') or d.get('en') or next(iter(d.values()), '')
def ver_tuple(v):
m = re.match(r'(\d+)\.(\d+)', v or '')
return (int(m[1]), int(m[2])) if m else None
def classify(m, s, s2):
"""Return (verdict, reasons). Hard failures first, then crash signals."""
no, bad, maybe, notes = [], [], [], []
min_sdk = m.get('usesSdk', {}).get('minSdkVersion', 1)
if min_sdk > LEPTON_SDK:
no.append(f'Needs Android API {min_sdk}; Lepton is Android 11 (API 30), so it won\'t install')
native = m.get('nativecode') or s.get('abis') or []
if native and 'arm64-v8a' not in native:
no.append(f'Native code only for {", ".join(native)}; Lepton is 64-bit ARM only, so it won\'t install')
cv = s2.get('compose_ver')
if cv and ver_tuple(cv) and ver_tuple(cv) < COMPOSE_OK:
bad.append(f'Jetpack Compose {cv}: Compose screens crash (no clipboard service); 1.11+ is fine')
elif s.get('compose') and not cv:
maybe.append('Uses Jetpack Compose, version unknown: crashes if older than 1.11')
elif cv:
notes.append(f'Jetpack Compose {cv} (fine)')
fw = set(s.get('frameworks', []))
if 'sdl' in fw or s2.get('sdl3'):
bad.append('SDL app: registers a clipboard listener at start-up and crashes')
if 'kivy' in fw:
bad.append('Kivy (SDL) app: crashes at start-up on the missing clipboard')
if 'godot' in fw:
maybe.append('Godot: 4.3 crashed (clipboard), 4.6 worked')
if 'reactnative' in fw or 'hermes' in fw:
notes.append('React Native: 2 of 3 tested apps worked')
if 'qt' in fw or 'qt6' in fw:
maybe.append('Qt app: the one tested crashed on a missing libc++ symbol')
if 'flutter' in fw:
notes.append('Flutter (tested apps worked)')
if 'gdx' in fw:
notes.append('libGDX (tested games worked)')
if s.get('gms') or s2.get('gms_meta'):
maybe.append('Uses Google Play Services, which Lepton lacks')
if s2 and not s2.get('launcher'):
if s2.get('ime'):
maybe.append('Keyboard (IME), not an app you open; untested in Lepton')
else:
maybe.append('No launcher icon (widget, tile, wallpaper or plug-in)')
feats = s2.get('features', [])
if 'android.hardware.touchscreen.multitouch' in feats:
notes.append('Mentions multi-touch; the Frame pointer is single-touch (inferred)')
if any(f in feats for f in ('android.hardware.telephony', 'android.hardware.nfc')):
notes.append('Mentions telephony or NFC, which Lepton lacks')
if 'android.hardware.type.watch' in feats:
maybe.append('Wear OS watch app')
if no:
return 'no', no + bad + maybe + notes
if bad:
return 'unlikely', bad + maybe + notes
if maybe:
return 'maybe', maybe + notes
if not s or 'error' in s:
return 'maybe', ['APK not scanned'] + notes
return 'likely', notes or ['No known blockers']
def finalize(app, reps):
"""Set the shown verdict ('r', 'why', 't') from the prediction plus any reports."""
rv = reports.verdict(reps)
if rv:
app['r'], lines = rv
app['why'] = lines + ['Rule check: ' + r for r in app['pw'] if not r.startswith('No known')]
else:
app['r'], app['why'] = app['pr'], list(app['pw'])
app['t'] = bool(rv)
return app
def load_catalog(path=None):
"""Read site/apps.js back into a list (for serve.py and Frame Control)."""
src = open(path or os.path.join(HERE, 'site', 'apps.js'), encoding='utf-8').read()
return json.loads(src.split('window.APPS=', 1)[1].rstrip().rstrip(';'))
def main():
idx = json.load(open(os.path.join(DATA, 'index-v2.json')))
s1 = jsonl(os.path.join(DATA, 'scan.jsonl'))
s2 = jsonl(os.path.join(DATA, 'scan2.jsonl'))
pins = json.load(open(os.path.join(HERE, 'pins.json'))) if os.path.exists(os.path.join(HERE, 'pins.json')) else {}
cats = idx.get('repo', {}).get('categories', {})
apps = []
for pkg, p in idx['packages'].items():
if not p.get('versions'):
continue
v = pick_version(p)
md, m = p['metadata'], v['manifest']
verdict, why = classify(m, s1.get(pkg, {}), s2.get(pkg, {}))
apk, sha, shown_ver = REPO + v['file']['name'], v['file'].get('sha256'), m.get('versionName')
pin = pins.get(pkg)
if pin:
apk, sha, shown_ver = pin['apk'], pin['sha256'], pin['version']
why = [pin['why']] + why
icon = loc(md.get('icon'))
apps.append(finalize({
'p': pkg,
'n': loc(md.get('name')) or pkg,
's': loc(md.get('summary')),
'c': [loc(cats.get(c, {}).get('name')) or c for c in md.get('categories', [])],
'i': REPO + icon['name'] if isinstance(icon, dict) and icon.get('name') else '',
'v': shown_ver,
'z': v['file'].get('size'),
'u': md.get('lastUpdated'),
'a': apk,
'h': sha,
'af': sorted(v.get('antiFeatures', {}).keys()),
'pr': verdict,
'pw': why,
}, None))
apps.sort(key=lambda a: (RANK[a['r']], a['n'].lower()))
out = os.path.join(HERE, 'site', 'apps.js')
meta = {'built': time.strftime('%Y-%m-%d'), 'count': len(apps),
'source': 'F-Droid main repo, rated on the newest version each app has that Lepton can install'}
with open(out, 'w') as f:
f.write('window.CATALOG_META=' + json.dumps(meta) + ';\n')
f.write('window.APPS=' + json.dumps(apps, separators=(',', ':'), ensure_ascii=False) + ';\n')
counts = {k: sum(a['r'] == k for a in apps) for k in RANK}
print(out, counts)
if __name__ == '__main__':
main()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-19
View File
@@ -1,19 +0,0 @@
"""Choose which F-Droid version of an app to rate and install.
F-Droid often publishes one APK per ABI under different version codes, and
the highest code is frequently the x86_64 build. Prefer the newest version
Lepton can install (arm64-v8a or no native code, minSdk <= 30), else the newest.
"""
LEPTON_SDK = 30
def installable(v):
m = v['manifest']
native = m.get('nativecode') or []
return (not native or 'arm64-v8a' in native) and \
m.get('usesSdk', {}).get('minSdkVersion', 1) <= LEPTON_SDK
def pick_version(p):
vs = sorted(p['versions'].values(), key=lambda v: v['manifest'].get('versionCode', 0), reverse=True)
return next((v for v in vs if installable(v)), vs[0])
-8
View File
@@ -1,8 +0,0 @@
{
"org.fdroid.fdroid": {
"apk": "https://f-droid.org/archive/org.fdroid.fdroid_1017002.apk",
"sha256": "756b7dfc7fb43ef28c27d276428a2f7826cd482fd794bbd9eeaef24016b2081c",
"version": "1.17.2",
"why": "Pinned to 1.17.2: 1.23.2 crashed (old Compose). 2.0 uses Compose 1.12 but is untested. Don't let it update itself."
}
}
-33
View File
@@ -1,33 +0,0 @@
"""How compatibility reports turn into a verdict. The reports themselves live
in Frame Control's private database (ui/frame_compat_db.py, a Lakebed capsule
in compat-db/); this module is the pure logic shared by the build and the app.
A report: package, version, result (runs | crashes | install_failed |
instance_failed, from an automated test), rating (works | issues | broken, from
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
Newest wins, and a person's rating beats an automated result.
"""
def verdict(reports):
"""(verdict, summary lines) for one package's reports, or None."""
if not reports:
return None
rs = sorted(reports, key=lambda r: r.get('date') or '')
people = [r for r in rs if r.get('rating')]
best = people[-1] if people else rs[-1]
kind = best.get('rating') or best.get('result')
v = {'works': 'works', 'runs': 'works', 'issues': 'maybe'}.get(kind, 'no')
n_ok = sum((r.get('rating') or r.get('result')) in ('works', 'runs') for r in rs)
lines = [f"Reported on a Frame {(best.get('date') or '')[:10]} (v{best.get('version')}): "
f"{kind}{' – ' + best['notes'] if best.get('notes') else ''}"]
if len(rs) > 1:
lines.append(f'{len(rs)} reports, {n_ok} working')
return v, lines
def by_package(reports):
out = {}
for r in reports:
out.setdefault(r['package'], []).append(r)
return out
-123
View File
@@ -1,123 +0,0 @@
"""Scan F-Droid APKs (latest version per app) for Steam Frame / Lepton signals.
Reads only the zip central directory and the resources.arsc key-string pool
through HTTP range requests. Output: one JSON line per package (resumable).
"""
import json, os, struct, sys, zlib, threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import zipcd
from pick import pick_version
REPO = 'https://f-droid.org/repo'
HERE = os.path.dirname(os.path.abspath(__file__))
IDX = os.path.join(HERE, 'data', 'index-v2.json')
OUT = os.path.join(HERE, 'data', 'scan.jsonl')
KEYS = {
'compose': [b'compose_view_saveable_id_tag', b'wrapped_composition_tag',
b'androidx_compose_ui_view_compositionlocal_map'],
'gms': [b'common_google_play_services_unknown_issue',
b'common_google_play_services_install_title'],
'firebase': [b'google_app_id', b'gcm_defaultSenderId'],
}
LIBS = {
'unity': 'libunity.so', 'flutter': 'libflutter.so', 'reactnative': 'libreactnative',
'hermes': 'libhermes', 'godot': 'libgodot_android.so', 'gdx': 'libgdx.so',
'sdl': 'libSDL2.so', 'unreal': 'libUE4.so', 'unreal5': 'libUnreal.so',
'xamarin': 'libmonodroid.so', 'qt': 'libQt5Core', 'qt6': 'libQt6Core',
'cocos': 'libcocos', 'love': 'liblove.so', 'renpy': 'librenpy',
'kivy': 'libpython', 'gomobile': 'libgojni.so',
}
def arsc_keys(url, entries):
comp, csize, lho = entries['resources.arsc']
h = zipcd.rng(url, lho, lho + 29)
nl, el = struct.unpack('<HH', h[26:30])
base = lho + 30 + nl + el
if comp == 8:
blob = zlib.decompress(zipcd.rng(url, base, base + csize - 1), -15)
read = lambda o, n: blob[o:o + n]
elif comp == 0:
read = lambda o, n: zipcd.rng(url, base + o, base + o + n - 1)
else:
raise ValueError(f'arsc compression {comp}')
th = read(0, 12)
if struct.unpack('<H', th[:2])[0] != 2:
raise ValueError('not a ResTable')
off = struct.unpack('<H', th[2:4])[0]
pools = []
total = struct.unpack('<I', th[4:8])[0]
while off < total:
ch = read(off, 8)
ctype, chdr, csz = struct.unpack('<HHI', ch)
if ctype == 0x0200: # package
ph = read(off, 288)
key_off = struct.unpack('<I', ph[8 + 4 + 256 + 8:8 + 4 + 256 + 12])[0]
kh = read(off + key_off, 8)
ksz = struct.unpack('<I', kh[4:8])[0]
pools.append(read(off + key_off, ksz))
if csz <= 0:
break
off += csz
return b''.join(pools)
def scan(pkg, meta, ver):
f = ver['file']
url = REPO + f['name']
m = ver['manifest']
r = {'pkg': pkg, 'vc': m.get('versionCode'), 'vn': m.get('versionName'),
'apk': url, 'size': f.get('size')}
try:
names, entries, _ = zipcd.list_names(url, f['size'])
libs = {n for n in names if n.startswith('lib/')}
r['frameworks'] = sorted(k for k, s in LIBS.items() if any(s in n for n in libs))
r['abis'] = sorted({n.split('/')[1] for n in libs if n.count('/') >= 2})
r['metainf_compose'] = any(n.startswith('META-INF/androidx.compose.ui') for n in names)
r['assets_bin_data'] = any(n.startswith('assets/bin/Data/') for n in names)
if 'resources.arsc' in entries:
kp = arsc_keys(url, entries)
for k, pats in KEYS.items():
r[k] = any(p in kp or p.decode().encode('utf-16-le') in kp for p in pats)
else:
r['no_arsc'] = True
except Exception as e: # keep going; record the failure
r['error'] = f'{type(e).__name__}: {e}'[:200]
return r
def main():
idx = json.load(open(IDX))
done = set()
if os.path.exists(OUT):
for line in open(OUT):
try:
r = json.loads(line)
done.add((r['pkg'], r.get('vc')))
except Exception:
pass
jobs = []
for pkg, p in idx['packages'].items():
if not p.get('versions'):
continue
ver = pick_version(p)
if (pkg, ver['manifest'].get('versionCode')) not in done:
jobs.append((pkg, p['metadata'], ver))
print(f'{len(done)} done, {len(jobs)} to scan', flush=True)
lock = threading.Lock()
n = 0
with open(OUT, 'a') as out, ThreadPoolExecutor(int(os.environ.get('WORKERS', '12'))) as ex:
futs = [ex.submit(scan, *j) for j in jobs]
for fu in as_completed(futs):
r = fu.result()
with lock:
out.write(json.dumps(r) + '\n'); out.flush()
n += 1
if n % 100 == 0:
print(f'{n}/{len(jobs)}', flush=True)
print('finished', flush=True)
if __name__ == '__main__':
main()
-80
View File
@@ -1,80 +0,0 @@
"""Second pass: Compose UI version, launcher activity, GMS meta-data, uses-feature
strings from AndroidManifest.xml (binary XML string pool). Resumable JSONL."""
import json, os, struct, sys, threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import zipcd
HERE = os.path.dirname(os.path.abspath(__file__))
IN = os.path.join(HERE, 'data', 'scan.jsonl')
OUT = os.path.join(HERE, 'data', 'scan2.jsonl')
FEATURES = ['android.hardware.touchscreen.multitouch', 'android.hardware.telephony',
'android.hardware.nfc', 'android.hardware.bluetooth_le', 'android.hardware.usb.host',
'android.hardware.camera', 'android.hardware.vr.high_performance',
'android.software.leanback', 'android.hardware.type.watch']
def axml_strings(b):
# ResXMLTree_header (8) then string pool chunk
off = struct.unpack('<H', b[2:4])[0]
t, hs, sz, cnt, _styles, flags, sstart, _ = struct.unpack('<HHIIIIII', b[off:off + 28])
utf8 = flags & 0x100
offs = struct.unpack(f'<{cnt}I', b[off + hs:off + hs + 4 * cnt])
base = off + sstart
out = []
for o in offs:
p = base + o
if utf8:
n = b[p]; p += 2 if n & 0x80 else 1
n = b[p]; hi = n & 0x80
if hi:
n = ((n & 0x7f) << 8) | b[p + 1]; p += 2
else:
p += 1
out.append(b[p:p + n].decode('utf-8', 'replace'))
else:
n = struct.unpack('<H', b[p:p + 2])[0]; p += 2
if n & 0x8000:
n = ((n & 0x7fff) << 16) | struct.unpack('<H', b[p:p + 2])[0]; p += 2
out.append(b[p:p + 2 * n].decode('utf-16-le', 'replace'))
return out
def scan(r):
o = {'pkg': r['pkg'], 'vc': r.get('vc')}
try:
names, ent, _ = zipcd.list_names(r['apk'], r['size'])
for n in ('META-INF/androidx.compose.ui_ui.version', 'META-INF/androidx.compose.ui_ui-android.version'):
if n in ent:
o['compose_ver'] = zipcd.read_entry(r['apk'], ent, n).decode().strip()
break
o['sdl3'] = any(n.endswith('/libSDL3.so') for n in names)
s = set(axml_strings(zipcd.read_entry(r['apk'], ent, 'AndroidManifest.xml')))
o['launcher'] = 'android.intent.category.LAUNCHER' in s
o['leanback_launcher'] = 'android.intent.category.LEANBACK_LAUNCHER' in s
o['gms_meta'] = 'com.google.android.gms.version' in s
o['ime'] = 'android.view.InputMethod' in s
o['features'] = [f for f in FEATURES if f in s]
except Exception as e:
o['error2'] = f'{type(e).__name__}: {e}'[:200]
return o
def main():
rows = list({r['pkg']: r for r in map(json.loads, open(IN))}.values()) # latest per app
done = set()
if os.path.exists(OUT):
done = {(o['pkg'], o.get('vc')) for o in map(json.loads, open(OUT))}
rows = [r for r in rows if (r['pkg'], r.get('vc')) not in done and 'error' not in r]
print(len(done), 'done', len(rows), 'todo', flush=True)
lock = threading.Lock(); n = 0
with open(OUT, 'a') as out, ThreadPoolExecutor(int(os.environ.get('WORKERS', '40'))) as ex:
for fu in as_completed([ex.submit(scan, r) for r in rows]):
with lock:
out.write(json.dumps(fu.result()) + '\n'); out.flush(); n += 1
if n % 200 == 0:
print(n, flush=True)
print('finished', flush=True)
if __name__ == '__main__':
main()
-2
View File
@@ -1,2 +0,0 @@
window.CATALOG_META={"built": "2026-09-25", "count": 4455, "source": "F-Droid main repo, rated on the newest version each app has that Lepton can install"};
window.APPS=[{"p":"com.terokarvinen.x54ask","n":"0x54ask","s":"Todo.txt manager. Offline, works with Syncthing. Fork of SimpleTask Cloudless","c":["Calendar & Agenda","Note","Task"],"i":"https://f-droid.org/repo/com.terokarvinen.x54ask/en-US/icon_FOzSYq6etfsaWRiMc7bx-8vLVKtsug1dmhT9NvxRj9w=.png","v":"1.1.2 (fork of Simpletask)","z":13037003,"u":1788427366142,"a":"https://f-droid.org/repo/com.terokarvinen.x54ask_1010200.apk","h":"f05781226bb84205caa5b5aa6a511afcb8df86de8bc4b53e33b7de34c2940e8a","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.github.ashutoshgngwr.tenbitclockwidget","n":"10-bit Clock Widget","s":"A beautiful BCD clock for your home screen","c":["Clock"],"i":"https://f-droid.org/repo/com.github.ashutoshgngwr.tenbitclockwidget/en-US/icon_TrUyJLRoXZGniCc2uQM3OnsVmlOokr_KZk0ZQaPrtjY=.png","v":"2.2-1","z":1281564,"u":1696789501000,"a":"https://f-droid.org/repo/com.github.ashutoshgngwr.tenbitclockwidget_221.apk","h":"35ff9940fd3d73acd1099f3640be6367c311ec9f6c34fa17e4748e874ecfe763","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"dev.lonami.klooni","n":"1010! Klooni","s":"A libGDX game based on 1010","c":["Puzzle Game"],"i":"https://f-droid.org/repo/icons/dev.lonami.klooni.860.png","v":"0.8.6","z":2735506,"u":1598918400000,"a":"https://f-droid.org/repo/dev.lonami.klooni_860.apk","h":"55641cdb5dba7f30c1d229cf8a34f390a8ff6b3f60cdff9b45d277919f33ce24","af":[],"pr":"likely","pw":["libGDX (tested games worked)"],"r":"likely","why":["libGDX (tested games worked)"],"t":false},{"p":"eu.quelltext.counting","n":"12345 - Learn Counting","s":"Learn counting in different languages with pictures","c":["Educational Game","Science & Education"],"i":"https://f-droid.org/repo/eu.quelltext.counting/en-US/icon_30ymRTCTMZiTzSNXPRLEOukBSubDfmp1CV_cpbGudKw=.png","v":"1.3","z":2413060,"u":1646352000000,"a":"https://f-droid.org/repo/eu.quelltext.counting_3.apk","h":"98fe65f21ff8e51918b94e80d25d99d52f5527d24a69dcd8dd9ca1a5da9b7a02","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.roufsyed.onekey","n":"1Key Password Manager","s":"Offline password manager. 2FA + notes. No account, no network, no telemetry.","c":["Password & 2FA","Security"],"i":"https://f-droid.org/repo/com.roufsyed.onekey/en-US/icon_7Oq_UnE5rGthf-UdC05ENbWiZZe00b9J8cKU2qrdVMQ=.png","v":"1.1.1","z":4738420,"u":1784362608829,"a":"https://f-droid.org/repo/com.roufsyed.onekey_3.apk","h":"690a58bb75780d9f835183ae6deb563e06db659218a4275ddd40ba15353d66ce","af":[],"pr":"likely","pw":["Jetpack Compose 1.11.2 (fine)"],"r":"likely","why":["Jetpack Compose 1.11.2 (fine)"],"t":false},{"p":"org.og8.a1tox","n":"1toX","s":"Remember numbers quick to train your brain","c":["Educational Game"],"i":"https://f-droid.org/repo/icons/org.og8.a1tox.1.png","v":"1.00","z":639637,"u":1567641600000,"a":"https://f-droid.org/repo/org.og8.a1tox_1.apk","h":"34895a84a638d53bd5ed57d134511eee9468f5461cb0e41874a1968ac256e4c8","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.dasp.worldcup2026","n":"2026 Football Fixtures Widget","s":"2026 football fixtures and widgets.","c":["Sports & Health"],"i":"","v":"0.1.0","z":33934,"u":1780506857489,"a":"https://f-droid.org/repo/com.dasp.worldcup2026_1.apk","h":"8c7b60c9cef5a6343f000f12ff0a0714bc6f3de72ced17c57f1ce4a147bc4a67","af":["NonFreeNet"],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"org.secuso.privacyfriendly2048","n":"2048 (Privacy Friendly)","s":"(SECUSO) Try to reach 2048 in this puzzle game","c":["Puzzle Game"],"i":"https://f-droid.org/repo/org.secuso.privacyfriendly2048/en-US/icon__EtkwPp725lQQYnzjkzDUiOqD2X5nnY1CiZSIYN9TVU=.png","v":"1.4.2","z":9294779,"u":1753701498000,"a":"https://f-droid.org/repo/org.secuso.privacyfriendly2048_100.apk","h":"02c799d3d582669daf2acf920093c68d2933f60aa937bb72fa2a805557233fe8","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"org.mattvchandler.a2050","n":"2050","s":"A game loosely based on 2048, but with circles instead of squares","c":["Puzzle Game"],"i":"https://f-droid.org/repo/org.mattvchandler.a2050/en-US/icon_3BMQD76YZDYHbtVP8WR8CTKi6E7pd6L82YveKdLHjR4=.png","v":"1.0.10","z":5079962,"u":1693608133000,"a":"https://f-droid.org/repo/org.mattvchandler.a2050_190010010.apk","h":"98a0e75e589c319093db56cf98bfa32d920b9436a9cbe7c30b32dcf7a4a6d284","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"nl.eventinfra.wifisetup","n":"37C3 Wifi Setup","s":"Official NOC application for connecting to the 36C3 Wi-Fi","c":["Connectivity"],"i":"","v":"0.37","z":2405866,"u":1729155289000,"a":"https://f-droid.org/repo/nl.eventinfra.wifisetup_20231222.apk","h":"aa0ca052e9e48ad7945f9aa535f5fd691018a5356a8ff95b0e5bf94662a54a10"Line truncated
-31
View File
@@ -1,31 +0,0 @@
import struct, urllib.request, zlib
UA={'User-Agent':'steam-frame-compat-scan/1.0'}
def rng(url, start, end=None):
h=dict(UA); h['Range']=f'bytes={start}-' if end is None else f'bytes={start}-{end}'
with urllib.request.urlopen(urllib.request.Request(url,headers=h),timeout=60) as r:
return r.read()
def tail(url, n):
h=dict(UA); h['Range']=f'bytes=-{n}'
with urllib.request.urlopen(urllib.request.Request(url,headers=h),timeout=60) as r:
return r.read()
def list_names(url, size):
t=tail(url, min(size, 65557))
i=t.rfind(b'PK\x05\x06')
if i<0: raise ValueError('no EOCD')
cd_size, cd_off = struct.unpack('<II', t[i+12:i+20])
base=size-len(t)
cd = t[cd_off-base:cd_off-base+cd_size] if cd_off>=base else rng(url, cd_off, cd_off+cd_size-1)
names=[]; p=0; entries={}
while p+46<=len(cd) and cd[p:p+4]==b'PK\x01\x02':
comp,=struct.unpack('<H',cd[p+10:p+12])
csize,usize=struct.unpack('<II',cd[p+20:p+28])
nl,el,cl=struct.unpack('<HHH',cd[p+28:p+34]); lho,=struct.unpack('<I',cd[p+42:p+46])
n=cd[p+46:p+46+nl].decode('utf-8','replace'); names.append(n); entries[n]=(comp,csize,lho)
p+=46+nl+el+cl
return names, entries, cd_size
def read_entry(url, entries, name):
comp,csize,lho=entries[name]
h=rng(url, lho, lho+29)
nl,el=struct.unpack('<HH',h[26:30])
data=rng(url, lho+30+nl+el, lho+30+nl+el+csize-1)
return zlib.decompress(data,-15) if comp==8 else data
-3
View File
@@ -1,3 +0,0 @@
node_modules/
dist/
build/deps/
-134
View File
@@ -1,134 +0,0 @@
// Downloads what the app bundles so users install nothing else: a standalone
// Python (python-build-standalone), adb (Android platform-tools) and a CA
// bundle. Each goes in build/deps/<os>-<arch>/{python,tools}, which package.json
// copies into the app's resources. Everything is pinned by version and SHA-256.
// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
const crypto = require("crypto");
const fs = require("fs");
const https = require("https");
const path = require("path");
const { execFileSync } = require("child_process");
const PY = "3.12.14+20260924";
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
+ `${PY.split("+")[1]}/cpython-${PY}-${triple}-install_only_stripped.tar.gz`;
const PYTHON = {
"mac-arm64": ["aarch64-apple-darwin", "c2edb321cd32ec2b170df208db0446dccc4398db602ca27cf2079098fb1f7d9d"],
"win-x64": ["x86_64-pc-windows-msvc", "c5bf8edfe858c1df9891be498b5bbc8761d383df5b9790658b088fea4870433a"],
"linux-x64": ["x86_64-unknown-linux-gnu", "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41"],
"linux-arm64": ["aarch64-unknown-linux-gnu", "c8499b61252c433280f134df954464d19811527b31cb920c35fc6967c1222e35"],
};
// Google publishes no arm64 Linux platform-tools; there the app uses the system adb.
const PT = "37.0.1";
const PT_URL = (os) => `https://dl.google.com/android/repository/platform-tools_r${PT}-${os}.zip`;
const TOOLS = {
mac: ["darwin", "ee39ad5967e95c2a07f04dbcbde96b1a0c916ba376096db5d2f498b7727a5d1d", ["adb"]],
win: ["win", "45f4d63113e895ebde0c90f194099a4676b6ac653bd28d54314a9e022bbc1a99",
["adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll", "libwinpthread-1.dll"]],
linux: ["linux", "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1", ["adb"]],
};
// Mozilla's CA list, as curl publishes it: Python on Windows only trusts roots
// already in the Windows store (see frame_host.trust_bundled_cas).
const CA = "2026-09-25";
const CA_SHA256 = "a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505";
// Parts of Python the server never imports (GUI, tests, packaging, headers).
const PRUNE = [
"include", "share", "Scripts", "libs", "tcl", "lib/pkgconfig", "lib/itcl4", "lib/tcl8", "lib/tcl8.6",
"lib/tk8.6", "lib/thread2.8", "bin/idle3", "bin/idle3.12", "bin/pip", "bin/pip3", "bin/pip3.12",
"bin/pydoc3", "bin/pydoc3.12", "bin/2to3", "bin/2to3-3.12", "bin/python3-config", "bin/python3.12-config",
...["test", "idlelib", "tkinter", "turtledemo", "ensurepip", "lib2to3", "site-packages/pip", "pydoc_data", "venv"]
.flatMap((d) => [`lib/python3.12/${d}`, `Lib/${d}`]),
];
function get(url, redirects = 5) {
return new Promise((resolve, reject) => {
https.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (!redirects) return reject(new Error(`${url}: too many redirects`));
let next;
try { next = new URL(res.headers.location, url).href; }
catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); }
return resolve(get(next, redirects - 1));
}
if (res.statusCode !== 200) return reject(new Error(`${url}: HTTP ${res.statusCode}`));
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
}).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject);
});
}
async function download(url, sha256, file) {
const data = await get(url);
const sum = crypto.createHash("sha256").update(data).digest("hex");
if (sum !== sha256) throw new Error(`checksum mismatch for ${url}: ${sum}`);
fs.writeFileSync(file, data);
}
// Windows' own bsdtar: Git's GNU tar, often first on PATH, reads C:\ as a remote host.
const TAR = process.platform === "win32" ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe") : "tar";
function extract(file, dir) {
fs.mkdirSync(dir, { recursive: true });
// bsdtar (macOS, Windows 10+) reads zip files; GNU tar doesn't, so fall back to unzip.
try { execFileSync(TAR, ["-xf", file, "-C", dir]); }
catch (e) {
if (!file.endsWith(".zip")) throw e;
execFileSync("unzip", ["-q", "-o", file, "-d", dir]);
}
fs.rmSync(file);
}
async function fetch(os, arch) {
const key = `${os}-${arch}`;
if (!PYTHON[key]) throw new Error(`no bundle for ${key}`);
const out = path.join(__dirname, "deps", key);
const stamp = path.join(out, ".version");
const version = `python ${PY}, platform-tools ${PT}, CA ${CA}`;
if (fs.existsSync(stamp) && fs.readFileSync(stamp, "utf8") === version) {
console.log(`${key}: already fetched (${version})`);
return;
}
fs.rmSync(out, { recursive: true, force: true });
fs.mkdirSync(out, { recursive: true });
const [triple, pySha] = PYTHON[key];
const tgz = path.join(out, "python.tar.gz");
await download(PY_URL(triple), pySha, tgz);
extract(tgz, out); // unpacks to python/
for (const p of PRUNE) fs.rmSync(path.join(out, "python", p), { recursive: true, force: true });
const stdlib = path.join(out, "python", "lib", "python3.12"); // macOS and Linux: drop the static libpython
if (fs.existsSync(stdlib)) {
for (const d of fs.readdirSync(stdlib)) {
if (d.startsWith("config-3.12")) fs.rmSync(path.join(stdlib, d), { recursive: true, force: true });
}
}
const tools = path.join(out, "tools");
fs.mkdirSync(tools);
if (!(os === "linux" && arch === "arm64")) {
const [name, ptSha, keep] = TOOLS[os];
const zip = path.join(out, "pt.zip");
const tmp = path.join(out, "pt");
await download(PT_URL(name), ptSha, zip);
extract(zip, tmp);
for (const f of [...keep, "NOTICE.txt", "source.properties"]) {
fs.copyFileSync(path.join(tmp, "platform-tools", f), path.join(tools, f));
}
if (os !== "win") fs.chmodSync(path.join(tools, "adb"), 0o755);
fs.rmSync(tmp, { recursive: true, force: true });
}
await download(`https://curl.se/ca/cacert-${CA}.pem`, CA_SHA256, path.join(tools, "cacert.pem"));
fs.writeFileSync(stamp, version);
console.log(`${key}: ${version} -> ${out}`);
}
(async () => {
const [os, ...archs] = process.argv.slice(2);
if (!os || !archs.length) throw new Error("usage: node build/fetch-deps.js <mac|win|linux> <arch>...");
for (const arch of archs) await fetch(os, arch);
})().catch((e) => { console.error(e.message); process.exit(1); });
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

-30
View File
@@ -1,30 +0,0 @@
<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>
<g clip-path="url(#tile)">
<rect x="100" y="100" width="824" height="824" fill="url(#bg)"/>
</g>
<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"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-32
View File
@@ -1,32 +0,0 @@
// Renders build/icon.svg to icon.png (1024px) and icon.icns. Run: npm run icon
const { app, BrowserWindow } = require("electron");
const { execFileSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
app.dock?.hide();
app.whenReady().then(async () => {
const win = new BrowserWindow({ width: 1024, height: 1024, show: false, transparent: true, frame: false,
useContentSize: true, webPreferences: { offscreen: true } });
const svg = fs.readFileSync(path.join(__dirname, "icon.svg"), "utf8");
await win.loadURL("data:text/html," + encodeURIComponent(
`<body style="margin:0;background:transparent">${svg}</body>`));
await new Promise((r) => setTimeout(r, 300));
const png = (await win.webContents.capturePage({ x: 0, y: 0, width: 1024, height: 1024 }))
.resize({ width: 1024, height: 1024 }).toPNG();
fs.writeFileSync(path.join(__dirname, "icon.png"), png);
const set = fs.mkdtempSync(path.join(os.tmpdir(), "icon-")) + "/icon.iconset";
fs.mkdirSync(set);
for (const size of [16, 32, 128, 256, 512]) {
for (const scale of [1, 2]) {
const px = size * scale, name = `icon_${size}x${size}${scale === 2 ? "@2x" : ""}.png`;
execFileSync("sips", ["-z", String(px), String(px), path.join(__dirname, "icon.png"),
"--out", path.join(set, name)], { stdio: "ignore" });
}
}
execFileSync("iconutil", ["-c", "icns", set, "-o", path.join(__dirname, "icon.icns")]);
console.log("wrote build/icon.png and build/icon.icns");
app.quit();
});
-33
View File
@@ -1,33 +0,0 @@
// Parses frame-control://install?manifest=URL and frame-control://install?url=URL
// (see docs/web-install.md). Pure, so it runs under plain node for the tests.
// This is only a first filter: ui/frame_webinstall.py applies the full URL rules
// (HTTPS, no private addresses, redirects) before anything is fetched.
const SCHEME = "frame-control";
const MAX_LINK = 4096;
const MAX_URL = 2048;
// {kind: "manifest" | "url", target} or null if raw isn't a usable install link.
function parseInstallLink(raw) {
if (typeof raw !== "string" || raw.length > MAX_LINK || !raw.toLowerCase().startsWith(`${SCHEME}:`)) return null;
let link;
try { link = new URL(raw); } catch { return null; }
// frame-control://install?… puts "install" in the host; accept a trailing slash too.
if (link.protocol !== `${SCHEME}:` || link.hostname !== "install" || !["", "/"].includes(link.pathname)) return null;
const keys = [...new Set(link.searchParams.keys())];
if (keys.length !== 1 || !["manifest", "url"].includes(keys[0])) return null;
const values = link.searchParams.getAll(keys[0]);
if (values.length !== 1) return null;
const target = values[0];
if (!target || target.length > MAX_URL) return null;
let parsed;
try { parsed = new URL(target); } catch { return null; }
if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password) return null;
return { kind: keys[0], target };
}
// The link among command-line arguments (Windows and Linux pass it there).
function linkFromArgv(argv) {
return (argv || []).find((a) => typeof a === "string" && a.toLowerCase().startsWith(`${SCHEME}:`)) || null;
}
module.exports = { SCHEME, parseInstallLink, linkFromArgv };
-394
View File
@@ -1,394 +0,0 @@
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
const http = require("http");
const net = require("net");
const os = require("os");
const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
const run = promisify(execFile);
const IS_MAC = process.platform === "darwin";
const IS_WIN = process.platform === "win32";
// Packaged: <resources>/{ui,scripts,python}. Dev: the repo checkout.
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
const TOOLS = path.join(ROOT, "tools"); // bundled adb and CA certificates
const SERVER = path.join(ROOT, "ui", "server.py");
const SCRIPTS = path.join(ROOT, "scripts");
const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Control")
: path.join(app.getPath("userData"), "logs");
const LOG = path.join(LOG_DIR, "server.log");
const BG = "#0d1117";
const FRAME = process.env.FRAME_ALIAS || "frame";
let server = null;
let url = null;
let win = null;
let quitting = false;
let python = null;
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses
// Homebrew's python3, rsync and adb (desktop launchers on Linux can be as bare).
// Take PATH from the login shell instead. Windows has no login shell to ask.
// Runs asynchronously so a slow shell profile can't freeze the window.
let cachedPath = null;
async function loginPath() {
if (IS_WIN) return process.env.PATH || "";
if (cachedPath) return cachedPath;
const shellPath = os.userInfo().shell || process.env.SHELL || (IS_MAC ? "/bin/zsh" : "/bin/sh");
const extra = IS_MAC ? ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")] : [];
let fromShell = "";
try {
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
{ encoding: "utf8", timeout: 5000 });
fromShell = (stdout.match(/__PATH__(.*)__PATH__/) || [])[1] || "";
} catch {}
const parts = [...fromShell.split(":"), ...(process.env.PATH || "").split(":"), ...extra];
const joined = [...new Set(parts.filter(Boolean))].join(":");
if (fromShell) cachedPath = joined; // retry next time if the shell didn't answer
return joined;
}
// The Windows build bundles Python; elsewhere use the system's python3 (3.8+).
// -I ignores PYTHON* variables and user site-packages, so a PYTHONHOME or
// PYTHONPATH set for another Python can't break the bundled one. That makes these
// flags stand in for PYTHONUNBUFFERED, PYTHONDONTWRITEBYTECODE (no __pycache__
// inside the signed app) and PYTHONUTF8.
const PY_FLAGS = ["-I", "-u", "-B", "-X", "utf8"];
async function findPython(env) {
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"];
// The packaged app bundles Python (app/build/fetch-deps.js); a checkout uses PATH.
const candidates = [path.join(ROOT, "python", ...(IS_WIN ? ["python.exe"] : ["bin", "python3"]))];
for (const dir of env.PATH.split(path.delimiter)) {
// The WindowsApps "python.exe" is a stub that opens the Microsoft Store.
if (!dir || (IS_WIN && /\\WindowsApps\\?$/i.test(dir))) continue;
for (const name of names) candidates.push(path.join(dir, name));
}
for (const p of candidates) {
try {
fs.accessSync(p, fs.constants.X_OK);
// /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed.
await run(p, [...PY_FLAGS, "-c", "import http.server, sys; assert sys.version_info >= (3, 8)"],
{ timeout: 10000, env, windowsHide: true });
return p;
} catch {}
}
return null;
}
async function hasSsh(env) {
try { await run("ssh", ["-V"], { timeout: 5000, env, windowsHide: true }); return true; } catch { return false; }
}
const PYTHON_HELP = app.isPackaged ? "The bundled Python is missing; reinstall Frame Control."
: "Install Python 3.8 or later, then reopen the app.";
const SSH_HELP = IS_WIN
? "Turn on Windows' OpenSSH client: Settings → System → Optional features → Add a feature → OpenSSH Client."
: "Install the OpenSSH client (e.g. sudo apt install openssh-client).";
function freePort() {
return new Promise((resolve, reject) => {
const s = net.createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); });
});
}
// Ready once the port answers with our Server header.
function ping(target) {
return new Promise((resolve) => {
const req = http.get(target, { timeout: 1000 }, (res) => {
res.resume();
resolve(/^FrameControl/.test(res.headers.server || ""));
});
req.on("error", () => resolve(false));
req.on("timeout", () => { req.destroy(); resolve(false); });
});
}
async function startServer() {
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
python = await findPython(env);
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`);
const port = await freePort();
fs.mkdirSync(LOG_DIR, { recursive: true });
const log = fs.openSync(LOG, "a");
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
// stdin stays open while the app runs; the server exits cleanly when it closes.
const child = spawn(python, [...PY_FLAGS, SERVER, "--port", String(port), "--exit-on-eof"],
{ env, stdio: ["pipe", log, log], windowsHide: true });
child.stdin.on("error", () => {});
fs.closeSync(log);
server = child;
let exited = null;
child.once("error", (err) => {
exited = err.message;
if (server === child) { server = null; if (!quitting && url) serverDied(err.message); }
});
child.once("exit", (code, signal) => {
exited = signal || code;
if (server !== child) return; // replaced by Restart Server
server = null;
if (!quitting && url) serverDied(exited);
});
const target = `http://127.0.0.1:${port}/`;
for (let i = 0; i < 100; i++) {
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
if (await ping(target)) { url = target; return; }
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
endServer(child);
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
}
// Closing stdin lets server.py close its SSH connections and exit (the only clean
// way on Windows); SIGTERM does the same elsewhere.
function endServer(child) {
try { child.stdin.end(); } catch {}
if (!IS_WIN) child.kill("SIGTERM");
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
}
function stopServer() {
if (server) endServer(server);
}
function errorPage(message) {
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
}
function serverDied(why) {
url = null;
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
}
async function restartServer() {
const old = server;
server = null;
url = null;
if (old) endServer(old);
await load();
}
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
const CHROME_CSS = IS_MAC && `
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
`;
// Restart Server can start a new load while an older one is still waiting for
// its server; only the newest load may touch the window.
let loadGen = 0;
async function load() {
const gen = ++loadGen;
try {
if (!url) await startServer();
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
} catch (e) {
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
}
}
// `ssh -G` prints the effective config. An alias nobody configured keeps its
// own name as HostName; connect.sh always writes a HostName.
async function aliasConfigured(env) {
try {
const { stdout } = await run("ssh", ["-G", FRAME], { encoding: "utf8", timeout: 5000, env });
return (stdout.match(/^hostname (.*)$/m) || [])[1] !== FRAME;
} catch {
return true; // can't tell; don't nag
}
}
let setupOffered = false;
async function firstRunCheck() {
if (setupOffered || !url) return; // not on the error page, and once per launch
if (await aliasConfigured({ ...process.env, PATH: await loginPath() })) return;
if (!win || setupOffered) return;
setupOffered = true;
const { response } = await dialog.showMessageBox(win, {
type: "info",
message: "Connect to your Steam Frame",
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → `
+ "Enable Developer Mode, then Developer → Set User Password. Then run the setup: it finds the "
+ "headset, creates a key, and asks for that password once in a terminal window.",
buttons: ["Set Up Connection…", "Later"],
defaultId: 0, cancelId: 1,
});
if (response === 0) setUpConnection();
}
// IPC only from our own page in our own window.
function fromUi(e) {
if (!win || e.sender !== win.webContents || !url || !e.senderFrame) return false;
try {
return new URL(e.senderFrame.url).origin === new URL(url).origin;
} catch { return false; }
}
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
// frame-control://install links from websites (docs/web-install.md). They can
// arrive before the window or server exists (macOS open-url on a cold launch),
// so they wait here until the page asks for them. The page checks the link with
// the server and installs nothing until the user confirms in its dialog.
const pendingLinks = [];
let linkPage = null; // the webContents whose current page is listening
function openInstallLink(raw) {
const req = parseInstallLink(raw);
if (!req) {
app.whenReady().then(() => dialog.showErrorBox("Frame Control can't use this link",
"Install links look like frame-control://install?manifest=https://… or frame-control://install?url=https://…"));
return;
}
pendingLinks.push(req);
if (pendingLinks.length > 5) pendingLinks.shift(); // a page opening links in a loop
deliverLinks();
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
}
function deliverLinks() {
if (!win || !linkPage || linkPage !== win.webContents) return;
while (pendingLinks.length) win.webContents.send("install-link", pendingLinks.shift());
}
ipcMain.on("install-link:ready", (e) => {
if (!fromUi(e)) return;
linkPage = e.sender;
deliverLinks();
});
function registerScheme() {
// A checkout runs as `electron .`, so the OS must be told the script too.
// (macOS takes the scheme from Info.plist, which only the built app has.)
if (process.defaultApp) {
if (process.argv.length >= 2) app.setAsDefaultProtocolClient(SCHEME, process.execPath, [path.resolve(process.argv[1])]);
} else {
app.setAsDefaultProtocolClient(SCHEME);
}
}
function createWindow() {
win = new BrowserWindow({
width: 1400, height: 950, minWidth: 760, minHeight: 560,
title: "Frame Control", backgroundColor: BG, show: false,
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
: { icon: path.join(__dirname, "build", "icon.png") }),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
preload: path.join(__dirname, "preload.js") },
});
win.once("ready-to-show", () => win.show());
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
// External links open in the default browser; the app never navigates away.
win.webContents.setWindowOpenHandler(({ url: target }) => {
if (/^https?:\/\//.test(target)) shell.openExternal(target);
return { action: "deny" };
});
win.webContents.on("will-navigate", (e, target) => {
if (!url || new URL(target).origin !== new URL(url).origin) e.preventDefault();
});
// A reload or a new page must ask for links again before it gets any.
win.webContents.on("did-start-loading", () => { linkPage = null; });
win.on("closed", () => { win = null; linkPage = null; });
load();
}
// Opens a terminal window (Terminal, a Linux terminal emulator or a console) via
// ui/frame_host.py, which the server uses too: setup and power actions ask for the
// Developer Mode password there.
async function runInTerminal(argv) {
try {
const env = { ...process.env, PATH: await loginPath() };
const py = python || await findPython(env);
if (!py) throw new Error(`Python 3.8 or later is needed. ${PYTHON_HELP}`);
await run(py, [...PY_FLAGS, path.join(ROOT, "ui", "frame_host.py"), "terminal", "--", ...argv],
{ env, timeout: 15000, windowsHide: true });
} catch (err) {
dialog.showErrorBox("Couldn't open a terminal", String((err.stderr || err.message || err)).trim());
}
}
async function setUpConnection() {
const alias = `FRAME_ALIAS=${FRAME}`;
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
// A new console inherits our environment on Windows; Linux terminals may not.
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
}
function buildMenu() {
const template = [
...(IS_MAC ? [{ role: "appMenu" }] : []),
{ role: "fileMenu" },
{ role: "editMenu" },
{
label: "Frame",
submenu: [
{ label: "Set Up Connection…", click: setUpConnection },
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
{ type: "separator" },
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
{ label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) },
...(IS_WIN ? [] : [{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) }]),
],
},
{
label: "View",
submenu: [
{ role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" },
{ type: "separator" }, { role: "togglefullscreen" },
],
},
...(IS_MAC ? [{ role: "windowMenu" }] : []),
{
role: "help",
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
// macOS delivers install links here, even before the app is ready.
app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); });
// Windows and Linux start a second instance with the link as an argument.
app.on("second-instance", (_e, argv) => {
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
const link = linkFromArgv(argv);
if (link) openInstallLink(link);
});
const firstLink = IS_MAC ? null : linkFromArgv(process.argv);
if (firstLink) openInstallLink(firstLink);
app.whenReady().then(() => {
registerScheme();
buildMenu();
createWindow();
});
app.on("activate", () => { if (!win) createWindow(); });
app.on("window-all-closed", () => app.quit());
app.on("before-quit", () => { quitting = true; stopServer(); });
process.on("exit", stopServer);
}
-3591
View File
File diff suppressed because it is too large. Load diff
-163
View File
@@ -1,163 +0,0 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.3.1",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
"license": "MIT",
"scripts": {
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
"dist": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
"dist:dir": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
},
"devDependencies": {
"electron": "^44.4.5",
"electron-builder": "^26.15.3"
},
"build": {
"appId": "com.saphid.frame-control",
"productName": "Frame Control",
"protocols": [
{
"name": "Frame Control install link",
"schemes": [
"frame-control"
]
}
],
"directories": {
"output": "dist",
"buildResources": "build"
},
"files": [
"main.js",
"preload.js",
"install-link.js",
"package.json",
"build/icon.png"
],
"extraResources": [
{
"from": "../ui",
"to": "ui",
"filter": [
"*.py",
"*.html"
]
},
{
"from": "../scripts",
"to": "scripts",
"filter": [
"*.sh"
]
},
{
"from": "../frame/android",
"to": "frame/android",
"filter": [
"*.sh",
"*.py"
]
},
{
"from": "../frame/devkit-utils",
"to": "frame/devkit-utils",
"filter": [
"**/*",
"!**/__pycache__/**"
]
},
{
"from": "../apk-catalog",
"to": "apk-catalog",
"filter": [
"*.py",
"pins.json",
"site/apps.js"
]
},
{
"from": "build/deps/${os}-${arch}/python",
"to": "python",
"filter": [
"**/*"
]
},
{
"from": "build/deps/${os}-${arch}/tools",
"to": "tools",
"filter": [
"**/*"
]
}
],
"mac": {
"category": "public.app-category.utilities",
"icon": "build/icon.icns",
"identity": "-",
"hardenedRuntime": false,
"target": [
"dmg",
"zip"
],
"extendInfo": {
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
},
"artifactName": "Frame-Control-mac-${arch}.${ext}"
},
"dmg": {
"title": "Frame Control ${version}"
},
"electronFuses": {
"runAsNode": false,
"enableNodeOptionsEnvironmentVariable": false,
"enableNodeCliInspectArguments": false
},
"linux": {
"target": [
"AppImage",
"deb"
],
"category": "Utility",
"icon": "build/icon.png",
"executableName": "frame-control",
"synopsis": "Manage a Valve Steam Frame over SSH",
"artifactName": "Frame-Control-linux-${arch}.${ext}",
"desktop": {
"entry": {
"StartupWMClass": "frame-control"
}
}
},
"deb": {
"depends": [
"openssh-client"
]
},
"win": {
"target": [
"nsis",
"zip"
],
"icon": "build/icon.png",
"artifactName": "Frame-Control-win-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"artifactName": "Frame-Control-Setup-${arch}.${ext}"
}
},
"homepage": "https://github.com/saphid/steam-frame",
"author": {
"name": "saphid",
"email": "4596216+saphid@users.noreply.github.com"
}
}
-17
View File
@@ -1,17 +0,0 @@
// Lets the page read this computer's clipboard through Electron, so sending it
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// 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 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.
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link");
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
ipcRenderer.send("install-link:ready");
},
});
-2
View File
@@ -1,2 +0,0 @@
.lakebed/
.env.lakebed.server
-88
View File
@@ -1,88 +0,0 @@
# Lakebed app instructions
Treat this capsule directory as the whole app. Use Lakebed's built-in APIs and CLI.
## Limits to check first
- The public alpha is not production-ready.
- App code cannot use arbitrary npm packages or Node built-ins. Do not install app dependencies.
- Database fields support `string()`, `boolean()`, `number()`, `id(...)`, and `userId()`. Chain `.optional()` or `.default(value)` on any field.
- Local database data and uploaded files reset when the dev server restarts.
- Hosted server secrets and outbound server-side `fetch` require a claimed deploy.
- Unclaimed deploys expire. Use the expiry printed by the CLI. Claimed deploys do not expire.
## App structure and APIs
- `server/index.ts` exports the default `capsule()` definition. Put server code in `server/`. Import from `lakebed/server` or relative server and shared files.
- `client/index.tsx` exports `App`. Put client code in `client/`. Import from `lakebed/client`, `preact`, `preact/hooks`, `preact/jsx-runtime`, `preact/jsx-dev-runtime`, or relative client and shared files.
- Keep `shared/` pure TypeScript. Do not import DOM APIs, Node built-ins, env values, or Lakebed runtimes there.
- In client code, use `import type app from "../server/index"` and `createClient<typeof app>()` for typed queries, mutations, and actions. Query hooks return `undefined` until the first result arrives.
- Database calls are async. Await or return every database operation. Declare indexes with `.index(name, fields)` and query with `withIndex`. Use `by_creation` for unfiltered creation-order queries. Do not use legacy `where`, `orderBy`, `limit`, or `all`.
- Queries and actions cannot write to the database. Use mutations or endpoints for writes. Filter user-owned data by the caller's `userId` and check ownership again before updates or deletes.
- Guests get protected browser sessions without setup. Use `ctx.auth` on the server and `useAuth()` on the client. A user ID is not a credential. Do not invent guest IDs or check their prefixes.
- Use `ctx.auth.requireIdentity()` for data that belongs to a guest or signed-in user. Use `ctx.auth.requireSignedIn()` for account-only operations. `isGuest` and `isSignedIn` are separate checks. Neither is true without a session.
- Set `auth: { requireSignIn: true }` in `capsule()` to block all app data operations until sign-in. Client UI checks alone do not protect data. On the client, gate data components on `canAccessApp()` from `lakebed/client`.
- If `auth.error` blocks access, show `retryAuth()` and Google sign-in. Retry cannot renew an expired or revoked token for a pending guest upgrade. Keep data components unmounted until auth recovers.
- Declare Lakebed user fields with `userId()` from `lakebed/server`, never `string()`. When a guest signs in, declared `userId()` fields follow them to their account. `userId()` does not grant access. Keep owner filters and ownership checks. Make shared data intentional with a shared query, not a fake global user.
- Use `auth.onGuestUpgrade` only for app-specific merge rules. It runs before automatic reference transfer in the same transaction. Plain strings, profile text, and external data do not transfer automatically.
- Add Google sign-in with `SignInWithGoogle` or `signInWithGoogle()` from `lakebed/client`. For custom endpoints, send the identity token from `getIdentity().token` in the `X-Lakebed-Token` header. `Authorization` belongs to the app. Same-origin guest cookies work without that header.
- Read server secrets through `ctx.env`, with values in `.env.lakebed.server`. They are not available at build time. Never put secrets in client or shared code. Deploy sync replaces hosted env with the file contents after the deploy is claimed.
- Use complete Tailwind class names in JSX. Lakebed compiles CSS automatically from client files and their imports. Use inline styles for values loaded at runtime. Do not add CSS files, CSS modules, PostCSS, or a separate Tailwind build step.
- Use the router from `lakebed/client` for pages. There is no file-based routing. Use `endpoint({ method, path }, handler)` from `lakebed/server` for webhooks and external HTTP clients. Request helpers include `headers.get(name)`, `query`, `json()`, `text()`, and `bytes()`.
- Static capsule assets are limited to the favicon. Use `favicon.svg`, `favicon.ico`, or the `favicon` option in `capsule()`. Use `client.storage` for user uploads.
## External data and dashboards
Use global `fetch(url, options)` inside a handler, not `ctx.fetch`. Queries, mutations, actions, and endpoints can fetch locally and on claimed deploys. A mutation or writable endpoint can fetch external data and write rows in the same call. Data does not need to pass through the browser. Fetch shares the handler time budget and can hold up other writes, so ingest one small batch per call.
Lakebed has no built-in scheduler or durable continuation queue yet. For periodic ingest, use an external scheduler to call a protected `POST` endpoint. Return a cursor for the caller to advance across separate requests. Keep `auth.requireSignIn` off for public reads and check an app secret in the ingest endpoint. CLI deploy tokens do not authenticate app endpoint callers.
Database read budgets apply to the whole handler. A loop over `paginate()` does not bypass them. For totals larger than one handler can read, maintain summary rows during ingest. Store timestamps with `number()` as epoch milliseconds. See the [handler capability table](https://docs.lakebed.dev/capsule-api/index.md#handler-capabilities), [dashboard ingest example](https://docs.lakebed.dev/database/index.md#dashboard-counts), and [resource limits](https://docs.lakebed.dev/limits/index.md) before planning a backfill.
## Run and verify
Run commands from this capsule directory with `npx lakebed`.
Start dev in a terminal session that can stay open:
```sh
npx lakebed dev
```
Keep that process running. Edit the starter to build the requested app, then test its behavior at the URL printed by dev. Use another terminal to inspect logs and data:
```sh
npx lakebed logs --port 3000
npx lakebed db dump --port 3000
```
Use the dev server's port if it differs from 3000. Fix compile errors and runtime errors before deploying. Check user-owned data with separate browser profiles or the `?lakebed_guest=<name>` local test override when the app stores private data. Named overrides are local test identities and cannot upgrade to an account.
## Deploy and verify
After local checks pass, deploy from another terminal:
```sh
npx lakebed deploy
```
If the CLI requires a claim for server secrets or outbound fetch, follow its claim instructions and deploy again. A claim-required preview is not a working app.
Open the returned URL and test the requested behavior. Inspect the deployed app from this capsule directory, using its returned ID or URL:
```sh
npx lakebed inspect <deploy-id-or-url>
npx lakebed logs <deploy-id-or-url>
```
Hosted inspection is private by default. The CLI uses saved credentials. Report the working URL, the checks you ran, and the expiry if the deploy is unclaimed. Default app URLs use `lakebed.app` subdomains.
## Read when needed
- For server and client API details, read the [capsule API](https://docs.lakebed.dev/capsule-api/index.md).
- For indexes and queries, read the [database guide](https://docs.lakebed.dev/database/index.md).
- For Google sign-in and identity, read the [auth guide](https://docs.lakebed.dev/auth/index.md).
- For user uploads, read the [storage guide](https://docs.lakebed.dev/storage/index.md).
- For claiming, domains, and other CLI commands, read the [reference](https://docs.lakebed.dev/reference/index.md).
- For an older capsule using synchronous database calls, read the [migration guide](https://docs.lakebed.dev/database-migration/index.md).
- For anything else, read the [docs index](https://docs.lakebed.dev/llms.txt). It lists every page and section so you can fetch only the one you need.
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
-58
View File
@@ -1,58 +0,0 @@
# compat-db: Frame Control's compatibility database
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
reports for Android apps on the Steam Frame. For now only the maintainer's
copy of Frame Control has the key to read or write it. Everyone else's reports
stay on their own Mac (see `shared()` in `ui/frame_compat_db.py`).
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
claimed, so it doesn't expire). The browser page only says it's private.
- Access: `GET /v1/reports?since=<createdAt>` and `POST /v1/reports` with
`{"reports": [...]}`. Both need the `x-frame-control-key` header. There are
no Lakebed queries or mutations, so nothing else can reach the rows.
- Key: `FRAME_CONTROL_KEY` in `.env.lakebed.server` (git-ignored, synced on
deploy) and in the Mac's login Keychain (service `frame-control-compat-db`,
account `app-key`), where `ui/frame_compat_db.py` reads it.
- Duplicates: each report carries a `clientId`, and a report already stored is
skipped, so retries and restores are safe to repeat.
- Free-plan limits: 1 MiB of data and 16,384 rows per deploy, 1,000 writes a
day. A report is about 300 bytes, so roughly 3,000 reports fit.
## Backups
`scripts/compat-db-backup.sh` exports every report through the app key and
keeps dated copies in
`~/Library/Application Support/Frame Control/compat-db/backups` (newest 60).
When the data has changed, it also uploads them with `gog` to the Google
Drive folder named by `DRIVE_FOLDER_ID` (set it in the LaunchAgent's
`EnvironmentVariables`). A LaunchAgent runs it daily at 03:40 and logs to
`~/Library/Logs/frame-compat-backup.log`. If an export has fewer reports than
the last good backup (`backups/.last-good`), it's kept as `refused-*.json`,
nothing is uploaded, and every later run refuses too until you rerun with
`--accept-shrink`.
Reports that can't be sent (unreadable outbox lines, or ones the server
rejects, which it lists by `clientId`) are never dropped: they move to
`~/Library/Application Support/Frame Control/compat-db/compat-outbox.jsonl.rejected`,
with the reason.
Restore (to this deploy or a new one):
```sh
python3 ui/frame_compat_db.py import BACKUP.json # duplicates are skipped
python3 ui/frame_compat_db.py count
```
`npx lakebed db export dep_dDmcsosVSiFirpW6 --out full.json` is a second,
owner-only export path through the Lakebed CLI.
## Change and deploy
```sh
cd compat-db
npx lakebed dev --port 3917 # local; data resets on restart
npx lakebed deploy # updates frame-compat.lakebed.app
```
To rotate the key: generate a new one, update the Keychain item and
`.env.lakebed.server`, then deploy.
-9
View File
@@ -1,9 +0,0 @@
// No browser access to the data: Frame Control reads and writes it through the
// key-protected /v1 endpoints only.
export function App() {
return (
<main className="min-h-screen grid place-items-center bg-slate-900 text-slate-300 p-8">
<p>Frame compatibility database. Private: only Frame Control can use it.</p>
</main>
);
}
-11
View File
@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="lakebed-favicon-gradient" x1="12" y1="8" x2="52" y2="56" gradientUnits="userSpaceOnUse">
<stop stop-color="hsl(157 84% 58%)" />
<stop offset="1" stop-color="hsl(193 82% 44%)" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#lakebed-favicon-gradient)" />
<circle cx="48" cy="16" r="18" fill="#fff" opacity=".16" />
<text x="32" y="39" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="32" font-weight="800" fill="#fff">C</text>
</svg>

Before

Width:  |  Height:  |  Size: 658 B

-3
View File
@@ -1,3 +0,0 @@
{
"deployId": "dep_dDmcsosVSiFirpW6"
}
-101
View File
@@ -1,101 +0,0 @@
import { capsule, endpoint, json, string, table, text } from "lakebed/server";
// Compatibility reports for Android apps on the Steam Frame, written and read
// only by Frame Control. There are no queries or mutations, so browsers and
// Lakebed clients can't reach the data; the two endpoints require the app key
// (FRAME_CONTROL_KEY in .env.lakebed.server, kept in the Mac's Keychain).
const RESULTS = ["runs", "crashes", "install_failed", "instance_failed"];
const RATINGS = ["works", "issues", "broken"];
const PAGE = 500;
type Incoming = Record<string, unknown>;
function field(r: Incoming, key: string, max = 200): string | undefined {
const v = r[key];
if (v === undefined || v === null || v === "") return undefined;
return String(v).slice(0, max);
}
function authorised(ctx: { env: Record<string, string | undefined> }, key: string | null): boolean {
const expected = ctx.env.FRAME_CONTROL_KEY;
if (!expected || !key) return false;
// Compare every position of the longer string so timing doesn't reveal the key length.
const n = Math.max(key.length, expected.length);
let diff = key.length ^ expected.length;
for (let i = 0; i < n; i++) diff |= (key.charCodeAt(i) || 0) ^ (expected.charCodeAt(i) || 0);
return diff === 0;
}
export default capsule({
name: "frame-compat",
auth: { requireSignIn: false },
schema: {
reports: table({
package: string(),
version: string().optional(),
result: string().optional(),
rating: string().optional(),
notes: string().optional(),
via: string().optional(),
reportedAt: string(),
steamos: string().optional(),
lepton: string().optional(),
runtime: string().optional(),
label: string().optional(),
source: string().optional(),
clientId: string()
}).index("by_package", ["package"]).index("by_client", ["clientId"])
},
endpoints: {
// GET /v1/reports?since=<createdAt> -> { reports: [...], next: <createdAt> | null }
// Pass `next` back as `since` until it's null; rows at the boundary repeat, so dedupe by id.
list: endpoint({ method: "GET", path: "/v1/reports" }, async (ctx, req) => {
if (!authorised(ctx, req.headers.get("x-frame-control-key"))) return text("unauthorized", { status: 401 });
const since = req.query.get("since") ?? "";
const rows = await ctx.db.reports
.withIndex("by_creation", (q) => q.gte("createdAt", since))
.take(PAGE);
return json({ reports: rows, next: rows.length === PAGE ? rows[rows.length - 1].createdAt : null });
}),
// POST /v1/reports body: { reports: [ {...}, ... ] } (max 100 per call)
// clientId makes retries idempotent: a report already stored is skipped.
// Invalid reports are listed in `rejected` (by clientId) so the app can keep them.
add: endpoint({ method: "POST", path: "/v1/reports" }, async (ctx, req) => {
if (!authorised(ctx, req.headers.get("x-frame-control-key"))) return text("unauthorized", { status: 401 });
const body = await req.json<{ reports?: Incoming[] }>();
const incoming = Array.isArray(body?.reports) ? body.reports.slice(0, 100) : [];
let inserted = 0;
const rejected: string[] = [];
for (const r of incoming) {
const pkg = field(r, "package");
const clientId = field(r, "clientId", 80);
const reportedAt = field(r, "date", 40);
const result = field(r, "result");
const rating = field(r, "rating");
if (!pkg || !clientId || !reportedAt || (result && !RESULTS.includes(result)) ||
(rating && !RATINGS.includes(rating))) {
if (clientId) rejected.push(clientId);
continue;
}
const dup = await ctx.db.reports.withIndex("by_client", (q) => q.eq("clientId", clientId)).first();
if (dup) continue;
await ctx.db.reports.insert({
package: pkg, version: field(r, "version", 80), result, rating,
notes: field(r, "notes", 1000), via: field(r, "via", 20), reportedAt,
steamos: field(r, "steamos", 40), lepton: field(r, "lepton", 40),
runtime: field(r, "runtime", 20), label: field(r, "label", 120),
source: field(r, "source", 300), clientId
});
inserted++;
}
return json({ inserted, rejected, received: incoming.length });
}),
status: endpoint({ method: "GET", path: "/v1/status" }, () => text("ok"))
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

-300
View File
@@ -1,300 +0,0 @@
# Installing APKs (Lepton)
The confidence labels are the same as in [ssh.md](ssh.md). Android apps run
in **Lepton**, Valve's Waydroid-based container. Lepton is built for games,
not general Android use
([GamingOnLinux](https://www.gamingonlinux.com/2026/09/lepton-from-valve-to-run-android-games-on-linux-is-now-open-source/)).
## Install from the Mac: one app, one Lepton instance (verified 2026-09-25)
Use Frame Control's **Android apps** section (search, Install, Test, Report), drop
an `.apk` on **Send to Frame**, or:
```sh
./scripts/install-apk.sh some-app.apk # own instance, Steam shortcut
python3 ui/frame_android.py list|launch|stop|remove|probe <package>
```
Each APK becomes its own app, the way T3 Code is set up (see the instance
section below), instead of going into Lepton Development:
1. `ui/frame_apk.py` reads the package, label, version, ABIs and icon
(a stdlib parser of the binary manifest and resource table, so no Android SDK). APKs that need
API > 30 or have no `arm64-v8a` build are refused.
2. The APK, `frame/android/lepton-app.sh` (as `launch.sh`), `instance.id`,
`meta.json`, the icon and the `lepton-show-flatscreen` marker go to
`~/Applications/Android/<package>/` on the Frame.
3. A non-Steam shortcut is added through Steam's CEF debug port
(`frame/android/steam_shortcuts.py`), with no Steam restart.
4. Launching the shortcut runs Lepton directly with `SteamAppId` set to the
instance id (`2800000000 + crc32(package) % 70000000`). That's a
"steamlaunch" context, so app data in `compatdata/<id>/internal` survives
restarts and updates, and each app gets its own SteamVR panel. Several can
run at once alongside Lepton Development, each in its own container
(`lepton-steamlaunch-<id>`, ADB on 5556, 5557, …).
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
from the shortcut, stopped, and relaunched with their data intact. ADB and
Lepton Development aren't involved.
`--dev` keeps the old path: ADB into Lepton Development over an SSH tunnel
(first free Mac port from 15555), which starts Lepton Development if needed.
Apps installed that way are deleted when it exits (see below). No pairing or
"Allow debugging?" prompt is needed for either path.
Lepton Development must be installed once. Over SSH,
`ssh frame 'steam steam://install/3056000'` queues it, but the install still
needs to be confirmed or started in the headset.
## Installed apps disappear when Lepton Development closes (verified 2026-09-25)
Lepton Development runs in a throwaway "dev" context. When it exits for any
reason (you close it, or it crashes), the launcher script
`~/.local/share/Steam/steamapps/common/Lepton/lepton` calls
`clear_baked_app_data "non steamlaunch container"` and **deletes every app
installed over ADB**. The journal shows `Clearing baked app data due to non
steamlaunch container`, and `pm list packages -3` is empty afterwards.
The script skips the wipe when `LEPTON_NO_CLEANUP` is set
(`liblepton/liblepton.sh`, `clear_baked_app_data`). To keep your apps, set
Lepton Development's Steam launch options to:
```
LEPTON_NO_CLEANUP=1 %command%
```
(Steam → Library → Lepton Development → Properties → Launch Options.) Inferred
from the script, not yet tested across a restart.
## Which APKs work (verified 2026-09-25, SteamOS build 20260922.6101926)
Lepton is LineageOS 18.1 (`lepton_arm64_only`): Android 11, API 30,
`abilist=arm64-v8a` only, Mesa (Turnip, Adreno 750) with GLES 3.2 and
Vulkan 1.4. About 30 F-Droid apps were installed and opened on the Frame to
check each rule. The results are in the compatibility database (see compat-db/README.md).
**Won't install** (the installer refuses):
| Rule | Seen on device |
|---|---|
| `minSdkVersion` > 30 | `INSTALL_FAILED_OLDER_SDK: Requires newer sdk version #33 (current version is #30)` |
| Native code without `arm64-v8a` (32-bit ARM or x86 only) | `INSTALL_FAILED_NO_MATCHING_ABIS` |
**Crash on launch.** Lepton has no `clipboard` system service, so
`getSystemService(CLIPBOARD_SERVICE)` returns null:
| What | Result |
|---|---|
| **Jetpack Compose UI < 1.11** | Crashes as soon as a Compose screen appears: `null cannot be cast to non-null type android.content.ClipboardManager` in `AndroidComposeView`. Seen with 1.5, 1.6, 1.7, 1.8 and 1.10 apps. |
| Jetpack Compose UI 1.11, 1.12, 1.13 | **Works.** Six apps opened fine, including Aurora Store and NewPipe. |
| Old Compose, but the first screen uses classic Views | Opens (FoCal, Compose 1.3), and crashes only on Compose screens |
| SDL2 apps, including Kivy | Crash: SDL calls `ClipboardManager.addPrimaryClipChangedListener` at start-up |
| Godot 4.3 | Crashes (clipboard cast). Godot 4.6.1 works. |
**Works:** classic Android Views apps, Flutter (2 of 2), libGDX (2 of 2),
Compose 1.11+, Firebase-using apps. React Native: 2 of 3 opened; one
(controlloid) died with SIGSEGV on the Hermes JS thread. A Qt 6 app
(AusweisApp) failed on a missing libc++ symbol.
**Missing pieces:** an app may open but fail when you use one of these:
- No Google Play Services.
- No activity for `VIEW` of web links, `OPEN_DOCUMENT`/`GET_CONTENT` (no file
picker), `IMAGE_CAPTURE`, or text-to-speech. WebView (Chromium 152) is there.
- No Downloads or Contacts providers.
- Missing system services also include `accessibility`, `vibrator`, `phone`,
`print`, `usb`, `nfc` and `autofill`. The declared features lack
`touchscreen.multitouch`, `bluetooth_le` and `telephony`.
- No on-screen keyboard (IME) is installed. How text entry reaches Android
apps in the headset hasn't been checked.
**Lepton itself can crash.** Three times during testing, the graphics HAL
(`android.hardware.graphics.composer@2.1-service`) aborted right after an app
crashed. SurfaceFlinger died, the whole `lepton-dev` container exited, and the
installed apps were wiped (see above). A retry of the same app worked, so it's
intermittent rather than app-specific.
**How good are the predictions?** In a random sample of 12 apps rated "Should
work", all 12 installed, opened and were still running 12 s later (two needed
a retry because Lepton crashed mid-install). "Opened" isn't the same as fully
working: see the missing pieces above.
## Catalogue and compatibility reports
Frame Control's **Android apps** section lists every F-Droid app with a
verdict: Works on Frame, Should work, Might work, Probably crashes, or Won't
work, with the reasons. As of 2026-09-25 that's 30 working, 3,323 should work,
223 might work, 723 probably crash (mostly Compose < 1.11) and 156 won't
install. Each app is rated on its newest version that Lepton can install,
because F-Droid often publishes separate per-ABI builds and the newest is
frequently x86_64. **Install** downloads the APK (SHA-256 checked against the
F-Droid index) and sets it up as its own instance.
No ProtonDB-style database for sideloaded Android apps on the Frame existed
as of 2026-09-25. [Steam Frame Hub](https://verified.steamframehub.com/)
collects community reports for Steam games only and has no public API, and
Valve's "Great on Frame" badges and each Steam app's `recommended_runtime`
(for example `lepton-stable`) also cover Steam games only
([VR.org](https://vr.org/articles/steam-frame-lepton-android-runtime-52-of-130-certified-2026)).
So Frame Control keeps its own. Your reports are saved on your Mac; the
maintainer's copy also syncs them to a private Lakebed database.
**Test** records whether the app stays up in its own instance, and **Report**
(for any APK, F-Droid or not) records whether it worked, how it was run, where it came from, and notes, each with the SteamOS and Lepton build ids.
See
[compat-db/README.md](../compat-db/README.md) and
[apk-catalog/README.md](../apk-catalog/README.md).
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
newest archived build without Compose, runs, loads the full catalogue (the
first repo update takes about 90s), and can install apps. F-Droid 2.0 uses
Compose 1.12, so it should work, but it hasn't been tried. The catalogue's
Install button for F-Droid installs 1.17.2. To let F-Droid install apps without
a settings prompt, with the tunnel open:
```sh
adb -s $S shell appops set org.fdroid.fdroid REQUEST_INSTALL_PACKAGES allow
```
## Handy commands
Open a tunnel by hand (use any free local port):
```sh
ssh -f -N -M -S /tmp/frame-adb.sock -L 127.0.0.1:15555:127.0.0.1:5555 frame
adb connect 127.0.0.1:15555
S=127.0.0.1:15555
# when done: adb disconnect $S; ssh -S /tmp/frame-adb.sock -O exit frame
```
Then:
```sh
adb -s $S shell pm list packages -3 # installed third-party apps
adb -s $S shell monkey -p <pkg> -c android.intent.category.LAUNCHER 1
# If monkey exits with -5 (it did for T3 Code), start the activity directly:
adb -s $S shell am start -W -n "$(adb -s $S shell cmd package resolve-activity --brief -c android.intent.category.LAUNCHER <pkg> | tail -n 1)"
adb -s $S logcat -d -b crash # why an app died
adb -s $S uninstall <pkg>
adb -s $S exec-out screencap -p > shot.png # the Lepton window
```
## Reaching a Mac service from Lepton (T3 Code v2, verified 2026-09-25)
Lepton runs in podman with `pasta` networking. It has **its own loopback**, so
a port on the Frame's `127.0.0.1` isn't visible as `127.0.0.1` inside Android.
But pasta runs with `--map-gw`, so the **gateway address inside Lepton
(`192.168.1.1` on the home network) maps to the Frame host's loopback**.
T3 Code v2 on the Mac listens only on `127.0.0.1:3873`. To reach it:
1. Keep `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running on the Mac,
for example from a LaunchAgent with `KeepAlive`, so launchd restarts it if
it drops.
2. In the app on the Frame, the environment host is `192.168.1.1:3873`.
The app on the Frame was built from the T3 Code v2 nightly source with `expo prebuild` and `gradlew assembleRelease
-PreactNativeArchitectures=arm64-v8a`, using Homebrew `openjdk@17` and the
`android-commandlinetools` SDK. It's signed with the debug key.
To pair again, issue a one-time code on the Mac and type it into
**Add environment**:
```sh
A="/Applications/T3 Code (V2 Preview).app"
ELECTRON_RUN_AS_NODE=1 "$A/Contents/MacOS/T3 Code (Alpha)" \
"$A/Contents/Resources/app.asar/apps/server/dist/bin.mjs" \
auth pairing create --base-dir "$HOME/.t3-v2" --ttl 15m --label "Steam Frame"
```
The app is deleted whenever Lepton Development closes (see above), so reinstall
it afterwards or set `LEPTON_NO_CLEANUP=1`. The gateway address comes from the Frame's network when Lepton starts. On a
different network, check it with `adb shell ip route` and edit the host.
## Lepton Development forgets apps; give an app its own instance (verified 2026-09-25)
**Lepton Development wipes every installed app when it exits.** Its launcher
logs `Clearing baked app data due to non steamlaunch container`, unless
`LEPTON_NO_CLEANUP` is set. A Steam-style launch (with `SteamAppId` set) is a
"steamlaunch" context and keeps its data:
- App data lives in `STEAM_COMPAT_DATA_PATH/internal/<package>` (symlinked to
`/data/data/<package>`) and survives everything, including APK updates.
- `STEAM_COMPAT_DATA_PATH/baked` is Lepton's Android snapshot. It's rebuilt when
the APK changes, or when the app exits within 30 seconds of starting.
- `STEAM_COMPAT_DATA_PATH` must be under `~/.local/share/Steam` (use
`steamapps/compatdata/<id>`). Only that tree is mounted in the container. Put
it anywhere else and the symlinks dangle, so the app crashes with
`ENOENT` on its first file write.
- Lepton runs apps headless unless an empty `lepton-show-flatscreen` file sits
next to the APK (`STEAM_COMPAT_INSTALL_PATH`).
- Several instances can run at once. Each gets ADB on `5555 + offset`
(`podman ps --format "{{.Names}} {{.Labels.adb_port}}"`).
- Outside Steam, Lepton's `setpgid --foreground` re-exec fails with no
terminal. Set `IS_PARENT=true` and start it with `setsid --wait`.
[`frame/t3code/launch.sh`](../frame/t3code/launch.sh) does all this for T3
Code (context `steamlaunch-2873873873`; ADB is the first free `5555 + n`, e.g. 5557). It needs the Steam client
running (it mounts `~/.steam/steam.pipe`).
**T3 Code in the Steam library (verified 2026-09-25).** The wrapper lives on
the Frame at `~/Applications/T3Code/launch.sh`, with `t3code.apk` and the
flatscreen marker next to it. It's a non-Steam shortcut called "T3 Code"
(shortcut app id `3130509679`). Launching it from Steam gets its own SteamVR
panel, `valve.steam.desktopgame.3130509679`, and opens already paired.
- The shortcut was added without restarting Steam, through Steam's CEF debug
port (`127.0.0.1:8080` on the Frame, target `SharedJSContext`):
`SteamClient.Apps.AddShortcut(name, exe, "", "")`, then `SetShortcutName`
and `SetShortcutStartDir`. `steam steam://addnonsteamgame/<path>` only logged
the URL and added nothing.
- To launch it over SSH: `steam steam://rungameid/13445436691150012416`, which
is `(3130509679 << 32) | 0x02000000`.
- Steam sets `STEAM_FOSSILIZE_DUMP_PATH` for shortcut launches but not
`STEAM_COMPAT_SHADER_PATH`. Lepton then dies with "unbound variable", so the
wrapper sets both.
- To update T3, replace `t3code.apk`. Lepton rebuilds its snapshot on the next
launch, and the pairing survives.
## Crashing apps can take down the headset session (verified 2026-09-25)
Some apps crash Android's graphics composer HAL, which kills the Lepton
container. On 2026-09-25 a batch crash-test also coincided with `steamvr.service`
restarting "on client request", which stops and SIGKILLs `gamescope-session`.
After one of those kills, gamescope crash-looped about once a second on
`rendervulkan.cpp:2181 ... Assertion '!modifiers.empty()'` because it kept
attaching to the SteamVR processes orphaned from the dead session. The fix
without sudo was to `for p in vrdashboard vrcompositor vrserver; do pkill -TERM -x $p; done` (pkill takes one pattern). The
next session then started SteamVR fresh and recovered within a minute.
## Android display: resolution, UI scale, text size (verified 2026-09-25, SteamOS 0.3.0, build 20260922.6101926)
Each running Lepton instance has its own ADB port on the Frame, assigned at
launch: 5555 is Lepton Development, and own-instance apps take the next free
port (T3 Code was on 5557). Find them with `ss -ltn` (5555–5599) and identify
each with `pm list packages -3`. Both instances reported `Physical size:
1920x1080`. Their densities were 180 dpi (Lepton Development) and 213 dpi
(T3 Code), and `settings get system font_scale` returned `null` (1.0).
These all apply immediately and read back as set. Tested on Lepton Development
only:
```sh
adb -s $S shell wm size 2560x1440 # or: wm size reset
adb -s $S shell wm density 240 # or: wm density reset
adb -s $S shell settings put system font_scale 1.15
adb -s $S shell settings delete system font_scale
```
After a `wm` reset, Android writes `font_scale=1.0` back asynchronously, so a
single delete that follows one reads back `1.0`. A second delete a second later
leaves it `null`. Frame Control's **Android display** card does this for you
(`/api/android/display`).
**Inferred, not yet checked in the headset:** a bigger Android resolution with
density scaled to match (2560×1440 at 4/3 of the density) gives sharper text,
because gamescope scales Lepton's surface to fit the same panel. Also unverified:
whether the settings survive the app or its Lepton instance relaunching.
Lepton Development rebuilds its Android data on exit, so there they probably
don't.
-38
View File
@@ -1,38 +0,0 @@
# File transfer and clipboard
The confidence labels are the same as in [ssh.md](ssh.md). Everything here
depends on SSH working through the `frame` alias from `scripts/connect.sh`.
## Options
| Option | Command | Confidence | Notes |
|---|---|---|---|
| **scp / rsync over SSH** | `./scripts/push.sh file-or-dir [dest]`, or `rsync -a --progress x frame:Downloads/` | **Inferred.** SSH is confirmed. Valve recommends WinSCP (SFTP) for Windows ([debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)), which means SFTP is enabled. | Recommended. The Mac ships `rsync` (newer macOS uses `openrsync`, which supports the flags used here). `rsync` must also exist on the Frame. It's in SteamOS on Deck; if it's missing on the Frame, `push.sh` falls back to `scp`. |
| SFTP GUI | Finder can't do SFTP. Use Cyberduck / Transmit / ForkLift with `sftp://steamos@frame.local` | Inferred | Good for browsing. |
| `adb push` | `adb push x /sdcard/Download/` (Lepton) | Confirmed that ADB exists ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)) | Only reaches the Android container's storage. |
| SteamOS Devkit Client | "Title Upload" | Confirmed (Frame) ([loadgames](https://partner.steamgames.com/doc/steamhardware/steamframe/loadgames)) | For deploying apps and games, not general files. macOS support for the Devkit Client wasn't confirmed. |
| Syncthing | A Syncthing Flatpak on the Frame (`./scripts/install-apps.sh <flathub-app-id>`), app on the Mac | Guess (which Syncthing Flatpak, and whether it has an aarch64 build, not checked) | Good for an ongoing shared folder. |
| KDE Connect | KDE Connect on both | Guess | There's a macOS build of KDE Connect, but whether it's present or installable on the Frame wasn't confirmed. It would give you clipboard sync, file send, and remote input. Worth checking on-device. |
| microSD | Physical card | Confirmed that the slot exists ([Wikipedia](https://en.wikipedia.org/wiki/Steam_Frame)) | Offline fallback. |
## Clipboard
`scripts/paste-to-frame.sh` sends the Mac clipboard (or stdin) to the
headset's desktop clipboard. You can then paste in the headset with the
virtual keyboard's paste key or a right-click → Paste.
```sh
./scripts/paste-to-frame.sh # sends pbpaste
echo "https://example.com" | ./scripts/paste-to-frame.sh -
```
How it works (verified 2026-09-25). The headset's desktop is a Plasma Wayland
session nested inside gamescope, with its own runtime dir
(`/run/user/1000/nested_plasma`) and its own D-Bus bus. `wl-copy` and `xclip`
aren't installed. The script reads the bus address from `plasmashell`'s
environment and calls Klipper's `setClipboardContents` with `qdbus6`. The
desktop has to be running in the headset. It's text only, and pastes over about
100 KB hit the argument limit, so send big things with `push.sh`.
A simpler fallback: `ssh frame 'cat > ~/clip.txt'` < file, then open it in the
headset.
-143
View File
@@ -1,143 +0,0 @@
# Frame Control in detail
What each part of the app does, how it works, and what has been checked on a
real Frame. For installing it, see the [README](../README.md#install).
As of 2026-09-25 no other desktop app manages the Frame end to end.
[Stream Frame](https://streamframe.app/) (macOS 14+, free) records and screenshots
the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is
Windows-only. Steam Link views the headset.
You can also run the same UI in a browser without the app, from a checkout:
```sh
./scripts/frame-ui.sh # macOS: opens http://127.0.0.1:47810 in its own window
python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
```
## Features
- **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**
is 720p video at about 30 fps: `ffmpeg` on the Frame encodes SteamVR's
headset-view device (`/dev/video99`) to H.264 over SSH, and the page decodes
it with WebCodecs. Live video is one eye; Capture still gets both. The viewer
fits the whole frame; zoom with − / + (or scroll, or double-click), drag to
pan, `0` to fit, `F` for full screen. Capture uses OpenVR's `IVRScreenshots`
API through Python `ctypes` (`ui/frame_vrshot.py`). Nothing extra is
installed on the Frame (SteamOS ships `ffmpeg`). **Desktop panel** captures
gamescope's flat layer instead.
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
save them to `~/Pictures/SteamFrame`.
- **Battery** with charging state: charge rate in watts, time to full or empty,
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
the desktop, Lepton and xrdp are running.
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
- **Get games**: every game you own with its Steam Frame rating (Verified,
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
with live progress. Search the Steam store with prices and Frame ratings; Buy
opens the store page in your browser, or Store on Frame opens it in the
headset. It drives the Frame's own Steam client through its DevTools port;
see [steam-games.md](steam-games.md).
- **Volume** and mute (`wpctl`).
- **Android apps**: search about 4,500 F-Droid apps rated for the Frame, install
one with a click as its own Lepton instance (it keeps its data and shows in the
Steam library), then launch, stop, test or remove it. **Report an APK** records
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
installed app). Your reports are saved on your computer and change the verdicts
you see. They aren't uploaded anywhere: the shared database is maintainer-only
for now (see [compat-db/README.md](../compat-db/README.md)). Uses the app's bundled
`adb`, or yours if you have one.
- **Android display**: pick a running Lepton instance (by the app in it) and set
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
(0.85–1.3×) over ADB (`wm size`, `wm density`, `font_scale`). Reset puts all
three back. Whether the settings survive the app relaunching is untested.
- **Transfer**: drag and drop files to `~/Downloads`; `.apk` files install as
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
the Steam library (Valve's Devkit Game path, with Proton or the Steam Linux
Runtime picked from the program's header), listed under **Sideloaded titles**
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
Frame clipboard.
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
Remmina).
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
Linux). Sleep, restart and shut down open a terminal window because SteamOS
asks for the sudo password over SSH.
## How it works
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
and shows it in its own window; the server stops when you quit the app. The
app bundles `ui/`, `scripts/`, `frame/android/`, Valve's `frame/devkit-utils/` and the rated catalogue from
`apk-catalog/`, plus a standalone Python
([python-build-standalone](https://github.com/astral-sh/python-build-standalone))
and `adb` from Google's platform-tools, so there's nothing else to install. It
also bundles curl's copy of Mozilla's CA list, because Python on Windows only
trusts root certificates already in the Windows store.
`app/build/fetch-deps.js` downloads both, pinned by SHA-256.
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
with a non-local `Host` header, and any `/api/` request without a custom
header, so other websites can't drive it or read captures. Everything reaches
the headset through the `frame` SSH alias. On macOS and Linux it keeps one
multiplexed SSH connection open, so status and each capture take about 0.3 s.
Windows' OpenSSH can't share a connection, so there each request connects on
its own and the app is a little slower. What differs between the three
systems lives in `ui/frame_host.py`.
Headset captures are deleted from the Frame as soon as they're copied, because
they show everything on screen, including anything private. The look follows
the Steam client: its palette, Motiva Sans (loaded from Valve's CDN), portrait
library capsules and green Play buttons.
**Verified on the Frame 2026-09-25 (macOS app):** status and charging details,
both capture modes (headset view while in use, and a blank frame in standby,
which the UI labels), clipboard, volume, file push, and input validation.
**Not yet exercised from the UI:** Launch, Flatpak install/remove, APK drop,
title sideloading (not yet run on a headset at all), and the power buttons. Each of these calls a command that was verified
separately.
## Per-platform notes
**macOS.** The app reads `PATH` from your login shell, so Homebrew's `rsync`
and `adb` are used when you launch it from Finder. Set Up Connection runs
`scripts/connect.sh` in Terminal. The log is at
`~/Library/Logs/Frame Control/server.log`. The build is ad-hoc signed and not
notarized: a downloaded copy is quarantined until you run
`xattr -dr com.apple.quarantine "/Applications/Frame Control.app"`. The first
time you use them, macOS asks to allow local network access (for SSH) and
control of Terminal (for SSH and power actions).
**Windows.** `ssh` is Windows' built-in OpenSSH client
(Settings → System → Optional features, if it's been removed). Set Up
Connection runs `ui/frame_connect.py` in a console window. Copies use `scp`
because Windows has no `rsync`. The installer isn't code-signed, so SmartScreen
warns on first run: choose **More info → Run anyway**. The log is at
`%APPDATA%\Frame Control\logs\server.log`.
**Linux.** Needs `ssh`, which most desktops have; the `.deb` pulls it in.
The arm64 build also needs your distribution's `adb` for Android apps, because
Google publishes no arm64 Linux platform-tools. Set Up Connection runs
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
and others). The log is at
`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
`xclip` (X11).
## Building
```sh
cd app
npm install
npm start # run from the checkout without packaging
npm run dist # macOS: dist/*.dmg and .zip (Apple Silicon)
npm run dist:win # Windows: installer and .zip
npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
```
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to the
release (`.github/workflows/release.yml`).
-82
View File
@@ -1,82 +0,0 @@
# How the Frame is put together (field notes)
What we learnt by poking at a real Frame over SSH. Unless a line says
otherwise, it was **verified 2026-09-25** on SteamOS 0.3.0 (`VARIANT_ID=vr`,
build 20260922.6101926, kernel 6.18, aarch64). Topic docs go deeper. This page
is the map.
## The layer cake
```
SteamVR (vrserver, vrcompositor, vrdashboard) ← renders the room + panels
└─ gamescope --backend openvr ← one SteamVR overlay per app id
├─ Xwayland :0 (Steam UI, games, tagged apps) ← STEAM_GAME property = app id
├─ Xwayland :1 (STEAM_GAME_DISPLAY_0)
├─ Wayland socket gamescope-0
└─ steamos-nested-desktop ← "the Linux desktop" panel
└─ dbus-run-session startplasma-wayland
└─ kwin_wayland 1280×800, Wayland wayland-0, Xwayland :2
└─ plasmashell, Konsole, Dolphin, Flatpaks you open there
Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 3056000
```
## Facts worth knowing
| Fact | Where it matters |
|---|---|
| The desktop is a **nested** Plasma session: runtime dir `/run/user/1000/nested_plasma`, its own D-Bus bus, `WAYLAND_DISPLAY=wayland-0`, `DISPLAY=:2`. A plain `ssh frame app` can't find it. Copy the env from `plasmashell`'s `/proc/<pid>/environ`. | `run-on-frame.sh`, `paste-to-frame.sh` |
| The desktop size is hard-coded to 1280×800 in `/usr/bin/steamos-nested-desktop` (read-only rootfs). | [panels.md](panels.md) |
| gamescope runs with `--virtual-connector-strategy PerAppId`. Each app id becomes a SteamVR overlay `valve.steam.desktopgame.<id>`, which is a panel you can float. Setting `STEAM_GAME` on an X11 window on `:0` makes a new panel. | `panel-on-frame.sh`, [panels.md](panels.md) |
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
| SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) |
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
| **Testing VR apps without wearing the headset.** In standby SteamVR keeps OpenXR sessions hidden, so they render one frame and stop. `vrcmd` (in `/opt/steamvr/bin/linuxarm64`) settings use `section.key`: `vrcmd --set-settings-bool power.pauseCompositorOnStandby 0` and `vrcmd --set-settings-float power.turnOffScreensTimeout 3600`, then `vrcmd --handlewakeup`, keep the compositor running, and the scene app becomes visible. If it stays `visible-blurred`, the Steam dashboard is open: `SteamClient.OpenVR.VROverlay.HideDashboard()` in Steam's `SharedJSContext` (CDP on 8080) closes it. The headset view then captures with `ui/frame_vrshot.py`. Restore afterwards with `--set-settings-bool power.pauseCompositorOnStandby 1` and `--set-settings-float power.turnOffScreensTimeout 5`. The bool setter reads `true` as false, so use 1/0. A Steam launch that stalls in standby at `ShowInterstitials` or `CreatingProcess` (see `console_log.txt`) continues with `SteamClient.Apps.ContinueGameAction(<action id>, "<appid>", "<task>")`. **Verified 2026-09-27.** | Proving VR output remotely, [webxr-chromium.md](webxr-chromium.md) |
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale` (installable in `~`, see below), `krfb`, `wayvnc`. | Script design |
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` |
| `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things |
| Clipboard: Klipper over the nested D-Bus bus (`qdbus6 org.kde.klipper …`). | `paste-to-frame.sh` |
| Lepton listens for ADB on the Frame's loopback `5555`, so tunnel it over SSH. It's Android 11 (API 30), 64-bit ARM only, with no `clipboard` service: Compose < 1.11, SDL/Kivy and Godot 4.3 apps crash on launch. | [apks.md](apks.md), `apk-catalog/` |
| Lepton Development deletes every ADB-installed app when it exits (`clear_baked_app_data "non steamlaunch container"` in `…/common/Lepton/lepton`) unless `LEPTON_NO_CLEANUP` is set. | [apks.md](apks.md) |
| Any APK can run as its own Lepton instance: run `…/common/Lepton/lepton waitforexitandrun -- app.apk` with `SteamAppId` set and `STEAM_COMPAT_DATA_PATH` under `~/.local/share/Steam`. Data persists and each gets its own container and panel. `frame/android/lepton-app.sh`, `ui/frame_android.py`. | [apks.md](apks.md) |
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
| 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 |
| **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
```sh
# Which panels (app ids) exist right now?
ssh frame 'DISPLAY=:0 xprop -root GAMESCOPE_FOCUSABLE_APPS GAMESCOPE_FOCUSED_APP'
# Watch panels being created
ssh frame 'journalctl --user -f | grep --line-buffered "\[Overlays\]"'
# gamescope's full flags (in case Valve changes them)
ssh frame 'tr "\0" " " < /proc/$(pgrep -x gamescope | head -n 1)/cmdline'
# Everything the SteamVR dashboard can say (find hidden features)
ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashboard_english.json'
```
## Where the rest lives
- Access and SSH: [ssh.md](ssh.md)
- Seeing the Frame from the Mac, and the Mac from the Frame: [streaming.md](streaming.md)
- Files and clipboard: [file-transfer.md](file-transfer.md)
- Android apps: [apks.md](apks.md)
- 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)
- What's still unverified: [open-questions.md](open-questions.md)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

-63
View File
@@ -1,63 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Install with Frame Control</title>
<!-- Landing page for install links (docs/web-install.md): install.html?manifest=URL
or ?url=URL opens frame-control://install?… and offers the download if the
app doesn't open. Static, no requests of its own. Not published yet. -->
<style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0d1117; color: #e6edf3;
font: 15px/1.5 -apple-system, "Segoe UI", sans-serif; }
main { max-width: 520px; padding: 32px; }
h1 { font-size: 20px; margin: 0 0 8px; }
p { color: #8b98a8; }
code { color: #e6edf3; overflow-wrap: anywhere; }
a.btn { display: inline-block; margin: 8px 12px 0 0; padding: 9px 16px; border-radius: 3px; text-decoration: none;
background: #2d333b; color: #e6edf3; }
a.btn.go { background: #1a9fff; color: #fff; font-weight: 600; }
.err { color: #ff7b72; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main>
<h1>Install with Frame Control</h1>
<p id="what"></p>
<p id="bad" class="err" hidden>This link doesn't name an https:// manifest or file, so there's nothing to install.</p>
<div id="actions" hidden>
<a class="btn go" id="open">Open in Frame Control</a>
<a class="btn" href="https://github.com/saphid/steam-frame/releases/latest">Get Frame Control</a>
</div>
<p id="missing" hidden>Nothing happened? Frame Control isn't installed on this computer, or is older than the
version that handles install links. Get it, open it once, then use the link again.</p>
</main>
<script>
(() => {
const q = new URLSearchParams(location.search);
const kind = q.has("manifest") ? "manifest" : q.has("url") ? "url" : null;
const target = kind && q.get(kind);
let ok = false;
try {
const u = new URL(target);
const local = ["localhost", "127.0.0.1"].includes(u.hostname);
ok = !u.username && !u.password && (u.protocol === "https:" || (u.protocol === "http:" && local));
} catch {}
if (!ok) { document.getElementById("bad").hidden = false; return; }
const link = `frame-control://install?${kind}=${encodeURIComponent(target)}`;
document.getElementById("what").textContent = `From ${new URL(target).hostname}. Frame Control shows what it will `
+ "install and asks you before downloading anything.";
document.getElementById("open").href = link;
document.getElementById("actions").hidden = false;
// If the app opens, this page loses focus or is hidden; if not, say how to get it.
let left = false;
window.addEventListener("blur", () => { left = true; });
document.addEventListener("visibilitychange", () => { if (document.hidden) left = true; });
setTimeout(() => { if (!left) document.getElementById("missing").hidden = false; }, 2000);
location.href = link;
})();
</script>
</body>
</html>
-115
View File
@@ -1,115 +0,0 @@
# Open questions and on-device checks
Research as of 2026-09-25, eight days after the Frame's retail release
(2026-09-18). Most first-party detail comes from Valve's Steamworks developer
pages. Searches of Reddit and the Steam forums turned up **almost no
end-user reports** about SSH, desktop streaming, or macOS. Treat that as
"not documented yet", not "doesn't work".
## Verified on device (2026-09-25)
Checked over SSH from the Mac, read-only, on SteamOS 0.3.0 (`VARIANT_ID=vr`,
build 20260922.6101926, kernel 6.18, aarch64):
- **1–2.** Developer Mode + Set User Password gave working SSH with no terminal
steps. `sshd` is enabled and active. The user is `steamos` (in `wheel`) and
the hostname is `frame`.
- **3.** `frame.local` resolves from the Mac; `avahi-daemon` is active.
- **5.** `/etc/ssh/sshd_config` has `Include /etc/ssh/sshd_config.d/*.conf`.
The existing drop-ins are `20-systemd-userdb.conf` and `99-archlinux.conf`, so
`01-frame-keys-only.conf` would sort first as intended. (`--harden` itself
hasn't been run.)
- **8.** The in-headset desktop is `kwin_wayland` + `plasmashell` nested
inside gamescope (1280×800), with `XDG_RUNTIME_DIR=/run/user/1000/nested_plasma`,
`WAYLAND_DISPLAY=wayland-0`, `DISPLAY=:2` and a private D-Bus bus. SteamVR
(`vrserver`, `vrcompositor`) and `xrdp` are running.
- **9.** `rsync`, `flatpak`, `python3`, `git`, `qdbus6` and `xrdp` are present.
`wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb` and `wayvnc`
are **not** (Tailscale can be added in `~`; see [tailscale.md](tailscale.md)). `paste-to-frame.sh` now uses Klipper over D-Bus and round-trips
text correctly.
- Flathub is already configured as a **system** remote; Chromium is the only
installed Flatpak. `/` is 10 GB (42% used); `/home` is 929 GB.
- `push.sh` copied a test file with rsync.
- **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).
- **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.
## Check on the headset (in order)
1. **Is Developer Mode available on a retail unit?** Valve's pages are aimed at
developers. Confirm that **Steam Settings → System → Enable Developer Mode**
and **Developer → Set User Password** both exist on your OS channel (Stable
vs Beta).
2. **Does SSH work straight after that, with no terminal steps?** From the Mac,
run `nc -z frame.local 22`, then `./scripts/connect.sh`.
3. **Does `frame.local` resolve from the Mac (mDNS/Avahi)?** If not, use the IP
and set up a DHCP reservation.
4. **Does SSH stay enabled after a reboot and after an OS update?** Also check
that `~/.ssh/authorized_keys` survives an update.
5. **Is the `sshd_config.d` include present?** Check before `--harden`:
`ssh frame 'grep -n Include /etc/ssh/sshd_config'`.
6. **What does Steam Link on macOS show when connected to `frame`?** Is it the
VR view, a flat mirror, or the desktop? Does keyboard/mouse input reach the
headset?
7. **Does the xrdp session work from Microsoft Windows App on macOS?** Valve
only documents Windows Remote Desktop Connection. Is clipboard sync
supported?
8. **What kind of session is the in-headset Linux desktop?** It could be a
normal Plasma Wayland session (with a `wayland-*` socket in
`/run/user/$(id -u)`), X11, or something nested in SteamVR. This decides
whether `paste-to-frame.sh` works. `ssh frame 'ls /run/user/$(id -u); loginctl list-sessions'`.
9. **Are `wl-copy`, `xclip`, and `rsync` present on the image?**
`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.
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
to type locally.
15. **ADB**: does `adb shell` over USB-C from a Mac (not just a Windows PC)
reach the Linux side? Does USB power from the Mac cope?
16. ~~**Tailscale**~~: answered 2026-09-25. A userspace `tailscaled` in `~`
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
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
option: do ADB-installed apps survive closing and reopening it?
19. **Typing in Android apps:** Lepton has no IME installed. Does the SteamVR
keyboard or a Bluetooth keyboard reach Android text fields, or does an
F-Droid keyboard (installed and enabled with `ime enable`/`ime set`) work?
20. **F-Droid 2.0** (Compose 1.12): does it run? If so, the catalogue can
install it instead of 1.17.2.
21. **DeoVR local files:** does DeoVR's file browser show `Videos → VR`
(the symlink from `push-vr-video.sh`) or `Z:\home\steamos\Videos\VR`, and do
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?
## 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
`bootstrap-on-frame.sh` haven't run against real hardware.
-125
View File
@@ -1,125 +0,0 @@
# Arranging windows in space
The confidence labels are the same as in [ssh.md](ssh.md).
## The short version
- The in-headset **Linux desktop is one flat panel**: a nested Plasma session,
fixed at 1280×800, drawn into a single SteamVR overlay. Windows *inside* it
are arranged by KWin inside that rectangle. They can't leave it.
- Every **Steam app gets its own panel**. gamescope runs with
`--virtual-connector-strategy PerAppId`, so each distinct app id becomes a
separate SteamVR overlay named `valve.steam.desktopgame.<appid>`.
- To float a Linux app on its own, run it on gamescope's X display (`:0`)
instead of in Plasma, and tag its window with an app id of its own.
`scripts/panel-on-frame.sh` does this:
```sh
./scripts/panel-on-frame.sh konsole # a terminal, as its own panel
./scripts/panel-on-frame.sh --name notes -- kate '~/notes.md' # quote ~ so the Frame expands it
./scripts/panel-on-frame.sh org.mozilla.firefox # a Flatpak
./scripts/panel-on-frame.sh mac-screen # the Mac's screen (Remmina/VNC)
```
- Then **place each panel with the SteamVR dashboard's docking controls**:
**Float in World**, **Move**, **Size**, **Toggle Curvature**, dock on the
left or right controller, **View in Theater**, and **Multitasking View**.
## How a panel is born (verified 2026-09-25)
gamescope's command line on the Frame includes:
```
--backend openvr --xwayland-count 2 --virtual-connector-strategy PerAppId
--vr-overlay-key valve.steam.gamepadui.fallback
--vr-app-overlay-key valve.steam.desktopgame
--vr-overlay-physical-width 2.67 --vr-overlay-enable-control-bar
--nested-width 1280 --nested-height 720
```
gamescope reads each X11 window's `STEAM_GAME` property as its app id. That's
the same property Steam sets on games it launches. On a new id, Steam's
SteamVR system UI logs:
```
[Overlays] Created: valve.steam.desktopgame.7777777
[Overlays] Created: valve.steam.desktopgame.7777777.layer1 … layer7
```
The test: an `xterm` on `DISPLAY=:0`, tagged with
`xprop -id <win> -f STEAM_GAME 32c -set STEAM_GAME 7777777`, produced the
overlay above. Two more apps with different ids (`konsole`, `xterm`) produced
two more overlays, and all three were listed together in the root property
`GAMESCOPE_FOCUSABLE_APPS`. **Not yet checked by eye:** how the new panels
look in the headset and how they handle input.
Untagged windows on `:0` get app id 0 and share the default panel. Plasma
itself (`kwin_wayland`, pid in `GAMESCOPE_FOCUSABLE_WINDOWS`) is one of those.
### What `panel-on-frame.sh` does
1. Sets `DISPLAY=:0`, unsets `WAYLAND_DISPLAY`, and forces X11 in the
toolkits (`QT_QPA_PLATFORM=xcb`, `GDK_BACKEND=x11`, `SDL_VIDEODRIVER=x11`,
`MOZ_ENABLE_WAYLAND=0`). A Wayland-only app would connect to gamescope's
own Wayland socket and not get tagged.
2. Starts the app detached (`setsid nohup`), so it outlives SSH.
3. Diffs the root window's children before and after, and sets `STEAM_GAME`
on each new mapped top-level window. It keeps watching about 3s after the
first window (for splash screens), up to 20s in total (for slow Flatpaks).
It gives up early if the app exits before showing a window.
4. The id comes from `--id`, or is derived from `--name`/the command in the
range 2,000,000,000–2,000,999,999, far above real Steam app ids. The same
label always gives the same id.
Limits:
- **Single-instance apps** (Remmina, most KDE apps with a running copy in
Plasma) hand the request to the existing process, so the window opens
wherever that process lives. Close the app in Plasma first.
- A window the app opens later (a dialog, a second window) isn't tagged, so it
lands on the default panel. Tag it by hand:
`ssh frame 'DISPLAY=:0 xprop -id <win> -f STEAM_GAME 32c -set STEAM_GAME <id>'`
(find `<win>` with `DISPLAY=:0 xwininfo -root -children`).
- The script tags *any* new window on `:0` during its watch window, so a
Steam popup that opens in those few seconds would join the panel too. For
the same reason, run one `panel-on-frame.sh` at a time. If a stray window
is tagged first, the script can report success while the app's own window
stays on the default panel; check in the headset.
- Each panel renders at gamescope's nested size (1280×720), not the Plasma
desktop's 1280×800.
- Steam treats the tagged id as "the current game": it applies a generic
controller config and logs `Failed to get app info` for the made-up id. So
far this hasn't caused anything worse.
## Placing panels: the SteamVR dashboard (inferred from SteamVR's UI code)
The Frame's SteamVR dashboard
(`/opt/steamvr/resources/webinterface/dashboard/`) wraps each overlay in a
frame with a **dock location**: `Dashboard`, `World`, `Theater`,
`LeftController`, `RightController`. The strings and handlers are there
(`dashboard_english.json`, `systemui.js`):
| Control | What it does |
|---|---|
| **Float in World** | Only shown while the panel is docked on the dashboard. Detaches it into the room, where it stays after the dashboard closes. |
| **Move** / grab handle | Push, pull and drag the panel. *Grab Handle Acceleration* in SteamVR settings speeds up push and pull. |
| **Size** | Resize the floating panel. |
| **Toggle Curvature** | Flat vs curved. |
| **Dock on Left/Right Controller** | Attach to a controller, like a wrist screen. |
| **Dock on Dashboard / Return to Dashboard** | Put it back. |
| **View in Theater** / Show/Hide Theater Screen | Shows the panel as a large theater screen. |
| **Multitasking View** | Shows every open panel together (only if `VRHTML.BSupportsMultitaskingView()`). |
| **More Options** (…) | Where the less common docking actions live. |
**Still to check in the headset:** where exactly each control appears, whether
floating positions survive a panel closing and reopening, and whether there's
a limit on the number of floating panels.
## Other routes
- **Just the desktop somewhere else**: float the Plasma panel itself. No
script needed.
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
-105
View File
@@ -1,105 +0,0 @@
# Scripts and headset setup
The command-line side of this repo: how SSH gets set up with as little typing on
the headset as possible, what to use for each job, and the helper scripts that
Frame Control is built on. The scripts are zsh/bash and run on macOS; most also
run on Linux. On Windows, use the app.
## Minimum typing on the headset
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
thing you type on the headset is a password you choose.
On the Frame:
1. **Steam Settings → System → Enable Developer Mode** (a toggle, no typing).
2. Scroll down to the **Developer** section and click **Set User Password**.
Type a password. **This is the only thing you type on the headset.** Pick
something short, because you'll type it once more on the Mac and then
never again.
3. (Optional, no typing) Note the IP address from **Quick Settings** or
**Steam Settings → Internet**, in case `frame.local` doesn't resolve.
4. (Optional) Check **Steam Settings → System → Hostname**. Leaving it as
`frame` means the scripts work without any extra setup.
On the Mac:
To use the scripts from a checkout instead of the app:
```sh
git clone https://github.com/saphid/steam-frame.git && cd steam-frame
./scripts/connect.sh # or: ./scripts/connect.sh 192.168.1.50
ssh frame # passwordless from now on
```
`connect.sh` does four things:
- finds the headset (`frame.local`, then `frame`, or the IP/host you pass in)
- creates dedicated keys (`~/.ssh/id_ed25519_frame`, plus `~/.ssh/id_rsa_frame_devkit` for pairing)
- adds a `Host frame` block to `~/.ssh/config`
- tries SteamOS devkit pairing (approve on the headset, no password; **inferred**,
see [SSH](ssh.md#password-free-pairing-steamos-devkit-service)), else runs
`ssh-copy-id`, which asks for the Developer Mode password once
Run `./scripts/connect.sh --harden` later if you want to turn off SSH password
logins.
Sources: [Valve: Setting up your Steam Frame for development](https://partner.steamgames.com/doc/steamhardware/steamframe/setup),
[Valve: Steam Frame Debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)
(both **confirmed on Steam Frame**, Valve official).
**Fallback, only if the Developer Mode toggle doesn't give you SSH.** From the
Mac, run `./scripts/serve-bootstrap.sh`. It prints a one-liner of about 30
characters, like `curl -fsS mac.local:8765|bash`, to type into Konsole on the
Frame's Linux desktop. The script it serves installs your Mac's public key and
enables `sshd`. See [docs/ssh.md](ssh.md#fallback-bootstrap-one-liner).
## Recommended options
| Goal | Recommended | Confidence |
|---|---|---|
| Shell on the Frame | `ssh frame` (user `steamos`) | Confirmed (Valve docs) |
| **See/control the Frame from the Mac** | **Steam Link for macOS → connect to `frame`** (Valve names this). Alternatives: RDP to `xrdp` with Microsoft *Windows App* for the Linux desktop, or `adb`/`scrcpy` for the Android (Lepton) layer only | Steam Link and xrdp confirmed on Frame; the Mac RDP client is inferred |
| **Show the Mac's desktop inside the Frame** | **macOS Screen Sharing (built-in VNC) → Remmina (Flatpak, aarch64) on the Frame's Linux desktop**, installed over SSH | Inferred: each piece is documented, but the combination hasn't been tested on a Frame |
| File transfer | `scp` / `rsync` over the `frame` alias (`scripts/push.sh`) | **Verified** (rsync is on the image) |
| Paste Mac clipboard into the headset | `scripts/paste-to-frame.sh` (`pbpaste` → `ssh` → Klipper over D-Bus), or the clipboard sync in an RDP session | **Verified** (script); RDP untested |
Details: [docs/ssh.md](ssh.md), [docs/streaming.md](streaming.md),
[docs/file-transfer.md](file-transfer.md),
[docs/open-questions.md](open-questions.md). For how the Frame's software
fits together, see [docs/how-the-frame-works.md](how-the-frame-works.md).
## Windows anywhere in the room
The in-headset Linux desktop is a single 1280×800 panel, and its windows can't
leave it. Each Steam app, though, gets its own SteamVR panel. That also works
for any Linux app tagged with an app id of its own:
```sh
./scripts/panel-on-frame.sh konsole
./scripts/panel-on-frame.sh mac-screen # the Mac's screen, in its own panel
```
Then use the SteamVR dashboard's **Float in World**, **Move** and **Size**
controls to place each panel. See [docs/panels.md](panels.md).
## Scripts
| Script | Runs on | Purpose |
|---|---|---|
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
| `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/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**) |
| `scripts/compat-db-backup.sh` | Mac | Maintainer-only: back up the shared compatibility database locally and to Google Drive (**verified**) |
| `scripts/push-vr-video.sh` | Mac → Frame | Upload VR180/360 videos to `~/Videos/VR`, linked into DeoVR's Proton prefix; `--launch` starts DeoVR (**verified**: upload and link; in-headset playback of local files not yet checked). See [docs/vr-video.md](vr-video.md) |
| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
| `scripts/bootstrap-on-frame.sh` | Frame | Fallback: install the key and enable `sshd` |
-171
View File
@@ -1,171 +0,0 @@
# Sideloading Linux and Windows games
A game you have as files (an itch.io download, your own build, a DRM-free
release) can go into the Frame's Steam library without a Steam store page.
Frame Control uses the same path as Valve's
[SteamOS Devkit Client](https://gitlab.steamos.cloud/devkit/steamos-devkit):
the title becomes a Steam **Devkit Game**, with a runtime (Proton or a Steam
Linux Runtime) chosen from the program itself.
For Android APKs, see [apks.md](apks.md) instead.
**Status: nothing here has run on a headset yet.** Every device-side step is
**inferred from Valve's steamos-devkit source** (release v0.20260925.1). The
local steps (reading the zip, picking the program and runtime, building the
request) are covered by `tests/test_frame_titles.py`.
## Using it
Drop a game's `.zip`, folder or `.exe` on **Send to Frame**. (Folders need the
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.
- **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
between Proton Experimental and Proton (stable).
Install copies it to the Frame and registers it with Steam; progress shows in
the bar and the activity log. **Sideloaded titles** lists what's installed,
with Launch and Remove. **Copy to ~/Downloads instead** keeps the old
behaviour for a zip that isn't a game.
From a terminal:
```sh
python3 ui/frame_titles.py inspect Game.zip # what would be installed, no headset needed
python3 ui/frame_titles.py install Game.zip [--name N] [--exe REL] [--runtime R]
python3 ui/frame_titles.py list | launch ID | remove ID
```
## Choosing the runtime
The program's header decides, not its file name:
| Program | Runtime (Steam compat tool) | `steam_play` | Confidence |
|---|---|---|---|
| Windows `.exe`, x86-64 (PE machine `0x8664`) | `proton-experimental` | 1 | Inferred: ARM64 Proton runs x86-64 code through FEX |
| Windows `.exe`, 32-bit x86 (`0x14c`) or ARM64 (`0xaa64`) | `proton-experimental` | 1 | Inferred |
| Linux ELF, aarch64 (`e_machine` `0xB7`) | `SteamLinuxRuntime_4-arm64` | 0 | Verified: starts, but natively (see below) |
| Linux ELF, x86-64 (`0x3E`) | `SteamLinuxRuntime_4` | 0 | Verified not to start: the runtime isn't installed (see below) |
| Shell script | the runtime of the Linux binary beside it, else `SteamLinuxRuntime_4-arm64` | 0 | Guess |
| Anything else (32-bit Linux, other CPUs, DLLs, data) | refused with a message | | |
Proton Experimental is the default rather than stable because the Frame's
ARM64 Proton and FEX stack is new and Proton fixes reach Experimental first.
If a game misbehaves, reinstall it with Proton (stable).
The aliases and settings are the ones Valve's client sends: `RUNTIME_ALIASES`
in `devkit_client/__init__.py`, and `gui2._update_game`, which sets
`steam_play=1, steam_play_debug=0, steam_play_debug_version=2019` for Proton
and `steam_play=0` otherwise, plus `compat_tool=<alias>`. Valve's client only
offers `SteamLinuxRuntime_4-arm64` and Lepton when the device reports itself
as Deckard (the Frame).
## Picking the program
`ui/frame_titles.py` reads every file's header: ELF executables (PIE ones are
told from shared libraries by their `PT_INTERP` segment), PE executables (not
DLLs) and scripts with `#!`. A zip with a single top-level folder is treated
as that folder. Candidates are ranked by:
1. Not a helper: names like `UnityCrashHandler64`, `CrashReportClient`,
`*setup*`, `unins*`, `vc_redist*`, `dxsetup`, `*prereq*`, and anything under
`_CommonRedist`, `Redist`, `DirectX` or `Engine` go last.
2. Platform: native ARM64 Linux, then Windows x86-64, then x86-64 Linux, then
other Windows builds.
3. Name: a program named like the zip or folder (build words such as
`-linux-arm64` or `_v1.2` are dropped from the name).
4. Depth, then size: Unreal's top-level `Game.exe` beats
`Game/Binaries/Win64/Game-Win64-Shipping.exe`.
A top-level shell script beats a Linux binary one folder down (`run.sh` +
`bin/game`); a binary next to a script wins. The list in the dialog lets you
pick another.
## What happens on the Frame (inferred)
1. **Tools.** `frame/devkit-utils/` (Valve's scripts, vendored unmodified, MIT)
is copied to `~/devkit-utils`, where Valve's client puts it, unless the
stamp file there already matches. Files are merged, not replaced, so a
newer copy from Valve's client keeps its extra files.
2. **Folder.** `python3 ~/devkit-utils/steamos-prepare-upload --gameid ID`
makes `~/devkit-game/ID` and prints `{user, directory}`.
3. **Copy.** The files go there with `rsync -a --delete` on macOS and Linux,
or `scp -r` into a fresh folder that then replaces it on Windows. Then
`chmod -R 755`, the modes Valve's client gives an upload.
4. **Register.** `python3 ~/devkit-utils/steam-client-create-shortcut --parms JSON`
with `{gameid, directory, argv: [target], env: {}, settings, clear_settings,
force_appid: "", lepton_args: ""}`. It writes `ID-argv.json`,
`ID-env.json` and `ID-settings.json` next to the folder, then sends
`create-shortcut` to the running Steam client over `~/.steam/steam.pipe`
(authenticated by `~/.steam/steam.token`) and waits up to 5 s for Steam's
answer file. Its `error`, for example "The Steam client is not running",
is shown as the install error. The files stay, so installing again with
Steam running finishes the job.
5. **Launch** is `steam-devkit-rpc run-game gameid=ID`. **Remove** is
`steamos-delete --delete-title ID`, which deletes the folder and has Steam
drop shortcuts with no folder. Frame Control then removes the `ID-*.json`
files that Valve's script leaves behind.
Frame Control also writes `~/devkit-game/ID-framecontrol.json` (name, source
file, target, runtime, size). **Sideloaded titles** lists every folder in
`~/devkit-game`, including titles uploaded with Valve's client.
`argv` is one string, as in Valve's client (the start command may carry
arguments), so a program path with spaces is sent in double quotes. How Steam
splits that string is **not checked**.
## Safety
- Zips are unpacked on your computer first. Entries with absolute paths, `..`,
drive letters or `:` anywhere in the path, or links that point outside the
zip (or at a folder they're in) are refused. So are zips over 64 GB
unpacked, over 200,000 entries, more than 200× compressed past 1 GB, or
bigger than the free space.
- No symlink is created while unpacking, so no write can be redirected
through one. A link to a file inside the zip (`libfoo.so.1 → libfoo.so.1.2`)
becomes a copy of that file, which also works on Windows. Links to folders,
loops and dangling links are left out.
- A dropped folder that contains symlinks (or Windows junctions) is copied on your computer first,
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
reserved sideload names (`steam`, `steamvr`, and their `deckard` forms,
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
[frame-control.md](frame-control.md#how-it-works)).
## Checked on a headset
Tested 2026-09-26 on a Frame (BUILD_ID 20260922.6101926) with small static test
programs and PuTTY's official 64-bit `putty.exe`, through both the command line
and the app (inspect, install job, ▶, Remove, and install links):
- [x] `create-shortcut` registers a title; it shows in the Steam library and in
**Sideloaded titles**, and Steam maps it to the chosen compat tool.
- [x] `steam-devkit-rpc run-game` starts it (Steam logs `devkit run-game: started
devkit game "<id>"`), and Remove (`steamos-delete`) deletes the files, the
shortcut and the Proton prefix.
- [x] A quoted path in the start command is fine: Steam runs
`proton waitforexitandrun "/home/steamos/devkit-game/<id>/<exe>"`.
- [x] An x86-64 Windows `.exe` runs under **Proton 11 (stable)** through FEX
(ARM64EC) inside the Steam Linux Runtime 4.0 ARM64 container; PuTTY stayed up.
Proton Experimental wasn't installed at the time (it was downloading), so it's
untested. A Go-built x86-64 test program crashed in `libarm64ecfex.dll`
(a FEX limitation with that program, not the sideloading).
- [ ] **An aarch64 build runs natively, not in `SteamLinuxRuntime_4-arm64`**:
Steam records the mapping (`CompatToolMapping`, `compat_log.txt`) but launches
the devkit title without the runtime's `_v2-entry-point` prefix. Fine for a
self-contained build; a build that needs the runtime's libraries may not start.
- [ ] **An x86-64 Linux build doesn't start**: Steam logs `Tool 4183110 "Steam
Linux Runtime 4.0" is found for appID …, but is not installed`, and the Frame
doesn't install that x86-64 runtime for a devkit title (a `steam://install/4183110`
request did nothing).
- [ ] Whether these titles open as flat panels or need anything VR-specific.
-164
View File
@@ -1,164 +0,0 @@
# SSH into the Steam Frame
Confidence labels:
- **Confirmed (Frame)**: Valve's Steam Frame docs or a Frame-specific source.
- **Inferred (Deck/SteamOS)**: true on Steam Deck or SteamOS generally, but
not checked on a Frame.
- **Guess**: reasoned, with no source.
## How access is turned on
| Claim | Confidence | Source |
|---|---|---|
| **Steam Settings → System → Enable Developer Mode** enables SSH, ADB, and RDP | Confirmed (Frame) | [setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup), [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) |
| A password is set in **Developer → Set User Password**. There is no default password. | Confirmed (Frame) | [setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup) |
| The default user is **`steamos`**, not `deck` | Confirmed (Frame) | [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging): `ssh steamos@frame` |
| The default hostname is **`frame`**, and can be changed in **Steam Settings → System → Hostname** | Confirmed (Frame) | [setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup), [adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton) |
| The IP address is shown in Quick Settings or **Steam Settings → Internet** | Confirmed (Frame) | [adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton) |
| The rootfs is read-only. `sudo steamos-readonly disable` makes it writable. | Confirmed (Frame) | [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) |
| `sudo pacman` works. Helper aliases `cdd` (Frame scripts dir), `cdl` (Steam logs), and `lepton` exist. | Confirmed (Frame) | [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) |
| There's a full KDE Plasma Linux desktop inside the headset, reachable from the SteamVR dashboard | Confirmed (Frame, press) | [Road to VR review](https://roadtovr.com/valve-steam-frame-review/), [UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/) |
| On Deck, the manual route is Desktop Mode → Konsole → `passwd` → `sudo systemctl enable --now sshd` | Inferred (Deck) | [pimylifeup](https://pimylifeup.com/steam-deck-ssh/), [gist](https://gist.github.com/chphr/9c0791de6d2c659af3bf5890d9080973) |
On Deck, SSH needs the manual terminal steps. On the Frame, the Developer Mode
UI handles both the password and the SSH service. That's why the headset-side
checklist in the README involves no terminal at all.
## Name resolution from a Mac
Valve's examples use a bare `frame`. That works on Windows through
LLMNR/NetBIOS. **On macOS, a bare single-label name usually doesn't resolve**
unless your router's DNS registers DHCP client names.
- **Verified on device (2026-09-25):** `avahi-daemon` is running on the Frame
and `frame.local` resolves from the Mac over mDNS.
- `scripts/connect.sh` tries `frame.local`, then `frame`, then an mDNS browse for
the devkit service (below). If none works, it tells you to re-run it with the IP.
Once you have a working address, the `Host frame` alias means you just type
`ssh frame`.
- To check discovery yourself: `dns-sd -G v4 frame.local` (Ctrl-C to stop), or
`dscacheutil -q host -a name frame.local`.
- A DHCP reservation for the headset on your router makes the IP stable. That's
the most reliable fallback.
## Key-based login (done by `scripts/connect.sh`)
```sh
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_frame -N '' -C "mac->steam-frame"
ssh-copy-id -i ~/.ssh/id_ed25519_frame.pub steamos@frame.local
```
`~/.ssh/config` block (managed between marker lines by the script):
```
Host frame
HostName frame.local
User steamos
IdentityFile ~/.ssh/id_ed25519_frame
IdentityFile ~/.ssh/id_rsa_frame_devkit
IdentitiesOnly yes
ServerAliveInterval 30
```
The script only asks for the password if the pairing below doesn't work.
## Password-free pairing (SteamOS devkit service)
From Valve's source ([steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service),
[steamos-devkit](https://gitlab.steamos.cloud/devkit/steamos-devkit) client). **Verified on a
Frame 2026-09-26** (BUILD_ID 20260922.6101926): the service runs with Developer Mode
on, `properties.json` answers with `"login": "steamos"`, the headset advertises
`_steamos-devkit._tcp` as `frame`, and `/register` needs pairing mode (below). The
approve prompt and key install are not verified yet. SteamOS's devkit service is
what Valve's Devkit Client uses to pair. `scripts/connect.sh` and
`ui/frame_connect.py` try it first:
- The headset serves HTTP on port **32000** and advertises mDNS
`_steamos-devkit._tcp`. `GET /properties.json` gives the `login` user; the
script uses it as `User` (unless you set `FRAME_USER`, or it says `root`),
for the password fallback too, and keeps it on re-runs.
- **Open Steam Settings → Developer → Pair new host in the headset first.**
Otherwise `/register` answers at once with `403` `"please put the Steam client
in pairing mode: Settings -> Developer -> Pair new host"` (verified). The
scripts say so and keep asking for 2 minutes while you open it.
- `POST /register` with `ssh-rsa <key> <comment> 900b919520e4cf601998a71eec318fec`
(a fixed token from Valve's client) shows an approve prompt inside the
headset naming the comment (`frame-control@<your computer>`). It waits 30 s,
then installs the key for the device user and turns `sshd` on. The reply is
`200 Registered`, or `403` with `{"error": ...}` (declined, timed out, Steam
not running).
- It only accepts **RSA** keys, hence the second key,
`~/.ssh/id_rsa_frame_devkit` (3072-bit).
- A host counts as found if port 22 **or** 32000 answers. With no host given,
and `frame.local`/`frame` unreachable, it browses `_steamos-devkit._tcp` with
`dns-sd` (macOS) or `avahi-browse` (Linux) for a few seconds if installed.
- Port 32000 closed, a timeout, or an error: the script says why and falls back
to copying the ed25519 key with the Developer Mode password, as before.
Anyone on your network can send the request, so only approve a prompt you
started. `curl http://<frame-ip>:32000/properties.json` shows whether the service is up.
`~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS
updates (inferred from Deck; the Frame uses the same A/B image scheme).
## Keeping `sshd` enabled across updates
- **Frame**: SSH is tied to the Developer Mode toggle, so it should survive
updates as long as Developer Mode stays on. (Inferred: Valve doesn't say how
the toggle is implemented.)
- **Deck (for comparison)**: `systemctl enable sshd` usually persists because
`/etc` is an overlay that survives updates. Changes under `/usr` do not.
- Don't `pacman -S` anything you depend on for access. Packages installed into
the read-only rootfs are **wiped by OS updates** on SteamOS. Use Flatpaks
(`--user`) or `~/` for anything that needs to persist.
## Hardening (optional: `./scripts/connect.sh --harden`)
The script writes `/etc/ssh/sshd_config.d/01-frame-keys-only.conf` with
`PasswordAuthentication no` and `KbdInteractiveAuthentication no`, then reloads
`sshd`. First, it checks that key login works in BatchMode, so you can't lock
yourself out.
- Needs `sudo` (Developer Mode password), entered on the **Mac**.
- It assumes `/etc/ssh/sshd_config` includes `sshd_config.d/*.conf`, which is
the Arch default. The script checks for this and stops if the include is
missing.
- `/etc` drop-ins normally persist across SteamOS updates (inferred from Deck).
- It doesn't affect RDP (xrdp) or `sudo`, which still use the password.
- Undo: `ssh frame 'sudo rm /etc/ssh/sshd_config.d/01-frame-keys-only.conf && sudo systemctl reload sshd'`.
## Other shells
- **ADB over USB-C** to the native Linux OS:
`adb shell`. Plug the headset into the Mac. Valve notes that USB power may be
insufficient. Install with `brew install android-platform-tools`. This is
useful if Wi-Fi SSH is broken.
(Confirmed (Frame): [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging))
- **ADB over Wi-Fi** reaches the **Lepton (Android) container**, not Linux:
`adb connect frame:5555`. It only works while "Lepton Development" or an
Android app is running.
(Confirmed (Frame): [adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton))
- **RDP**: xrdp with user `steamos` and the Developer Mode password (see
[streaming.md](streaming.md)).
## Fallback bootstrap one-liner
Use this only if the Developer Mode toggle doesn't give you SSH (for example,
an OS build without it).
1. On the Mac: `./scripts/serve-bootstrap.sh`. It serves
`bootstrap-on-frame.sh`, with your `~/.ssh/id_ed25519_frame.pub` embedded,
on port 8765, and prints the exact one-liner.
2. On the Frame's Linux desktop, open **Konsole** and type the printed line,
roughly `curl -fsS mac.local:8765|bash` (~30 characters). If `mac.local`
doesn't resolve, the script prints an IP form instead.
3. The bootstrap installs the key into `~steamos/.ssh/authorized_keys`, and
then runs `sudo systemctl enable --now sshd`. `sudo` asks for a password,
and if none is set yet, it tells you to run `passwd` first. That means
typing the password on the headset one more time.
4. Stop the server on the Mac with Ctrl-C.
This is plain HTTP on your LAN, and it only serves a public key, so the
content isn't secret. Anyone on the LAN who can spoof your Mac's address could
serve a different script, though, so use it only on a trusted network.
-108
View File
@@ -1,108 +0,0 @@
# Installing and buying Steam games from the Mac
Frame Control's **Get games** section lists the games you own with each one's
Steam Frame rating, installs them on the Frame, and searches the Steam store.
This page covers how it works underneath, so you can do the same from a shell.
## How it works
The Frame's Steam client runs with `-cef-enable-debugging`. So its UI, a
Chromium page, answers the Chrome DevTools protocol on the Frame's loopback,
`127.0.0.1:8080`. The page titled **SharedJSContext** holds the client's own
state and API:
| Object | What it gives you |
|---|---|
| `appStore.allApps` | Every app the account owns (868 games here), with `local_per_client_data.installed`, playtime, `vr_supported`/`vr_only` and `steam_hw_compat_category_packed` |
| `downloadsStore.m_DownloadOverview` | A Map keyed by client ID. `"0"` is this machine: current app, percent, ETA, bytes/s |
| `SteamClient.Installs.*` | The install wizard: `GetInstallManagerInfo`, `ContinueInstall`, `CancelInstall`, `OpenInstallWizard` |
| `SteamClient.User.GetIPCountry()` | The store country (`AU` here), which store search needs |
`ui/frame_steam.py` is a stdlib-only WebSocket client for this page. It's piped
over SSH like the other helpers:
```sh
ssh frame 'python3 - owned' < ui/frame_steam.py # owned games + download
ssh frame 'python3 - install 274190' < ui/frame_steam.py # install Broforce
ssh frame 'python3 - store 1145360' < ui/frame_steam.py # store page in the headset
```
The debugger port only listens on the Frame's loopback, so it's reachable over
SSH and not from the network.
## Installing a game you own
`steam steam://install/<appid>`, run over SSH, hands the URL to the running
client, which opens its install wizard. The wizard's state
(`GetInstallManagerInfo().eInstallState`) then tells you what happens next:
| State | Meaning | What `frame_steam.py` does |
|---|---|---|
| 14 complete | Steam skipped the options dialog and queued the download | Reports "queued" |
| 7 config | The options dialog is showing in the headset (library folder, compatibility note) | Calls `ContinueInstall()` when the game fits on disk, as the headset's Install button does |
| 3, 4, 6, 8, 13 | Free license, CD key, password, EULA, signup | Leaves them for you to answer in the headset |
| 15 failed | Error | Reports `errorDetail` |
**Verified 2026-09-25 (SteamOS 0.3.0, build 20260922.6101926):**
- Balatro (2379780, 67 MB) went straight to state 14 and installed in about 7 s,
with nothing to answer in the headset.
- Broforce (274190, 0.6 GB) stopped at state 7. Calling `ContinueInstall()` over
DevTools queued the download, and the game installed.
- Calling `SteamClient.Installs.OpenInstallWizard([appid])` directly did nothing:
the state stayed at 0. Go through the `steam://install` URL instead.
**Inferred** from the client's JS: Steam skips the options dialog when there's
one library folder, the game fits, and there's no compatibility note to show.
Broforce is Deck "Playable", which probably explains why it stopped.
## Frame ratings
`steam_hw_compat_category_packed` holds two bits per device. The client decodes
it like this (from `steamui/chunk~2dcc5aaf7.js`):
| Device | Bits |
|---|---|
| Steam Deck | `packed & 3` |
| SteamOS | `packed >> 4 & 3` |
| Steam Machine | `packed >> 6 & 3` |
| **Steam Frame** | `packed >> 8 & 3` |
The values are 0 unknown, 1 unsupported, 2 playable and 3 verified. On
2026-09-25 this library had 12 Frame Verified, 2 Playable, 6 Unsupported and 848
Unknown games.
For games you don't own, the store's public
`saleaction/ajaxgetdeckappcompatibilityreport?nAppID=<id>` returns
`frame_resolved_category` on the same scale, along with `resolved_category`
(Deck), `steamos_resolved_category` and `machine_resolved_category`. No key or
login is needed.
## Buying
Frame Control doesn't buy anything. Purchases happen on Steam's own store page,
signed in as you:
- **Buy on Steam ↗** opens `store.steampowered.com/app/<id>/` in the Mac's
browser (the Electron app sends `target=_blank` links there).
- **Store on Frame** runs `steam steam://store/<id>`, which opens the page in the
Steam client on the headset. **Verified 2026-09-25:** a "Hades on Steam" page
appeared in the DevTools page list. It wasn't visible in the headset capture
because an app was in the foreground; it opens in Steam's dashboard.
After buying, press **Refresh** in Get games. The game shows up as owned, and
**Install on Frame** installs it.
Store search uses `store.steampowered.com/api/storesearch/?term=…&cc=…`. It
returns nothing without `cc`, so Frame Control takes the country from
`SteamClient.User.GetIPCountry()` on the Frame.
## Not yet checked
- Free-to-play games: `steam://install` should stop at state 3 (free license)
for you to accept in the headset. Not tried, because it adds a license to the
account.
- Games with a EULA (state 8).
- Installing when there's more than one library folder, such as a microSD card.
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
headset.
-64
View File
@@ -1,64 +0,0 @@
# Screen and desktop streaming
This covers two directions:
- **A. Frame → Mac**: see and control the headset from the Mac.
- **B. Mac → Frame**: use the Mac's desktop inside the headset.
The confidence labels are the same as in [ssh.md](ssh.md).
## A. See and control the Frame from the Mac
| Option | What you get | Confidence | Notes |
|---|---|---|---|
| **Steam Link (macOS app) → `frame`** | A remote view of the headset | **Confirmed (Frame)**: Valve says to "use Steam Link on iOS, Android, or desktop to view the headset remotely by connecting to 'frame'" ([debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)) | Steam Link for macOS exists ([Tom's Guide](https://www.tomsguide.com/news/macbook-gaming-just-got-a-killer-upgrade-with-steam-link-heres-how-it-looks)). It's the lowest-effort option. Whether you get the VR view or a flat mirror, and whether input works, is unverified. |
| **RDP to xrdp** | A separate Linux (Xorg) desktop session as `steamos` | **Confirmed (Frame)** for the server ([debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)); **Inferred** for the Mac client | On the Mac, use Microsoft **Windows App** (the old "Microsoft Remote Desktop") from the App Store. Add PC `frame.local` (or the IP), user `steamos`, and the Developer Mode password. Valve says Xorg is the default session. This is a *separate* X session, not a mirror of what's in the headset. It's good for running GUI apps and supports clipboard sync. |
| **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. |
| VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. |
**Recommendation for A:** start with Steam Link for macOS, because Valve
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
Mac with keyboard, mouse, and clipboard.
## B. Show the Mac's desktop inside the Frame
The Frame's streaming features are built around a **Windows PC running
SteamVR** plus the USB Wi-Fi 6E dongle. Even Linux hosts had VR-streaming
problems at launch
([Steam discussion](https://steamcommunity.com/app/4165890/discussions/0/528765047224280796/),
[gbl08ma](https://gbl08ma.com/posts/steam-frame-a-linux-machine-doesnt-support-linux/)).
**macOS isn't a supported SteamVR host**, so for the Mac we're only looking at
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). |
| 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. |
| WiVRn / ALVR | VR streaming from a Linux or Windows PC | Irrelevant for a Mac host (no SteamVR/OpenXR runtime on macOS) | N/A |
For **VR video files** (180°/360° stereo), don't stream the Mac's screen. Play
them on the Frame in DeoVR instead: see [vr-video.md](vr-video.md).
### Pre-seeding the Remmina profile (no typing in the headset)
`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.)
## Input and text entry without the virtual keyboard
- **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)).
- **RDP session**: Windows App syncs the clipboard with xrdp, but only inside
that RDP session.
-91
View File
@@ -1,91 +0,0 @@
# Tailscale: use the Frame from anywhere
With Tailscale on the Frame, the `frame` SSH alias works off the home LAN, and
so does everything built on it: Frame Control, the scripts and the Mac app.
When the Mac and the Frame are on the same network, Tailscale connects them
directly, so there's no relay in the way (`tailscale ping frame` → `via
192.168.1.50:41641`, 20 ms).
```sh
scripts/tailscale-on-frame.sh # install or update, then approve the login URL
scripts/connect.sh frame.<tailnet>.ts.net # point the alias at Tailscale (the script prints this)
scripts/tailscale-on-frame.sh --uninstall
```
## How it's installed
There's no Tailscale Flatpak, and the rootfs is read-only. So the script
installs Tailscale's static arm64 build in the `steamos` user's home and runs
`tailscaled --tun=userspace-networking` as a systemd **user** service. It
doesn't need sudo, and SteamOS updates don't touch it.
| Path | What |
|---|---|
| `~/.local/share/tailscale/<version>/` | `tailscale`, `tailscaled` (SHA-256 checked against pkgs.tailscale.com) |
| `~/.local/share/tailscale/current` | Symlink to the active version |
| `~/.local/share/tailscale/state/` | Node key and state |
| `~/.local/bin/tailscale` | CLI wrapper that points at the daemon's socket (`$XDG_RUNTIME_DIR/tailscale/tailscaled.sock`) |
| `~/.config/systemd/user/tailscaled.service` | The service |
Lingering (`loginctl enable-linger`) is on, so the service starts at boot
without anyone logging in. polkit allowed that without sudo. Re-running the
script is safe. It restarts `tailscaled` only if the version or unit changed,
and then does so detached after 3 s, because the SSH session may itself run
over Tailscale.
To update, run the script again; it installs the latest stable version. To
manage the node, use `ssh frame '~/.local/bin/tailscale status'` (or `set`,
`down`, `up`).
**Verified 2026-09-25 (SteamOS 0.3.0, build 20260922.6101926, Tailscale
1.102.4):**
- First install and login approval. This ran an earlier revision of the script,
which restarted the daemon unconditionally. The node is `frame`,
with a 100.x.y.z tailnet address.
- SSH works over Tailscale: the Frame serves the same ED25519 host key as it
does on `frame.local`.
- Frame Control's status and Get games work through the alias.
- The current script: a re-run with nothing changed doesn't restart anything,
and a re-run with a changed unit restarts `tailscaled` 3 s after the SSH
session ends and then reads `Running`. The timer needs
`AccuracySec=100ms`; the default of 1 min made it fire up to a minute late.
The first-install guard was checked on its own.
**Not verified:**
- A clean first install and login with the current script end to end. It would
mean removing the node from the tailnet.
- Reaching the Frame from outside the home network. Only the direct LAN path
was tested.
- The service coming up after a reboot. That's **inferred** from linger plus
`WantedBy=default.target`; the Frame hasn't been rebooted since.
## Exposure: every port is on the tailnet
In userspace mode, `tailscaled` passes inbound tailnet connections to the
Frame's **loopback**. Any device on the tailnet can therefore reach **every**
listening port, including ones meant to be local-only. Checked from the Mac on
2026-09-25:
| Port | Service | Normally |
|---|---|---|
| 22 | sshd | LAN |
| 8080 | Steam client DevTools (full control of the Steam client and account session) | loopback only |
| 27062 | SteamVR `vrserver` | loopback only |
| 5555 | Lepton ADB (unauthenticated shell into Android) | LAN |
| 3389 | xrdp | LAN |
The user accepted this on 2026-09-25, since the tailnet only holds their own
devices. Other options:
- `tailscale set --shields-up` blocks **all** inbound connections. That
includes SSH and Tailscale SSH (`--ssh`), both checked.
- A tailnet policy that tags the Frame (`tag:frame`) and allows only
`tag:frame:22` keeps the other ports private. This is an admin-console
change.
- Kernel-mode Tailscale (a root install, e.g. systemd-sysext) wouldn't expose
loopback-only ports, but it needs sudo and may not survive SteamOS updates.
If the Mac's Tailscale is off, the alias won't resolve. Use
`scripts/connect.sh frame.local` to go back to the LAN name.
-81
View File
@@ -1,81 +0,0 @@
# Watching VR video (180°/360°) on the Frame
The confidence labels are the same as in [ssh.md](ssh.md). Everything here
was checked on SteamOS 0.3.0, build 20260922.6101926.
## The short version
1. Install **DeoVR Video Player** from Steam (free, app 837380) and start it once
in the headset. That creates its Proton prefix.
2. On the Mac: `scripts/push-vr-video.sh --launch ~/Movies/beach_180_LR.mp4`
3. In the headset, open DeoVR's local file browser → **Videos → VR** and
pick the file.
## Why DeoVR
The Frame's Chromium can't play VR video in 3D. It has no immersive WebXR
([how-the-frame-works.md](how-the-frame-works.md)). DeoVR's Windows build
runs under Proton ARM64 + FEX as a real SteamVR app. **Verified 2026-09-25:**
it found the Steam Frame headset and controller over OpenVR, and decoded
7680×3840 and 8192×4096 H.265 VR180 side-by-side streams through AVPro's
hardware Media Foundation path, mapped onto a 180° dome or fisheye mesh.
Known quirks (verified):
- The first launch takes about 45 s while it compiles shaders.
- Grid thumbnails stay blank. Unity's own video player, which DeoVR uses for
previews, fails with `0xc00d36bb` under Proton. Full playback uses AVPro
and isn't affected.
- The in-app store and web content are separate from local files. You don't
need an account to play your own files.
## Getting files onto the headset
`scripts/push-vr-video.sh` copies files with `rsync --partial`, so an
interrupted upload resumes. They go to `~/Videos/VR` on the Frame (`/home`
has about 860 GB free). The script also links that folder into DeoVR's prefix
as `C:\users\steamuser\Videos\VR`. It's reachable at
`Z:\home\steamos\Videos\VR` as well. **Verified** that the upload and link
work. **Not yet checked** whether DeoVR's file browser lands there
(open question 21).
Speed: a test upload over Wi-Fi ran at about 3–5 MB/s (verified 2026-09-25,
one sample). At that rate an 8K file of several GB takes tens of minutes, so
start big uploads before you put the headset on.
## Naming files so they play correctly
DeoVR guesses the projection from the file name. Its binary contains the tags
`_180`, `_360`, `_fisheye`, `_fisheye190`, `_mkx200`, `_vrca220` and `_rf52`
(verified). For stereo layout, the common DeoVR convention is `_LR`/`_SBS`
(side by side) and `_TB` (top/bottom) (inferred). If a video looks wrong
(doubled, warped, or flat), change the projection and stereo mode in DeoVR's
player menu.
Examples: `trip_180_LR.mp4`, `concert_360_TB.mp4`, `hike_fisheye190_LR.mp4`.
Codecs: H.265 at 8K played (verified, streamed). Local H.264 and H.265
files haven't been played yet. The test clips below cover that.
## Test clips
The script doesn't include these. To check a setup, make two 20 s clips:
3840×1920, 180° side by side, with the left eye tinted red and the right eye
cyan. In the headset each eye should see only its own colour. A single mixed
colour means the stereo split is wrong.
```sh
ffmpeg -f lavfi -i testsrc2=size=1920x1920:rate=30:duration=20 \
-filter_complex "[0:v]split[a][b];[a]colorchannelmixer=rr=1:gg=0.3:bb=0.3[l];[b]colorchannelmixer=rr=0.3:gg=1:bb=1[r];[l][r]hstack" \
-c:v libx264 -pix_fmt yuv420p -b:v 20M frame-test_180_LR_h264.mp4
scripts/push-vr-video.sh frame-test_180_LR_h264.mp4
```
## Streaming from the Mac instead of copying (untested)
DeoVR has a DLNA browser (the binary contains `Searching for DLNA
devices...` and a UPnP ContentDirectory client). A DLNA server on the Mac
should therefore appear in DeoVR without copying anything, for example
`brew install rclone` then `rclone serve dlna ~/Movies/VR`. This is **inferred**, not
tried. 8K VR video needs roughly 50–100 Mbit/s sustained, and the Wi-Fi
sample above (about 30–40 Mbit/s) suggests copying first is the safer default.
-158
View File
@@ -1,158 +0,0 @@
# Install links for websites
A website can put an "Install with Frame Control" button next to its download.
Clicking it opens Frame Control, which shows what the link wants to install and
asks the user. Only after they click **Install** does it download the file and
install it on the Frame.
What's verified: the link parsing, URL rules, manifest parsing, download,
size cap and sha256 check, by `tests/test_webinstall.py` and
`tests/test_server.py` (no network: a stub server on 127.0.0.1). Installing on
the headset is the same code as dropping a file on Frame Control: `.apk` files go
to the APK installer ([apks.md](apks.md)), `.zip` and `.exe` files to the
Linux/Windows title installer. A link hasn't been clicked through to a headset
install yet.
## The link
```
frame-control://install?manifest=<URL-encoded manifest URL>
frame-control://install?url=<URL-encoded file URL>
```
Use `manifest` when you can: it carries the title's name and a sha256, which
Frame Control checks before installing. `url` is for a file on its own; the
dialog then names the title after the file.
The manifest is FrameDrop's format, so one manifest serves both apps. The
schema may be `framedrop.install/v1` or `frame-control.install/v1`:
```json
{
"schema": "framedrop.install/v1",
"name": "My Game",
"files": [
{ "url": "https://cdn.example.com/mygame-arm64.apk", "sha256": "optional-but-better" }
]
}
```
| Field | |
|---|---|
| `schema` | Required, one of the two above |
| `name` | Shown in the confirm dialog (at most 120 characters). Defaults to the file name. APKs are still named in the Steam library by their own label |
| `files` | Exactly one entry for now; more is refused with a message |
| `files[0].url` | Required. The file to install |
| `files[0].sha256` | Optional, 64 hex digits. The download must match or nothing is installed |
| `files[0].size` | Optional (Frame Control extension), bytes. Shown up front; the download must match |
| `files[0].exe` | Optional (Frame Control extension), for a `.zip` title: the program inside it to run |
What gets installed depends on the file name's extension:
| File | Installed as |
|---|---|
| `.apk` | An Android app in its own Lepton instance with a Steam shortcut ([apks.md](apks.md)) |
| `.zip`, `.exe` | A Linux or Windows title. Versions of Frame Control without the title installer say "Linux/Windows titles need a newer Frame Control" |
| anything else | Refused |
## Rules
Frame Control refuses a link, and downloads nothing, unless:
- Every URL (the manifest's, the file's and each redirect) is `https://`.
`http://` works only for `localhost` or `127.0.0.1`, for testing: only when
Frame Control runs with `FRAME_CONTROL_LOCAL_LINKS=1`, and only when the
link itself points there. It's off by default so a website's link can't make
the app fetch from services on your computer, and a public manifest can
never send it there.
- No URL has a user name or password in it (`https://user:pw@…`).
- No host is, or resolves to, a private, loopback, link-local, CGNAT
(100.64.0.0/10), multicast or otherwise non-public address. Every address
the name has must be public, it's checked again on every redirect (at most
5), and the download connects to the address that was checked.
- The file URL ends in a file name with one of the extensions above
(`https://example.com/games/` is refused).
- The manifest is JSON of at most 256 KB, and the file at most 4 GiB
(`MAX_MANIFEST` and `MAX_FILE` in `ui/frame_webinstall.py`).
- The user confirms. The dialog shows the title's name, the site the link came
from (and the file's host if different), the file name and type, the size if
known, and whether a sha256 was given.
A web page can't install anything itself: it can only open the link. Frame
Control's local server refuses requests from web pages, so the only way in is
the operating system handing the link to the app, then the user's click.
## Button for your site
Paste this where the download is, with your manifest's URL in `MANIFEST`:
```html
<a id="frame-control-install" href="#"
style="display:inline-block;padding:10px 18px;border-radius:4px;background:#1a9fff;color:#fff;
font:600 15px -apple-system,'Segoe UI',sans-serif;text-decoration:none">Install with Frame Control</a>
<script>
(() => {
const MANIFEST = "https://example.com/mygame/frame-control.json";
const GET_APP = "https://github.com/saphid/steam-frame/releases/latest";
const button = document.getElementById("frame-control-install");
button.href = "frame-control://install?manifest=" + encodeURIComponent(MANIFEST);
button.addEventListener("click", () => {
// If Frame Control opens, this page loses focus; if it doesn't, offer the download.
let left = false;
const away = () => { left = true; };
window.addEventListener("blur", away, { once: true });
setTimeout(() => {
window.removeEventListener("blur", away);
if (!left && confirm("Frame Control didn't open. Download it?")) location.href = GET_APP;
}, 2000);
});
})();
</script>
```
For a single file, use `"frame-control://install?url=" + encodeURIComponent(FILE_URL)`.
`docs/install.html` is a landing page that does the same from a plain link:
`install.html?manifest=<URL-encoded URL>` tries the app and shows a "Get Frame
Control" link. It isn't published anywhere yet; host a copy to use it.
## Testing locally
Start Frame Control with `FRAME_CONTROL_LOCAL_LINKS=1` in its environment (for
example `FRAME_CONTROL_LOCAL_LINKS=1 npm start` in `app/`), then serve the
manifest and file from your own computer:
```sh
cd mygame && python3 -m http.server 8000
open 'frame-control://install?manifest=http%3A%2F%2Flocalhost%3A8000%2Fmanifest.json' # xdg-open on Linux, start "" on Windows
```
The manifest's file URL must then be `http://localhost:8000/…` or
`http://127.0.0.1:8000/…` too.
## How it works
- `app/install-link.js` parses the link (only `frame-control://install` with
exactly one `manifest` or `url`); `app/main.js` registers the scheme
(`app.setAsDefaultProtocolClient`, and electron-builder's `protocols` for the
macOS Info.plist and the Linux `.desktop` file). macOS delivers links through
`open-url`, Windows and Linux as an argument to a second instance. Links
wait in the main process until the page has loaded and asked for them
(`frameApp.onInstallLink` in `app/preload.js`). `framedrop://` is left alone.
- The page posts the link to `/api/webinstall/check`, which reads the manifest,
applies the rules, asks the file's size with a HEAD request and returns a
one-time id. Nothing is downloaded.
- **Install** posts the id to `/api/webinstall/start`. The server downloads to
a temporary folder (progress at `/api/webinstall/job`, cancellable with
`/api/webinstall/cancel`), checks size and sha256, hands the file to
`frame_webinstall.dispatch()` and deletes the folder.
- The app registers the scheme each time it starts, so the last Frame Control
started (e.g. a development checkout) handles the links.
**Quitting during a stalled download.** On macOS and Linux, quitting stops a
download at once (`shutdown()` on its socket wakes the blocked read). On
Windows that doesn't wake a read in another thread, and closing the handle
under a TLS read isn't safe, so a download that has stalled holds the quit for
the 4-second grace period until the app stops the server; the partial file is
removed on the next start. Downloads that are still moving stop at their next
read either way.
-141
View File
@@ -1,141 +0,0 @@
# WebXR in Chromium on the Frame
Goal: open a web VR180 or 360 player (DeoVR and DL8 embeds, WebXR samples),
press its VR button, and watch in 3D in the headset.
The build and installer now live in their own public repo,
[saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame):
a build script for an x86-64 Linux host, the SO_PEERCRED patch, and a
Frame-side installer that adds "Chromium XR" to the Steam library. This page
keeps the findings and what was verified on this Frame.
## Why Flathub Chromium can't
**Verified 2026-09-25** (Frame BUILD_ID 20260922.6101926, Flathub
`org.chromium.Chromium` 154.0.8037.57 aarch64):
- `navigator.xr` exists, but `isSessionSupported("immersive-vr")` is `false`.
Flags don't change that, and neither does `--force-webxr-runtime=openxr`
with SteamVR exposed to the Flatpak.
- The binary has no OpenXR loader: no `XR_RUNTIME_JSON`,
`xrGetInstanceProcAddr` or `XR_LOADER_DEBUG` strings. The only OpenXR
strings are the `chrome://flags` entries.
**Cause (verified against Chromium source, same date).** M154 is the first
release that compiles OpenXR on Linux:
- `device/vr/buildflags/buildflags.gni` adds `is_linux` to `enable_openxr`.
- Flathub's tarball sets `checkout_openxr = true`.
- Flathub's GN args don't turn it off.
But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates
the OpenXR device under `ENABLE_OPENXR && IS_WIN`. That's true on 154, 155 and
`main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The
rest of the Linux port is in two unmerged CLs (bug 506004811):
- [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736)
runs the XR device service in a Linux sandbox that allows SteamVR's
sockets, `/dev/shm` and `flock`.
- [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979)
wires the provider to `OpenXrPlatformHelperLinux`. `kOpenXR` stays off by
default, so it needs `--enable-features=OpenXR`.
8132979 sits on top of 8441736, so fetching `refs/changes/79/8132979/<ps>`
gets both.
**The Frame side is ready.** `~/.config/openxr/1/active_runtime.json` names
SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The
Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`).
## Building and installing it
Follow the [public repo's README](https://github.com/saphid/chromium-webxr-steam-frame#build).
In short: `build/build.sh` on an x64 Linux host (no sudo, about 90 GB of
disk) produces `chromium-xr-arm64.tar.xz` (about 145 MB), and
`frame/install.sh` on the Frame unpacks it to `~/chromium-xr`, installs the
`chromium-xr` launcher in `~/.local/bin`, and adds the Steam library shortcut
through the Steam client's DevTools port, the same way as T3 Code
([apks.md](apks.md)). Launching the shortcut gives Chromium its own panel,
`valve.steam.desktopgame.<appid>`, like any other app.
First build, 2026-09-25, on a 12-thread, 31 GB x64 Linux box: 9 h 33 min for
94,835 steps, giving Chromium 156.0.8071.0. A rebuild after a one-file change
takes under a minute, plus about 4 minutes to repack.
To debug from the Mac, launch it as a panel with DevTools on the Frame
(verified 2026-09-27):
`scripts/panel-on-frame.sh -- '~/.local/bin/chromium-xr' --remote-debugging-port=9223 URL`
([panels.md](panels.md)). DevTools has no authentication. It listens on
loopback, but with the userspace Tailscale from [tailscale.md](tailscale.md)
running, loopback ports are reachable from your tailnet. Close the browser
when you're done. Chromium runs one browser per profile, so close the
Steam-launched one first or the flag is ignored.
**The SO_PEERCRED fix.** The XR seccomp policy refuses `getsockopt`. SteamVR's
client calls `getsockopt(SOL_SOCKET, SO_PEERCRED)` inside `xrCreateInstance`,
so the XR process died with a seccomp crash (arm64 syscall 209). The patch
allows that one option. It's needed but not enough: the launcher still turns
seccomp off (below), so the patch only matters once that's fixed too.
**Seccomp is off.** The launcher passes `--disable-seccomp-filter-sandbox`.
With the XR seccomp policy on, SteamVR's client reads `/proc/self/status`
through Chrome's file broker and gets the broker's pid. SteamVR then binds
the app to the wrong process ("Unable to init path manager:
VRInitError_Init_Internal") and `xrCreateInstance` fails. The broker can't
answer `/proc/self` for another process, so fixing this needs a change in
Chromium's broker client or in the CL. The namespace sandbox stays on, but
seccomp is off for every process, so use this profile for VR sites rather
than everyday browsing.
**Upstream (2026-09-27).** CL 8441736 (the XR sandbox) has merged into
Chromium, still refusing `getsockopt`; CL 8132979 is still in review. Valve
and the CLs' author are working on Steam Frame support
([utzcoz/chromium-webxr-linux#5](https://github.com/utzcoz/chromium-webxr-linux/issues/5)).
Both sandbox problems above, with the patch, are reported in
[utzcoz/chromium-webxr-linux#7](https://github.com/utzcoz/chromium-webxr-linux/issues/7).
**Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10,
this build):
- `isSessionSupported('immersive-vr')` is `true`. The WebXR samples page
shows "VR support detected".
- `requestSession('immersive-vr')` succeeds after the prompt. With a WebGL
layer, the first XR frame has a viewer pose with 2 views and a
2880 × 1440 framebuffer (1440 × 1440 per eye).
- SteamVR moves the app from `VRApplication_OpenXRInstance` to
`VRApplication_OpenXRScene` and gives it scene focus. `xrEndFrame` submits
both projection views, and the compositor receives the 2880 × 1440 scene.
- The OpenXR runtime uses Vulkan (`XR_KHR_vulkan_enable2`). Chromium's own GPU
process uses ANGLE on GL, running on zink over Turnip Vulkan (Adreno 750);
Chromium's Vulkan backend is off. That doesn't stop the session.
- Unprivileged user namespaces work (`unshare -Ur true`), so the namespace
sandbox runs without the setuid `chrome_sandbox`.
- **With the headset on** (same day, seccomp sandbox off): the WebXR
samples' Immersive VR Session showed its scene in the headset, and SteamVR
loaded the Frame controller bindings for the app. The three.js
[`webxr_vr_video`](https://threejs.org/examples/webxr_vr_video.html) demo,
a stereo 360 video, played in 3D after pressing Enter VR.
- **Launched from the Steam library, verified remotely 2026-09-27** with
nobody wearing the headset (standby workaround in
[how-the-frame-works.md](how-the-frame-works.md)). The installer's Steam
shortcut starts Chromium, and SteamVR takes it as scene app
`steam.app.<shortcut id>`. A minimal WebXR session that clears every frame
to red ran at about 75 frames per second, and the stereo headset capture
showed both eyes solid red. Steam preloads its overlay
(`gameoverlayrenderer.so`), which crashed Chromium's zygote about 30 s after
a Steam launch. The public repo's launcher now removes it from
`LD_PRELOAD`. With the headset outside its playspace, SteamVR shows
passthrough wherever the page leaves transparent pixels.
- **Frame rate and input, measured 2026-09-27** (standby workaround, red
test session): 72 fps with every frame at 13.9–14 ms over 16 s, and SteamVR
dropped frames only at startup. The right controller showed up as an
`oculus-touch` `tracked-pointer` with an `xr-standard` gamepad and a
25-joint hand, with poses on every frame. A real squeeze reached the page
as `squeezestart`/`squeeze`. Haptics aren't exposed (no actuators).
Details are in the public repo's technical notes.
**Not verified yet:** trigger, thumbstick and face buttons, the left
controller, bare-hand tracking, and third-party VR180 players (DeoVR and
DL8 web embeds).
-32
View File
@@ -1,32 +0,0 @@
#!/bin/bash
# Frame-side: run one Android app in its own persistent Lepton instance.
# Copied into ~/Applications/Android/<package>/launch.sh by the Mac-side
# installer, next to app.apk, instance.id and (for 2D apps) the empty
# lepton-show-flatscreen marker. A non-Steam shortcut points at this file.
#
# Why not Lepton Development: it wipes every app it installed when it exits.
# A "steamlaunch" context (SteamAppId set) keeps app data in
# compatdata/<id>/internal across restarts and APK updates. Pattern from
# frame/t3code/launch.sh; see docs/apks.md.
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
LEPTON="$HOME/.local/share/Steam/steamapps/common/Lepton/lepton"
[[ -x "$LEPTON" ]] || { echo "Lepton isn't installed (Steam app 3056000)" >&2; exit 1; }
for need in "$DIR/app.apk" "$DIR/instance.id"; do
[[ -f "$need" ]] || { echo "launch.sh: missing $need" >&2; exit 1; }
done
# A number that isn't a real Steam app; it names this app's Lepton context.
export SteamAppId="$(cat "$DIR/instance.id")"
export STEAM_COMPAT_INSTALL_PATH="$DIR"
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
export STEAM_COMPAT_SHADER_PATH="$HOME/.local/share/Steam/steamapps/shadercache/$SteamAppId"
export STEAM_FOSSILIZE_DUMP_PATH="$STEAM_COMPAT_SHADER_PATH/fozpipelinesv6/steamapp_pipeline_cache"
mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
# and SSH don't have; give it its own session instead.
export IS_PARENT=true
exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk"
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env python3
"""Frame-side: manage non-Steam shortcuts through the Steam client's CEF debug
port (127.0.0.1:8080, target SharedJSContext), without restarting Steam.
Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id
steam_shortcuts.py list -> JSON [{appid, name, exe}]
steam_shortcuts.py remove APPID
"""
import base64, json, os, socket, struct, sys, urllib.request
DEVTOOLS = 'http://127.0.0.1:8080/json'
def target_ws():
for t in json.load(urllib.request.urlopen(DEVTOOLS, timeout=5)):
if t.get('title') == 'SharedJSContext':
return t['webSocketDebuggerUrl']
sys.exit('SharedJSContext not found: is the Steam client running?')
class WS:
"""Just enough RFC 6455 for one CDP request/response on loopback."""
def __init__(self, url):
host_port, path = url[len('ws://'):].split('/', 1)
host, port = host_port.split(':')
self.s = socket.create_connection((host, int(port)), timeout=20)
key = base64.b64encode(os.urandom(16)).decode()
self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n'
f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n'
'Sec-WebSocket-Version: 13\r\n\r\n').encode())
buf = b''
while b'\r\n\r\n' not in buf:
buf += self.s.recv(4096)
if b' 101 ' not in buf.split(b'\r\n', 1)[0]:
sys.exit('websocket handshake failed')
self.rest = buf.split(b'\r\n\r\n', 1)[1]
def _read(self, n):
while len(self.rest) < n:
chunk = self.s.recv(65536)
if not chunk:
raise EOFError
self.rest += chunk
out, self.rest = self.rest[:n], self.rest[n:]
return out
def send(self, text):
data = text.encode()
mask = os.urandom(4)
n = len(data)
head = bytes([0x81]) + (bytes([0x80 | n]) if n < 126 else
bytes([0x80 | 126]) + struct.pack('>H', n) if n < 65536 else
bytes([0x80 | 127]) + struct.pack('>Q', n))
self.s.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
def recv(self):
msg = b''
while True:
b0, b1 = self._read(2)
n = b1 & 0x7f
if n == 126:
n = struct.unpack('>H', self._read(2))[0]
elif n == 127:
n = struct.unpack('>Q', self._read(8))[0]
msg += self._read(n)
if b0 & 0x80:
return msg.decode()
def evaluate(js):
ws = WS(target_ws())
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
while True:
r = json.loads(ws.recv())
if r.get('id') == 1:
break
res = r.get('result', {})
if 'exceptionDetails' in res:
sys.exit('JS error: ' + json.dumps(res['exceptionDetails'])[:500])
return res.get('result', {}).get('value')
def main():
cmd, args = sys.argv[1], sys.argv[2:]
if cmd == 'add':
name, exe, start_dir = args[:3]
icon = args[3] if len(args) > 3 else ''
js = f'''(async () => {{
const id = await SteamClient.Apps.AddShortcut({json.dumps(name)}, {json.dumps(exe)}, "", "");
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
if ({json.dumps(icon)}) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
return id;
}})()'''
print(evaluate(js))
elif cmd == 'list':
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
.map(a => ({appid: a.appid, name: a.display_name})))()'''
print(json.dumps(evaluate(js)))
elif cmd == 'remove':
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
print('removed')
else:
sys.exit(__doc__)
if __name__ == '__main__':
main()
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2017-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.
-18
View File
@@ -1,18 +0,0 @@
# Valve's devkit-utils (vendored)
Unmodified copy of `client/devkit-utils/` from Valve's
[SteamOS Devkit Client](https://gitlab.steamos.cloud/devkit/steamos-devkit),
MIT licensed (see `LICENSE`; Valve's own notes are in `VALVE-README.md`).
- Source: steamos-devkit, commit `6f0711a` ("Code drop."),
release **v0.20260925.1** (ChangeLog entry dated 2026-09-25).
`ui/frame_titles.py` copies this folder to `~/devkit-utils` on the Frame (where
Valve's own client puts it) and uses `steamos-prepare-upload`,
`steam-client-create-shortcut`, `steam-devkit-rpc` and `steamos-delete` to
register uploaded builds as Steam "Devkit Games". See `docs/sideloading.md`.
To update: copy the folder from a newer checkout over this one, keep this
README, and update the version line above. The stamp Frame Control compares
on the headset is a hash of these files, so a changed copy is re-synced on the
next use.
-4
View File
@@ -1,4 +0,0 @@
These scripts and supporting utility module are uploaded to the devkit by the devkit client:
- steamos-* : scripts that operate (mostly) at SteamOS level for devkit functionality purposes
- steam-client-* : scripts that relay commands to the local running Steam client
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
import os
import time
import subprocess
import logging
import argparse
import json
import datetime
import io
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
def main():
parser = argparse.ArgumentParser(description='Capture screenshot and videos on Steam Frame device')
parser.add_argument('--filename', '-f',
default='/tmp/screenshot.png',
help='Output')
parser.add_argument('--timestamp', action='store_true',
help='Add timestamp')
parser.add_argument('--json', action='store_true',
help='Output result as JSON')
args = parser.parse_args()
output_buffer = io.StringIO()
try:
steamvr_path = subprocess.check_output(['steamvr', 'path'], stderr=subprocess.STDOUT, universal_newlines=True).strip()
cdd = os.path.join(steamvr_path, 'bin/linuxarm64')
run_vrcmd = os.path.join(cdd, 'vrcmd')
assert os.path.exists(run_vrcmd), "vrcmd not found"
# Enable recording
cmd = [run_vrcmd, '--mailboxcmd', 'vrcompositor_systemlayer', 'set_local_video_record?enabled=true']
output_buffer.write(f"Command: {' '.join(cmd)}\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
# Wait for the video device to produce frames.
# Note that even when disabled it outputs roughly 2 blank frames per second.
cmd = ['timeout', '1', 'ffmpeg', '-f', 'v4l2', '-i', '/dev/video99', '-frames:v', '4', '-f', 'null', '-', '-v', 'error']
output_buffer.write(f"Command: {' '.join(cmd)}\n")
retries = 2
while True:
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode == 0:
break
retries -= 1
if retries <= 0:
raise Exception("Failed to get video frames from /dev/video99. Is VR active? Is the v4l2 configuration correct?")
output_filename = args.filename
if args.timestamp:
timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
base, ext = os.path.splitext(output_filename)
output_filename = f"{base}-{timestamp}{ext}"
# Capture screenshot
cmd = ['ffmpeg', '-f', 'v4l2', '-i', '/dev/video99', '-frames:v', '1', '-q:v', '1', '-y', output_filename]
output_buffer.write(f"Command: {' '.join(cmd)}\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
# Disable recording
cmd = [run_vrcmd, '--mailboxcmd', 'vrcompositor_systemlayer', 'set_local_video_record?enabled=false']
output_buffer.write(f"Command: {' '.join(cmd)}\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
except Exception as e:
error_msg = str(e)
# Print collected output to stderr on error
print(output_buffer.getvalue(), file=sys.stderr)
logger.error(error_msg)
if args.json:
result = {
'success': False,
'error': error_msg
}
print(json.dumps(result))
else:
print(f"Error: {error_msg}", file=sys.stderr)
return 1
if args.json:
result = {
'success': True,
'output': output_filename
}
print(json.dumps(result))
else:
print(f"Screenshot saved to {output_filename}")
if __name__ == '__main__':
sys.exit(main())
-300
View File
@@ -1,300 +0,0 @@
#!/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 = 'steam://{0}'.format(cmd)
# ^ hack to execute a normal command over the IPC directly - sometimes useful
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_env(gameid, env):
"""Save environment variables if provided"""
if not env:
return
envfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-env.json")
try:
with open(envfile, "w") as envf:
fcntl.flock(envf, fcntl.LOCK_EX)
json.dump(env, envf)
fcntl.flock(envf, fcntl.LOCK_UN)
except IOError:
raise Exception(
"Unable to open env file for writing: {0}".format(envfile))
def obtain_env(gameid):
"""Obtain environment variables for a game, if any were saved"""
envfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-env.json")
try:
with open(envfile, "r") as envf:
fcntl.flock(envf, fcntl.LOCK_EX)
env = json.load(envf)
fcntl.flock(envf, fcntl.LOCK_UN)
except IOError:
return {}
return env
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)
@@ -1,87 +0,0 @@
#!/usr/bin/env python3
import sys
import os
import logging
from urllib.parse import quote_plus as urllib_quote_plus
import json
import tempfile
from . import validate_steam_client
from . import execute_steam_client_command
from . import wait_on_file_response
import logging as logging_module
logger = logging_module.getLogger(__name__)
def resolve_shortcuts():
# make sure there is a steam client online that we can talk to before doing anything
validate_steam_client()
# scan the devkit games
installed_gameids = set([])
devkit_game_path = os.path.expanduser('~/devkit-game')
if not os.path.exists(devkit_game_path):
logger.info('%r does not exist, creating', devkit_game_path)
os.mkdir(devkit_game_path)
entries = sorted(os.scandir(devkit_game_path), key=lambda entry: entry.name)
directories = [e for e in entries if e.is_dir()]
for d in directories:
gameid = d.name
file_names = [f.name for f in entries if f.is_file() and f.name.startswith(gameid)]
has_argv = '{0}-argv.json'.format(gameid) in file_names
has_settings = '{0}-settings.json'.format(gameid) in file_names
if (not has_argv and not has_settings):
logger.info('Subfolder %r in %r is not accompanied by devkit configuration files, ignoring', d.name, devkit_game_path)
continue
logger.info('Found installed Devkit Game: %r', gameid)
installed_gameids.add(gameid)
# ask the Steam Client which Devkit Games are registered
with tempfile.TemporaryDirectory(prefix='list-shortcuts') as tempdir:
response = os.path.join(tempdir, 'shortcuts.json')
cmd = 'list-shortcuts?response={}'.format(
urllib_quote_plus(os.path.join(response))
)
# send the request
execute_steam_client_command(cmd)
with wait_on_file_response(response) as response:
client_shortcuts = json.loads(response)
logger.debug(client_shortcuts)
assert client_shortcuts['version'] == 2
registered_gameids = set([])
logger.info('Steam Client has %d registered devkit game(s)', len(client_shortcuts['gameids']))
for gameid in client_shortcuts['gameids']:
logger.info('Found Devkit Game registered with Steam Client: %r', gameid)
registered_gameids.add(gameid)
# any registered game that is not found installed on disk needs to be removed
for remove_gameid in registered_gameids - installed_gameids:
with tempfile.TemporaryDirectory(prefix='delete-shortcut') as tempdir:
logger.info('Removing stale registered Devkit Game: %r', remove_gameid)
response = os.path.join(tempdir, 'shortcut-deleted')
cmd = 'delete-shortcut?response={}&gameid={}'.format(
urllib_quote_plus(response),
remove_gameid
)
execute_steam_client_command(cmd)
with wait_on_file_response(response) as response:
logger.info('from Steam Client: %s', response.strip())
# any installed game that is not found registered needs to be added
for add_gameid in installed_gameids - registered_gameids:
with tempfile.TemporaryDirectory(prefix='create-shortcut') as tempdir:
logger.info('Registering installed Dekit Game: %r', add_gameid)
response = os.path.join(tempdir, 'registered')
cmd = 'create-shortcut?response={}&gameid={}&directory={}'.format(
urllib_quote_plus(response),
add_gameid,
urllib_quote_plus(devkit_game_path)
)
execute_steam_client_command(cmd)
with wait_on_file_response(response) as response:
logger.info('from Steam Client: %s', response.strip())
if __name__ == '__main__':
resolve_shortcuts()
@@ -1,92 +0,0 @@
#!/usr/bin/env python3
import os
import logging
import argparse
import json
import platform
import tempfile
from urllib.parse import quote_plus as urllib_quote_plus
import devkit_utils
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger()
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--parms', required=True, action='store')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
parms = json.loads(conf.parms)
gameid = parms['gameid']
directory = parms['directory']
assert os.path.isdir(directory)
force_appid = parms['force_appid']
steam_appid_path = os.path.join(directory, 'steam_appid.txt')
if force_appid:
with open(steam_appid_path, 'w') as f:
f.write(force_appid + '\n')
logger.info(f'Wrote {steam_appid_path} with AppID {force_appid}')
elif os.path.exists(steam_appid_path):
# don't overwrite an existing steam_appid.txt file from the content tree
# NOTE: if the user sets an AppID through the tool, then delete it, we may leave it in place ..
# (that's ok for now, do a clean upload if you want to get rid of it)
logger.info(f'{steam_appid_path} already exists, leaving it in place')
# Lepton (Android runtime) titles: write UECommandLine.txt next to the .apk
is_lepton = parms['settings']['compat_tool'] == 'lepton'
uecommandline = parms['lepton_args'] if is_lepton else ''
uecommandline_path = os.path.join(directory, 'UECommandLine.txt')
if uecommandline:
with open(uecommandline_path, 'w') as f:
f.write(uecommandline + '\n')
logger.info(f'Wrote {uecommandline_path}')
elif os.path.exists(uecommandline_path):
# don't overwrite an existing UECommandLine.txt file from the content tree
# NOTE: if the user sets cmdline args through the tool, then clears them, we may leave it in place ..
# (that's ok for now, do a clean upload if you want to get rid of it)
logger.info(f'{uecommandline_path} already exists, leaving it in place')
logger.info(f'Updating command line and runtime settings for {gameid} on {platform.node()}')
devkit_utils.save_argv(gameid, parms['argv'])
devkit_utils.save_env(gameid, parms['env'])
devkit_utils.save_settings(gameid, parms)
ret = {}
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
skipping = 'The Steam client is not running. Registration did not complete.'
logger.warning(skipping)
ret['error'] = skipping
else:
with tempfile.TemporaryDirectory(prefix='create-shortcut') as tempdir:
logger.info(f'Registering Devkit Game {gameid} with Steam Client')
response = os.path.join(tempdir, 'registered')
cmd = 'create-shortcut?response={}&gameid={}'.format(
urllib_quote_plus(response),
gameid,
)
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response) as success_response:
logger.debug(success_response)
ret['success'] = success_response
except devkit_utils.SteamResponse_Timeout:
ret['error'] = 'timeout - Steam client did not respond to registration request'
except devkit_utils.SteamResponse_Error as e:
ret['error'] = e.error_response
# response gets written out to stdout
print(json.dumps(ret))
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env python3
import sys
import os
import logging
import argparse
import tempfile
import urllib.parse
import re
import devkit_utils
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('command')
parser.add_argument('args', nargs='*')
conf = parser.parse_args()
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
logger.error(repr(e))
sys.exit(-1)
else:
with tempfile.TemporaryDirectory(prefix='steam-devkit-rpc') as tempdir:
response = os.path.join(tempdir, 'steam-devkit-rpc')
parms = {
'response' : response,
}
for arg in conf.args:
(k, v) = re.split('=', arg)
parms[k] = v
cmd = f'{conf.command}/?{urllib.parse.urlencode(parms)}'
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response) as success_response:
logger.info('success')
sys.stdout.write(success_response)
sys.exit(0)
except devkit_utils.SteamResponse_Timeout:
logger.error('timeout')
except devkit_utils.SteamResponse_Error as e:
logger.error('failed')
sys.stdout.write(e.error_response)
sys.exit(-1)
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env python3
import sys
import os
import shutil
import logging
import argparse
import subprocess
import devkit_utils.resolve
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
def session_select_command():
if shutil.which('holo-session-select'):
return 'holo-session-select'
return 'steamos-session-select'
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--delete-title', required=False, action='store', help='Delete a devkit title by name')
parser.add_argument('--delete-all-titles', required=False, action='store_true', default=False, help='Delete all devkit titles uploaded')
parser.add_argument('--reset-steam-client', required=False, action='store_true', default=False, help='Reset Steam client and delete all local Steam content')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
if conf.delete_all_titles:
subprocess.check_call('rm -rf ~/devkit-game/*', shell=True)
elif conf.delete_title:
gamepath = os.path.expanduser( os.path.join( '~/devkit-game', conf.delete_title ) )
if not os.path.isdir(gamepath):
print(f'Not found: {gamepath}')
else:
subprocess.check_call(f'rm -r {gamepath}', shell=True)
# synchronize the Steam client's view of the devkit games with the on disk state
try:
devkit_utils.resolve.resolve_shortcuts()
except Exception as e:
logger.warning(f'Steam client sync of devkit games failed: {e}')
if conf.reset_steam_client:
# first make sure any sideloaded trampoline has been deleted
devkit_steam_trampoline_path = os.path.join(DEVKIT_TOOL_FOLDER, 'devkit-steam')
if os.path.exists(devkit_steam_trampoline_path):
os.unlink(devkit_steam_trampoline_path)
# wipe the local Steam install
subprocess.check_call(f'rm -rf ~/.local/share/Steam', shell=True)
# restart the session, which will initiate a reinstall of Steam from the OS client
subprocess.check_call([session_select_command(), 'gamescope'])
@@ -1,57 +0,0 @@
#!/usr/bin/env python3
import os
import logging
import argparse
import tempfile
import json
from urllib.parse import quote_plus as urllib_quote_plus
import devkit_utils
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--appid', required=False, action='store')
parser.add_argument('--gameid', required=False, action='store')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
ret = {}
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
skipping = 'The Steam client is not running.'
logger.warning(skipping)
ret['error'] = skipping
else:
with tempfile.TemporaryDirectory(prefix='controller-config') as tempdir:
response = os.path.join(tempdir, 'dumpcontrollerconfig')
cmd = f'dumpcontrollerconfig?response={urllib_quote_plus(response)}'
if conf.appid:
cmd += f'&appid={conf.appid}'
if conf.gameid:
cmd += f'&gameid={conf.gameid}'
logger.debug(f'command: {cmd}')
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response) as success_response:
logger.debug(success_response)
ret['success'] = success_response
except devkit_utils.SteamResponse_Timeout:
ret['error'] = 'timeout - Steam did not respond to the command request'
except devkit_utils.SteamResponse_Error as e:
ret['error'] = e.error_response
# response gets written out to stdout
print(json.dumps(ret))
-443
View File
@@ -1,443 +0,0 @@
#!/usr/bin/env python3
import sys
import os
import re
import shutil
import subprocess
import logging
import enum
import argparse
import json
import shlex
import datetime
import pathlib
import socket
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
STEAM_EXTRA_ARGS_FILE = os.path.expanduser('~/.config/systemd/user/steam.service.d/extra_args.conf')
WIRELESS_DISABLE_POWER_MANAGEMENT = '/usr/bin/steamos-polkit-helpers/steamos-disable-wireless-power-management'
# Must match in gui2.py
class SteamStatus(enum.Enum):
NOT_RUNNING = 0
ERROR = 1
OS = 2
OS_DEV = 3
SIDELOADED = 4
@classmethod
def from_string(cls, status_str):
if not status_str:
return cls.ERROR
try:
if '.' in status_str:
name = status_str.split('.')[-1]
else:
name = status_str
return cls[name]
except KeyError:
return cls.ERROR
@property
def description(self):
DESCRIPTIONS = {
SteamStatus.NOT_RUNNING: 'not running',
SteamStatus.OS: 'OS client',
SteamStatus.OS_DEV: 'OS client dev mode',
SteamStatus.SIDELOADED: 'sideloaded client',
SteamStatus.ERROR: 'error',
}
return DESCRIPTIONS[self]
class SteamConfig(enum.Enum):
ERROR = 1
# Matching the SteamStatus numeric values
OS = 2
OS_DEV = 3
SIDELOADED = 4
@property
def description(self):
DESCRIPTIONS = {
SteamConfig.OS: 'OS client',
SteamConfig.OS_DEV: 'OS client dev mode',
SteamConfig.SIDELOADED: 'sideloaded client',
SteamConfig.ERROR: 'error',
}
return DESCRIPTIONS[self]
SESSION_NAMES = ['gamescope', 'plasma-x11', 'plasma-x11-persistent', 'plasma-wayland', 'plasma-wayland-persistent']
class SessionConfig(enum.IntEnum):
# note: matches SESSION_NAMES indexes
GAMESCOPE = 0
PLASMA_X11 = 1
PLASMA_X11_PERSISTENT = 2
PLASMA_WAYLAND = 3
PLASMA_WAYLAND_PERSISTENT = 4
ERROR = 5
def cef_debugging():
'''Only sane way to check is to look for the listening port.'''
ret = subprocess.run('/usr/bin/ss -l -t -n -p | grep steamwebhelper | grep 8080 > /dev/null', shell=True)
return ( ret.returncode == 0 )
def steam_process_get_path_and_args():
ret = subprocess.run(['pgrep', '-a', '-x', 'steam'], capture_output=True, text=True)
if ret.returncode != 0:
return None
# Proton may run a dummy 'steam' process that confused previous implementations of this logic
# look for a process who's real filename is 'steam'
for l in ret.stdout.splitlines():
try:
pid = int(l.split(' ')[0])
except:
continue
rp = os.path.realpath(f'/proc/{pid}/exe')
if os.path.basename(rp) == 'steam':
try:
with open(f'/proc/{pid}/cmdline', 'rb') as f:
cmdline = f.read()
argv = [a.decode('utf-8', errors='replace') for a in cmdline.split(b'\x00') if a]
if len(argv) >= 2:
path = argv[0]
args = argv[1:]
# strip -srt-logger-opened: injected by steam.sh at runtime
args = [a for a in args if a != '-srt-logger-opened']
return (path, args)
return (argv[0], [])
except Exception as e:
logger.warning(f'Failed to read /proc/{pid}/cmdline: {e}')
return None
def steam_process_get_path():
try:
(path, _) = steam_process_get_path_and_args()
except:
return None
return path
def steam_process_get_args():
try:
(_, args) = steam_process_get_path_and_args()
except:
return ''
return args
def steam_status():
'''What is the status of the Steam client on the system?'''
s = steam_process_get_path()
if s is None:
return SteamStatus.NOT_RUNNING
if s.find('.local/share/Steam/') != -1:
if os.path.exists(os.path.expanduser('~/devkit-game/devkit-steam')):
return SteamStatus.OS_DEV
return SteamStatus.OS
if s.find('devkit-game/steam/') != -1 or s.find('devkit-game/steamdeckard/') != -1:
return SteamStatus.SIDELOADED
logger.warning(f'could not interpret pgrep result to determine steam client status: {s!r}')
return SteamStatus.ERROR
def steam_configuration():
'''How is the Steam client configured to run?'''
devkit_steam_trampoline_path = os.path.join(DEVKIT_TOOL_FOLDER, 'devkit-steam')
if not os.path.exists(devkit_steam_trampoline_path):
return SteamConfig.OS
t = open(devkit_steam_trampoline_path, 'rt').read()
if t.find('SteamStatus.OS_DEV') != -1:
return SteamConfig.OS_DEV
if t.find('SteamStatus.SIDELOADED') != -1:
return SteamConfig.SIDELOADED
logger.warning(f'could not determine what {devkit_steam_trampoline_path} means to do')
return SteamConfig.ERROR
def osclient_branch(is_deckard):
'''Which branch is the default Steam 'OS client' configured to use?'''
# makes more sense to return strings here
beta_path = os.path.expanduser('~/.steam/steam/package/beta')
if not os.path.exists(beta_path):
return 'default' # not sure that's valid actually - would be the desktop client, which will only run in desktop mode ..
t = open(beta_path, 'rt').readline().strip('\n')
try:
p = 'steamdeck_(.*)'
if re.match(p, t):
branch = re.split(p, t)[1]
return branch
# internal builds
p = 'steampal_(.*)_.*'
if re.match(p, t):
branch = re.split(p, t)[1]
return branch
if is_deckard:
p = 'linux_arm64_(.*)_.*'
if re.match(p, t):
branch = re.split(p, t)[1]
return branch
raise Exception('no match')
except: # noqa: E722
logger.warning(f'could not determine the OS client branch config: {t!r}')
return 'error'
def osclient_version(conf):
'''Which version is the Steam 'OS client'?'''
if conf.is_deckard:
# old Steam client was using linuxarm64/, which is now reserved for the SDK binaries
for folder in ('linuxarm64', 'steamrtarm64'):
fn = os.path.expanduser(f'~/.steam/steam/{folder}/builddate.txt')
if os.path.exists(fn):
return open(fn, 'rt').read()
return 'Unknown - no builddate.txt'
beta_path = os.path.expanduser('~/.steam/steam/package/beta')
if not os.path.exists(beta_path):
logger.warning(f'not found: {beta_path}')
return None
t = open(beta_path, 'rt').readline().strip('\n')
manifest = os.path.expanduser(f'~/.steam/steam/package/steam_client_{t}_ubuntu12.manifest')
if not os.path.exists(manifest):
logger.warning(f'not found: {manifest}')
return None
try:
version = int(re.search('"version".*"(.*)"', open(manifest,'rt').read()).group(1))
return version
except:
logger.warning(f'could not parse version out of {manifest}')
return None
def session_config():
'''What is the graphics session configuration?'''
# RESTART_SESSION writes this file
conf_file = '/etc/sddm.conf.d/zz-steamos-autologin.conf'
if not os.path.exists(conf_file):
# fallback to the OS default
conf_file = '/etc/sddm.conf.d/steamos.conf'
if os.path.exists(conf_file):
s = open(conf_file, 'rt').read()
if s.find('plasmawayland.desktop') != -1:
return SessionConfig.PLASMA_WAYLAND_PERSISTENT
if s.find('plasma.desktop') != -1:
return SessionConfig.PLASMA_X11_PERSISTENT
if s.find('gamescope-wayland.desktop') != -1:
return SessionConfig.GAMESCOPE
if s.find('plasma-steamos-oneshot.desktop') != -1:
return SessionConfig.PLASMA_X11
if s.find('plasma-steamos-wayland-oneshot.desktop') != -1:
return SessionConfig.PLASMA_WAYLAND
else:
# if the conf file doesn't exist we are likely in the default config
# check for a running gamescope for sanity
if subprocess.call('pgrep -a -x gamescope', shell=True, stdout=subprocess.DEVNULL) == 0:
return SessionConfig.GAMESCOPE
# couldn't figure it out, halp
return SessionConfig.ERROR
def session_select_command():
if shutil.which('holo-session-select'):
return 'holo-session-select'
return 'steamos-session-select'
def get_os_info():
os_info = {}
try:
for k, v in [ s.split('=') for s in open('/etc/os-release').read().split('\n') if len(s) > 0 ]:
os_info[k] = v.strip('"')
except Exception as e:
logger.error(e)
logger.error('Failed to parse OS release file')
return os_info
def steam_default_args(conf):
if conf.is_deckard:
# Frame currently uses a different setup
return []
try:
if os.path.exists('/usr/lib/steamos/steam-launcher'):
output = subprocess.check_output('cat /usr/lib/steamos/steam-launcher | grep ^steamargs=',
shell=True,
universal_newlines=True)
ret = [ v.strip('"') for v in re.findall('\".*?\"', output) ]
return ret
except:
logger.warning('Failed to obtain steam default arguments from /usr/lib/steamos/steam-launcher')
# Legacy SteamOS
try:
output = subprocess.check_output('cat /usr/bin/gamescope-session | grep ^steamargs',
shell=True,
universal_newlines=True)
ret = [ v.strip('"') for v in re.findall('\".*?\"', output) ]
except:
logger.warning('Failed to obtain steam default arguments from /usr/bin/gamescope-session')
# Hardcoded fallback
return ['-steamos3', '-steampal', '-steamdeck', '-gamepadui']
def frame_osclient_extra_args(conf, steam_status):
if not conf.is_deckard or steam_status != SteamStatus.OS:
return None
if os.path.exists(STEAM_EXTRA_ARGS_FILE):
try:
content = open(STEAM_EXTRA_ARGS_FILE, 'rt').read()
match = re.search(r'Environment="STEAM_EXTRA_ARGS=(.*)"', content)
if match:
return match.group(1).replace('\\"', '"')
except Exception as e:
logger.warning(f'Failed to parse steam extra args: {e}')
return None
def user_password_is_set():
ret = subprocess.run('passwd', stdin=subprocess.DEVNULL, shell=True, capture_output=True, universal_newlines=True)
logger.debug(repr(ret))
return (ret.stderr.find('Current password:') != -1)
def steam_launch_flags():
'''Pull various steam flags that affect title execution.'''
ret = {}
if not 'XDG_RUNTIME_DIR' in os.environ:
logger.warning('XDK_RUNTIME_DIR is not set')
return ret
env_folder = os.path.join(os.environ['XDG_RUNTIME_DIR'], 'steam/env')
if not os.path.isdir(env_folder):
return ret
for fn in os.listdir(env_folder):
filepath = os.path.join(env_folder, fn)
content = open(filepath, 'rt').read()
# Check if this is a declaration file with key=value pairs
if content.count('\n') > 1 or '=' in content:
# Parse key=value format with comments
for line in content.splitlines():
line = line.strip()
# Skip comments and empty lines
if not line or line.startswith('#'):
continue
# Parse key=value pairs
if '=' in line:
key, value = line.split('=', 1)
ret[key.strip()] = value.strip()
else:
# Legacy format: filename is the key, file content is the value
ret[fn] = content.strip('\n')
return ret
def renderdoc_replay_server_running():
ret = subprocess.run(['pgrep', '-x', 'renderdoccmd'], capture_output=True)
return ret.returncode == 0
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--json', required=False, action='store_true')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
os_info = get_os_info()
assert os_info is not None
conf.is_deckard = os_info.get('VARIANT_ID', None) == 'vr'
os_name = os_info.get('PRETTY_NAME', None)
os_version = os_info.get('BUILD_ID', None)
_steam_launch_flags = steam_launch_flags()
if not conf.is_deckard:
# this bit of cargo cult is Steam Deck only
try:
# disable wireless power management for devkit usage: less latency on commands
subprocess.check_call(WIRELESS_DISABLE_POWER_MANAGEMENT)
except subprocess.CalledProcessError as e:
logger.warning(e)
session_config = session_config()
# enum -> human readable
session_status = SESSION_NAMES[session_config] if session_config != SessionConfig.ERROR else 'error'
steam_status = steam_status()
cef_debugging_enabled = False
if steam_status != SteamStatus.NOT_RUNNING:
cef_debugging_enabled = cef_debugging()
steam_configuration = steam_configuration()
osclient_branch = osclient_branch(conf.is_deckard)
osclient_version = osclient_version(conf)
steam_status_description = steam_status.description
if steam_status in (SteamStatus.OS, SteamStatus.OS_DEV) :
steam_status_description += f', on branch {osclient_branch!r}'
if osclient_version is not None:
if conf.is_deckard:
# we get builddate.txt
steam_status_description += f', {osclient_version}'
else:
utc_date_string = datetime.datetime.fromtimestamp(osclient_version, datetime.UTC).isoformat()
steam_status_description += f', version {osclient_version} {utc_date_string}'
has_side_loaded_client = os.path.exists(
os.path.join(
DEVKIT_TOOL_FOLDER,
'steam'
)
)
_user_password_is_set = user_password_is_set()
_renderdoc_replay_server_running = renderdoc_replay_server_running()
_renderdoc_layer_enabled = _steam_launch_flags.get('ENABLE_VULKAN_RENDERDOC_CAPTURE', '0') == '1'
_hostname = socket.gethostname()
if conf.json:
ret = {
'is_deckard': conf.is_deckard,
'hostname': _hostname,
'os_name': os_name,
'os_version': os_version,
'os_info': os_info,
'session_status': session_status,
'session_options': SESSION_NAMES,
'session_select': session_select_command(),
'steam_status': str(steam_status),
'cef_debugging_enabled': cef_debugging_enabled,
'steam_status_description': steam_status_description,
'steam_configuration': str(steam_configuration),
'steam_osclient_branch': osclient_branch,
'steam_osclient_version': osclient_version,
'has_side_loaded_client': has_side_loaded_client,
'steam_default_args': steam_default_args(conf),
'steam_current_args': steam_process_get_args(),
'frame_osclient_extra_args': frame_osclient_extra_args(conf, steam_status),
'user_password_is_set': _user_password_is_set,
'steam_launch_flags': _steam_launch_flags,
'renderdoc_layer_enabled': _renderdoc_layer_enabled,
'renderdoc_replay_server_running': _renderdoc_replay_server_running,
}
json.dump(ret, sys.stdout, sort_keys=True, indent=4)
else:
logger.info(f'Hostname : {_hostname}')
logger.info(f'OS : {os_name}')
logger.info(f'OS version : {os_version}')
logger.info(f'Session mode is : {session_status}')
logger.info(f'Session select command : {session_select_command()}')
logger.info(f'Steam client status : {steam_status_description}')
logger.info(f'Steam client args : {steam_process_get_args()!r}')
logger.info(f"Steam extra args (Frame) : {frame_osclient_extra_args(conf, steam_status)!r}")
logger.info(f"Steam CEF debug : {'enabled' if cef_debugging_enabled else 'disabled'}")
logger.info(f'Steam client config : {steam_configuration.description}')
logger.info(f'Steam OS client branch : {osclient_branch}')
logger.info(f'Steam OS client version : {osclient_version}')
logger.info(f"Sideloaded client : {'available' if has_side_loaded_client else 'not installed'}")
logger.info(f'OS client arguments : {steam_default_args(conf)!r}')
logger.info(f"User password is set : {'yes' if _user_password_is_set else 'no'}")
logger.info(f"Steam launch flags : {_steam_launch_flags}")
logger.info(f"RenderDoc layer enabled : {'yes' if _renderdoc_layer_enabled else 'no'}")
logger.info(f"RenderDoc replay running : {'yes' if _renderdoc_replay_server_running else 'no'}")
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env python3
import os
import logging
import argparse
import getpass
import json
from subprocess import DEVNULL
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
ret = []
if os.path.isdir(DEVKIT_TOOL_FOLDER):
for filename in os.listdir(DEVKIT_TOOL_FOLDER):
gamefolder = os.path.join(DEVKIT_TOOL_FOLDER, filename)
if os.path.isdir(gamefolder):
ret.append( {
'gameid': filename,
} )
print(json.dumps(ret))
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env python3
import sys
import os
import logging
import argparse
import getpass
import json
import shutil
import subprocess
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--gameid', required=True, action='store')
parser.add_argument('--restart-steam', required=False, default='0', action='store')
parser.add_argument('--use-mask-unmask', required=False, default='0', action='store')
parser.add_argument('--prevent-auto-repair', required=False, default='0', action='store')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
directory = os.path.join(
os.path.expanduser(DEVKIT_TOOL_FOLDER),
conf.gameid
)
os.makedirs(directory, exist_ok=True)
INHIBIT_SENTINEL = os.path.expanduser('~/.config/inhibit-short-session-tracker')
if int(conf.prevent_auto_repair) == 1:
open(INHIBIT_SENTINEL, 'w').close()
logger.info(f'Created sentinel file: {INHIBIT_SENTINEL}')
elif os.path.exists(INHIBIT_SENTINEL):
os.remove(INHIBIT_SENTINEL)
logger.info(f'Removed sentinel file: {INHIBIT_SENTINEL}')
ret = {
'user': getpass.getuser(),
'directory': directory,
}
print(json.dumps(ret))
@@ -1,7 +0,0 @@
#!/bin/bash
# meant to be executed remotely/interactively for password prompts
# there's some annoying trash at the top of the remote ssh screen
clear
passwd
sleep 2
-157
View File
@@ -1,157 +0,0 @@
#!/usr/bin/env python3
import sys
import os
import logging
import argparse
import enum
import subprocess
import shutil
import pathlib
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
# NOTE: only relevant to Frame + OS client with extra arguments
# sideloaded client on Frame supports full command line edit instead
STEAM_EXTRA_ARGS_FILE = os.path.expanduser('~/.config/systemd/user/steam.service.d/extra_args.conf')
class SteamStatus(enum.Enum):
# Supported values from steamos-get-status
OS = 2
OS_DEV = 3
SIDELOADED = 4
STATUS_STRINGS = [
( SteamStatus.OS, 'SteamStatus.OS' ),
( SteamStatus.OS_DEV, 'SteamStatus.OS_DEV' ),
( SteamStatus.SIDELOADED, 'SteamStatus.SIDELOADED' ),
]
# gamescope-session passes the execution to this script if it exists rather than start steam itself
DEVKIT_STEAM_TRAMPOLINE = os.path.expanduser('~/devkit-game/devkit-steam')
# this script executes the sideloaded Steam client (part of the steamos-devkit-service package)
SIDE_LOADED_STEAM_CLIENT = '/usr/share/steamos-devkit/bin/devkit-standalone.py'
def write_trampoline(text):
with open(DEVKIT_STEAM_TRAMPOLINE, 'w') as devkit_steam:
devkit_steam.write(text)
devkit_steam.flush()
os.chmod(DEVKIT_STEAM_TRAMPOLINE, 0o770)
# trying really hard to avoid leaving a zero sized trampoline if the deck is about to hang on the session restart coming next
subprocess.run(['/usr/bin/sync', DEVKIT_TOOL_FOLDER])
def get_os_info():
os_info = {}
try:
for k, v in [ s.split('=') for s in open('/etc/os-release').read().split('\n') if len(s) > 0 ]:
os_info[k] = v.strip('"')
except Exception as e:
logger.error(e)
logger.error('Failed to parse OS release file')
return os_info
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--client', action='store', required=True, choices=[ v[1] for v in STATUS_STRINGS ])
parser.add_argument('--args', action='store', required=False, help='steam client command line arguments')
parser.add_argument('--gameid', required=True, action='store')
parser.add_argument('--gdbserver', action='store_true', required=False)
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
target = [ v for v in STATUS_STRINGS if v[1] == conf.client ][0][0]
logging.info(f'Set steam client on device to {target}')
if os.path.exists(DEVKIT_STEAM_TRAMPOLINE):
os.unlink(DEVKIT_STEAM_TRAMPOLINE)
os_info = get_os_info()
assert os_info is not None
is_deckard = os_info.get('VARIANT_ID', None) == 'vr'
if target == SteamStatus.OS:
if is_deckard:
# conf.args is the extra arguments for the normal Steam 'OS client', update it now
if conf.args is None or conf.args == '':
logger.info('Clearning extra arguments for normal Steam client')
if os.path.exists(STEAM_EXTRA_ARGS_FILE):
os.unlink(STEAM_EXTRA_ARGS_FILE)
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
else:
logger.info(f'Setting extra arguments for normal Steam client: {conf.args}')
os.makedirs(os.path.dirname(STEAM_EXTRA_ARGS_FILE), exist_ok=True)
with open(STEAM_EXTRA_ARGS_FILE, 'wt') as extra_args_file:
escaped_args = conf.args.replace('"', '\\"')
extra_args_file.write(f'[Service]\nEnvironment="STEAM_EXTRA_ARGS={escaped_args}"')
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
# When disabling a sideloaded client, also delete the ~/.steam symlinks:
# They will be re-created by the OS client when starting,
# this prevents SteamVR trying to use the sideloaded binaries that are still there for the steam API.
# (this may happen because SteamVR starts before Steam starts and has a chance to set those symlinks correctly)
for path in pathlib.Path(os.path.expanduser('~/.steam')).glob('*'):
if path.is_symlink():
try:
lnk = path.resolve()
if 'devkit-game' in str(lnk):
path.unlink()
print(f'Deleted: {path} -> {lnk}')
except Exception as e:
print(f'Error processing {path}: {e}')
logger.info('Devkit Steam client override is disabled - default OS client execution will resume.')
sys.exit(0)
os.makedirs(os.path.dirname(DEVKIT_STEAM_TRAMPOLINE), exist_ok=True)
# OS client
steam_client = '$HOME/.local/share/Steam/steam.sh'
if target == SteamStatus.SIDELOADED:
steam_client = '$HOME/devkit-game/steam/steam.sh'
if is_deckard:
# RUNSTEAM.sh checks for SIDELOADED_STEAMROOT="${HOME}/devkit-game/steam"
# this is consistent with sideload on Steam Deck, but we use a different name 'steamdeckard'
# will be addressed when reworking the sideload and debug strategy, for now just drop in a symlink
steam_symlink = os.path.expanduser('~/devkit-game/steam')
if os.path.lexists(steam_symlink):
if os.path.islink(steam_symlink):
os.unlink(steam_symlink)
else:
# this happens if an upload in Steam Deck mode was attempted against a Steam Frame for instance
# was an easy mistake to make before recent changes
logger.warning('warning: ~/devkit-game/steam exists but is not a symlink. Removing anyway.')
shutil.rmtree(steam_symlink)
os.symlink(
os.path.expanduser('~/devkit-game/steamdeckard'),
steam_symlink,
)
args = '"$@"'
if conf.args is not None:
args = conf.args
gdbserver = ''
if conf.gdbserver:
logger.info('Configuring for remote debugging via gdbserver')
gdbserver = 'export DEBUGGER="gdbserver 0.0.0.0:2345"'
write_trampoline('''#!/bin/bash
# Generated by steamos-set-steam-client, do not edit!
# configuration tag (do not delete): {}
{}
mkdir -p $HOME/.steam/steam/logs
exec {} {}
'''.format(
conf.client,
gdbserver,
steam_client,
args
))
-41
View File
@@ -1,41 +0,0 @@
#!/bin/bash
# Frame-side: run T3 Code in its own Lepton (Android) instance whose data
# survives restarts. Lepton Development wipes its apps when it exits; a
# "steamlaunch" context (SteamAppId set) keeps them.
#
# Lives in ~/Applications/T3Code next to t3code.apk and the empty
# lepton-show-flatscreen marker (without it Lepton runs the app headless).
#
# App data (T3's pairing) lives in compatdata/<id>/internal and survives
# everything. Lepton rebuilds its Android system snapshot (compatdata/<id>/baked)
# when the APK changes or the app exits within 30 seconds of starting.
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
LEPTON="$HOME/.local/share/Steam/steamapps/common/Lepton/lepton"
# Without the marker Lepton runs T3 headless: Steam says "running" but no panel
# appears. Fail loudly instead.
for need in "$DIR/t3code.apk" "$DIR/lepton-show-flatscreen"; do
[[ -f "$need" ]] || { echo "launch.sh: missing $need" >&2; exit 1; }
done
[[ -x "$LEPTON" ]] || { echo "launch.sh: Lepton not installed at $LEPTON" >&2; exit 1; }
# Any fixed number that isn't a real Steam app: it names the Lepton context.
export SteamAppId=2873873873
export STEAM_COMPAT_INSTALL_PATH="$DIR"
# Must sit under ~/.local/share/Steam: Lepton symlinks /data/data/<app> and
# /data/media/0 to host paths here, and only the Steam dir is mounted inside.
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
# A Steam shortcut launch sets STEAM_FOSSILIZE_DUMP_PATH but not the shader
# path Lepton derives it from (unbound under `set -u`), so set both.
export STEAM_COMPAT_SHADER_PATH="$HOME/.local/share/Steam/steamapps/shadercache/$SteamAppId"
export STEAM_FOSSILIZE_DUMP_PATH="$STEAM_COMPAT_SHADER_PATH/fozpipelinesv6/steamapp_pipeline_cache"
mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
# Lepton re-execs itself through `setpgid --foreground`, which needs a
# controlling terminal that Steam shortcuts and systemd don't have. Skip that
# step and give Lepton its own session instead: its teardown kills its whole
# process group. --wait keeps this script alive so Steam sees the app running.
export IS_PARENT=true
exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/t3code.apk"
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: refresh the Android app catalogue that Frame Control shows
# (apk-catalog/). Fetches the latest F-Droid index, scans new or updated APKs
# with HTTP range requests, and rebuilds apk-catalog/site/apps.js.
# Frame Control picks up the new data on its next page load.
#
# Usage: scripts/apk-catalog.sh (the first full scan takes about an hour;
# later runs only scan what changed)
set -euo pipefail
CAT="${0:A:h}/../apk-catalog"
[[ "${1:-}" == -h || "${1:-}" == --help ]] && { sed -n '2,8p' "$0"; exit 0; }
print "==> Fetching the F-Droid index"
curl -fL --progress-bar -o "$CAT/data/index-v2.json.part" https://f-droid.org/repo/index-v2.json
mv "$CAT/data/index-v2.json.part" "$CAT/data/index-v2.json"
print "==> Scanning new or updated APKs"
WORKERS=40 python3 "$CAT/scan.py"
WORKERS=40 python3 "$CAT/scan2.py"
print "==> Rebuilding"
python3 "$CAT/build.py"
-30
View File
@@ -1,30 +0,0 @@
#!/bin/bash
# Runs ON the Steam Frame (fallback path only; normally Developer Mode's
# toggle + "Set User Password" is enough and this is not needed).
# Served by scripts/serve-bootstrap.sh, which substitutes the public key.
#
# UNTESTED against real hardware. Idempotent.
set -eu
KEY='__PUBKEY__'
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"
touch "$HOME/.ssh/authorized_keys"
chmod 600 "$HOME/.ssh/authorized_keys"
if grep -qxF "$KEY" "$HOME/.ssh/authorized_keys"; then
echo "key already present"
else
echo "$KEY" >> "$HOME/.ssh/authorized_keys"
echo "key added"
fi
echo "Enabling sshd. If sudo asks for a password you never set, press Ctrl-C,"
echo "set one in Steam Settings > Developer > Set User Password (or run: passwd),"
echo "then re-run the same one-liner."
sudo systemctl enable --now sshd
echo
echo "sshd: $(systemctl is-active sshd) user: $(id -un) host: $(hostname)"
ip -4 -brief addr show scope global 2>/dev/null || true
echo "Now on the Mac: scripts/connect.sh"
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: back up Frame Control's compatibility database (the private
# Lakebed capsule at https://frame-compat.lakebed.app).
#
# Exports every report through the app's own key (ui/frame_compat_db.py), keeps
# dated copies in ~/Library/Application Support/Frame Control/compat-db/backups (newest
# 60), and uploads to the Google Drive folder DRIVE_FOLDER_ID when the data
# changed since the last upload. Maintainer-only: it needs the database key.
# Run it daily from a LaunchAgent (see compat-db/README.md).
#
# Usage: scripts/compat-db-backup.sh [--no-upload] [--force-upload] [--accept-shrink]
# Env: DRIVE_FOLDER_ID, GOG_WRAPPER
set -euo pipefail
ROOT="${0:A:h}/.."
DEST="$HOME/Library/Application Support/Frame Control/compat-db/backups"
DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-}
GOG_WRAPPER=${GOG_WRAPPER:-$(command -v gog || true)}
upload=1 force=0 accept_shrink=0
for arg in "$@"; do
case "$arg" in
--no-upload) upload=0 ;;
--force-upload) force=1 ;;
--accept-shrink) accept_shrink=1 ;;
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
*) print -u2 "unknown option $arg"; exit 2 ;;
esac
done
mkdir -p "$DEST"
stamp=$(date -u +%Y%m%dT%H%M%SZ)
out="$DEST/frame-compat-$stamp.json"
python3 "$ROOT/ui/frame_compat_db.py" export "$out"
# A backup that lost data is worse than none: refuse to shrink. Compare with the
# last backup that passed this check (.last-good), never with a refused one, so a
# loss keeps failing every day until someone looks and passes --accept-shrink.
count=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["count"])' "$out")
good="$DEST/.last-good"
if [[ -f "$good" ]]; then
read -r good_count good_file < "$good"
if (( count < good_count )) && (( ! accept_shrink )); then
mv "$out" "$DEST/refused-${out:t}"
print -u2 "!! export has $count reports; the last good backup ($good_file) had $good_count."
print -u2 "!! Not uploading. Kept it as refused-${out:t}. If the loss is expected, rerun with --accept-shrink."
exit 1
fi
fi
print -r -- "$count ${out:t}" > "$good"
# Only the reports decide whether anything changed (not the export timestamp).
digest=$(python3 -c 'import json,sys,hashlib; r=json.load(open(sys.argv[1]))["reports"]; print(hashlib.sha256(json.dumps(sorted(r, key=lambda x: x["id"]), sort_keys=True).encode()).hexdigest())' "$out")
shasum -a 256 "$out" > "$out.sha256"
ls -1t "$DEST"/frame-compat-*.json | tail -n +61 | while read -r old; do rm -f "$old" "$old.sha256"; done
print "==> $count reports backed up to $out"
if (( upload )); then
last="$DEST/.last-uploaded-digest"
if (( ! force )) && [[ -f "$last" && "$(cat "$last")" == "$digest" ]]; then
print "==> Unchanged since the last Drive upload; skipped"
exit 0
fi
[[ -n "$DRIVE_FOLDER_ID" ]] || { print -u2 "Set DRIVE_FOLDER_ID, or pass --no-upload"; exit 1; }
[[ -n "$GOG_WRAPPER" && -x "$GOG_WRAPPER" ]] || { print -u2 "gog not found; install it or set GOG_WRAPPER"; exit 1; }
"$GOG_WRAPPER" drive upload "$out" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
"$GOG_WRAPPER" drive upload "$out.sha256" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
print -r -- "$digest" > "$last"
print "==> Uploaded to Google Drive"
fi
-273
View File
@@ -1,273 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: find the Steam Frame, create keys, add a `Host frame` alias to
# ~/.ssh/config, get a key onto the headset, and optionally disable SSH password
# logins. It first pairs through Valve's SteamOS devkit service (port 32000:
# approve on the headset, no password), else copies the key with the password.
#
# Verified on a Frame 2026-09-25 (except --harden and devkit pairing). Idempotent.
#
# Usage:
# scripts/connect.sh [HOST_OR_IP] # set up key + alias
# scripts/connect.sh [HOST_OR_IP] --harden # also disable password auth
#
# Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame).
set -euo pipefail
user_from_env=${+FRAME_USER}
FRAME_USER=${FRAME_USER:-steamos}
FRAME_ALIAS=${FRAME_ALIAS:-frame}
KEY="$HOME/.ssh/id_ed25519_frame"
# The devkit service only accepts ssh-rsa keys, so pairing uses a second key.
DEVKIT_KEY="$HOME/.ssh/id_rsa_frame_devkit"
CONFIG="$HOME/.ssh/config"
BEGIN_MARK="# >>> steam-frame ($FRAME_ALIAS) >>>"
END_MARK="# <<< steam-frame ($FRAME_ALIAS) <<<"
DEVKIT_PORT=32000
DEVKIT_SERVICE=_steamos-devkit._tcp
MAGIC_PHRASE=900b919520e4cf601998a71eec318fec # fixed token Valve's client appends
NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'
HOST_RE='^[A-Za-z0-9][A-Za-z0-9.:%-]*$'
harden=0
host_arg=""
for arg in "$@"; do
case "$arg" in
--harden) harden=1 ;;
-h|--help) sed -n '2,13p' "$0"; exit 0 ;;
*) host_arg="$arg" ;;
esac
done
port_open() {
# nc resolves through the system resolver (including mDNS for .local).
nc -z -G 3 "$1" "$2" >/dev/null 2>&1
}
# sshd, or the devkit service, which turns sshd on once a pairing is approved.
reachable() {
port_open "$1" 22 || port_open "$1" $DEVKIT_PORT
}
# What a command printed within $1 seconds; dns-sd never exits by itself.
run_for() {
local secs=$1; shift
"$@" 2>/dev/null &
local pid=$!
sleep "$secs"
kill $pid 2>/dev/null || true
wait $pid 2>/dev/null || true
}
# Hosts advertising the devkit service over mDNS (dns-sd -B, then -L each).
discover_devkit() {
local name target
run_for 3 dns-sd -B $DEVKIT_SERVICE local. \
| sed -n "s/.* Add .*${DEVKIT_SERVICE//./\\.}\\.[[:space:]]*//p" | awk '!seen[$0]++' | head -n 4 \
| while IFS= read -r name; do
target=$(run_for 2 dns-sd -L "$name" $DEVKIT_SERVICE local. \
| sed -n 's/.* can be reached at \([^ :]*\):[0-9].*/\1/p' | head -n 1)
[[ -n "$target" ]] && print -r -- "${target%.}"
done | awk '!seen[$0]++'
}
pick_host() {
local candidates=()
[[ -n "$host_arg" ]] && candidates+=("$host_arg")
[[ -z "$host_arg" ]] && candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
local h
for h in "${candidates[@]}"; do
if reachable "$h"; then
print -r -- "$h"; return 0
fi
print -u2 " - $h: not resolvable, or ports 22 and $DEVKIT_PORT closed"
done
[[ -n "$host_arg" ]] && return 1
print -u2 " - asking mDNS for $DEVKIT_SERVICE"
for h in ${(f)"$(discover_devkit)"}; do
if [[ "$h" =~ $HOST_RE ]] && reachable "$h"; then
print -r -- "$h"; return 0
fi
print -u2 " - $h: advertised, but not reachable"
done
return 1
}
make_key() { # path type comment [extra ssh-keygen args]
if [[ ! -f "$1" ]]; then
ssh-keygen -q -t "$2" "${@:4}" -N '' -C "$3" -f "$1"
print " created $1"
else
print " exists: $1"
fi
}
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
write_config() {
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
local tmp
tmp=$(mktemp) || return 1
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
# value it sees per option, so this block must precede any other "Host frame"
# or "Host *". The trailing "Host *" returns the rest of the file to global scope.
awk -v b="$BEGIN_MARK" -v e="$END_MARK" '
$0==b {skip=1; next}
$0==e {skip=0; next}
!skip {print}
' "$CONFIG" > "$tmp" || { rm -f "$tmp"; return 1; }
{
print -r -- "$BEGIN_MARK"
print -r -- "Host $FRAME_ALIAS"
print -r -- " HostName $HOST"
print -r -- " User $FRAME_USER"
print -r -- " IdentityFile $KEY"
print -r -- " IdentityFile $DEVKIT_KEY"
print -r -- " IdentitiesOnly yes"
print -r -- " ServerAliveInterval 30"
print -r -- "Host *"
print -r -- "$END_MARK"
cat "$tmp"
} > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
rm -f "$tmp"
}
# accept-new: after pairing, this is the first contact, so trust a first-seen host
# key (as ssh-copy-id's prompt would); a changed one still fails.
key_login_works() {
ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new "$FRAME_ALIAS" true 2>/dev/null
}
# The User in our managed block, so a re-run keeps one the headset named earlier.
configured_user() {
[[ -f "$CONFIG" ]] || return 0
awk -v b="$BEGIN_MARK" -v e="$END_MARK" '
$0==b {inside=1; next}
$0==e {exit}
inside && $1=="User" {print $2; exit}
' "$CONFIG"
}
devkit_url() {
if [[ "$HOST" == *:* ]]; then print -r -- "http://[$HOST]:$DEVKIT_PORT$1"
else print -r -- "http://$HOST:$DEVKIT_PORT$1"; fi
}
# Valve's steamos-devkit-service: GET /properties.json names the login user; POST
# /register with "ssh-rsa <key> <comment> <magic>" shows an approve prompt in the
# headset (the comment is what it displays, 30 s to answer), then installs the key
# and turns sshd on. Returns non-zero with the reason in $devkit_why to fall back.
devkit_why=""
pair_with_devkit() {
local props login comment body resp code text err
print "==> Pairing through the headset's SteamOS devkit service (no password)"
if [[ ! -r "$DEVKIT_KEY.pub" ]]; then
devkit_why="can't read the pairing key $DEVKIT_KEY.pub"; return 1
fi
if ! props=$(curl -fsS --noproxy '*' -m 5 "$(devkit_url /properties.json)" 2>&1); then
devkit_why="devkit service not reachable on port $DEVKIT_PORT: ${${props##*curl: }%%$'\n'*}"; return 1
fi
# properties.json is Valve's json.dumps(indent=2): "login" sits on its own line.
login=$(print -r -- "$props" | sed -n 's/.*"login"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
[[ "$login" =~ $NAME_RE && "$login" != root ]] || login=""
# Before the prompt, so the password fallback uses this user too.
if [[ -n "$login" && "$login" != "$FRAME_USER" ]]; then
if (( user_from_env )); then
print " the headset logs in as '$login'; keeping FRAME_USER=$FRAME_USER"
else
FRAME_USER=$login
print " the headset logs in as '$FRAME_USER'"
write_config || { print -u2 "Could not rewrite $CONFIG."; exit 1; }
fi
fi
# One word: the headset splits the body on spaces and shows the third field.
comment="frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')"
[[ "$comment" == "frame-control@" ]] && comment="frame-control@computer"
body="ssh-rsa $(awk '{print $2}' "$DEVKIT_KEY.pub") $comment $MAGIC_PHRASE"
print " In the headset: Steam Settings > Developer > Pair new host, then approve the request"
# The headset refuses at once unless Steam is on its "Pair new host" screen
# (verified on a Frame, 2026-09-26), so keep asking for 2 minutes while it's opened.
local deadline=$(( SECONDS + 120 ))
while true; do
if ! resp=$(print -r -- "$body" | curl -sS --noproxy '*' -m 60 -H 'Content-Type: text/plain' \
--data-binary @- -w '\n%{http_code}' "$(devkit_url /register)" 2>&1); then
devkit_why="devkit pairing failed: no answer (${${resp##*curl: }%%$'\n'*})"; return 1
fi
code=${resp##*$'\n'}
text=${resp%$'\n'*}
[[ "$code" == 2* ]] && break
err=$(print -r -- "$text" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
devkit_why="devkit pairing failed: ${err:-${text:-HTTP $code}}"
[[ "$devkit_why" == *"pairing mode"* ]] && (( SECONDS < deadline )) || return 1
sleep 3
done
# The approval is what turns sshd on, so it may take a moment to answer.
local i
for i in {1..10}; do
key_login_works && return 0
sleep 1
done
devkit_why="paired, but key login still fails"; return 1
}
print "==> Looking for the Steam Frame"
if ! HOST=$(pick_host); then
print -u2 "Could not reach the Frame on port 22 or $DEVKIT_PORT."
print -u2 "Check: Developer Mode on + user password set; same Wi-Fi; no client isolation."
print -u2 "Then re-run with the IP from Quick Settings: scripts/connect.sh 192.168.x.y"
exit 1
fi
print " found: $HOST"
print "==> SSH keys"
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
make_key "$KEY" ed25519 "mac->steam-frame"
make_key "$DEVKIT_KEY" rsa "frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')" -b 3072
if (( ! user_from_env )); then
prev_user=$(configured_user)
if [[ "$prev_user" =~ $NAME_RE ]]; then FRAME_USER=$prev_user; fi
fi
print "==> ~/.ssh/config alias '$FRAME_ALIAS' -> $HOST"
write_config
print "==> Checking key login"
if key_login_works; then
print " key login already works"
elif pair_with_devkit; then
print " paired; key login OK"
else
print " $devkit_why; falling back to the password"
print " copying key (enter the Developer Mode password once)"
ssh-copy-id -i "$KEY.pub" -o IdentitiesOnly=yes "$FRAME_USER@$HOST"
key_login_works || { print -u2 "Key login still failing after ssh-copy-id."; exit 1; }
print " key login OK"
fi
if (( harden )); then
print "==> Disabling SSH password auth (sudo password asked on the Frame)"
# shellcheck disable=SC2016
if ! ssh -t "$FRAME_ALIAS" '
set -e
grep -Eiq "^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf" /etc/ssh/sshd_config \
|| { echo "sshd_config has no sshd_config.d include; not hardening."; exit 1; }
printf "PasswordAuthentication no\nKbdInteractiveAuthentication no\n" \
| { sudo mkdir -p /etc/ssh/sshd_config.d; sudo tee /etc/ssh/sshd_config.d/01-frame-keys-only.conf >/dev/null; }
sudo sshd -t
sudo systemctl reload sshd
echo "password auth disabled"
'; then
print -u2 "!! Hardening failed. If the drop-in was written, it will disable password SSH"
print -u2 "!! on the next sshd restart. To undo it:"
print -u2 "!! ssh $FRAME_ALIAS 'sudo rm -f /etc/ssh/sshd_config.d/01-frame-keys-only.conf'"
exit 1
fi
if ssh -o BatchMode=yes -o ConnectTimeout=5 "$FRAME_ALIAS" true; then
print " key login still OK after hardening"
else
print -u2 "!! Key login FAILED after hardening. Password SSH is now off."
print -u2 "!! Recover via RDP or 'adb shell' (USB-C), then run:"
print -u2 "!! sudo rm /etc/ssh/sshd_config.d/01-frame-keys-only.conf && sudo systemctl reload sshd"
exit 1
fi
fi
print "\nDone. Try: ssh $FRAME_ALIAS"
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: start Frame Control (ui/server.py) and open it in its own window.
#
# Needs scripts/connect.sh to have been run once. Ctrl-C stops the server.
#
# Usage: scripts/frame-ui.sh [--no-open]
# Env: PORT (default 47810), FRAME_ALIAS (default frame).
set -euo pipefail
PORT=${PORT:-47810}
here=${0:A:h}
url="http://127.0.0.1:$PORT/"
open_window=1
[[ "${1:-}" == "--no-open" ]] && open_window=0
[[ "${1:-}" == -h || "${1:-}" == --help ]] && { sed -n '2,8p' "$0"; exit 0; }
show() {
(( open_window )) || return 0
# A Chrome app window looks like a native app; fall back to the default browser.
if [[ -d "/Applications/Google Chrome.app" ]]; then
open -na "Google Chrome" --args --app="$url" --window-size=1400,950
else
open "$url"
fi
}
# The Server header tells our server apart from anything else on the port.
ours() { curl -fsS -D - -o /dev/null "$url" 2>/dev/null | grep -qi '^server: FrameControl'; }
if ours; then
print "Frame Control is already running at $url"
show
exit 0
fi
python3 "$here/../ui/server.py" --port "$PORT" &
server=$!
trap 'kill $server 2>/dev/null' EXIT INT TERM
for i in {1..50}; do
ours && break
kill -0 $server 2>/dev/null || { print -u2 "Server exited (port $PORT in use? try PORT=... $0)"; exit 1; }
(( i == 50 )) && { print -u2 "Server didn't start"; exit 1; }
sleep 0.1
done
show
wait $server
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: install APKs on the Frame.
#
# Default: each APK becomes its own app, in its own persistent Lepton
# instance with a Steam library shortcut (ui/frame_android.py). Nothing is
# lost when it closes.
#
# --dev: the old way, ADB into Lepton Development over an SSH tunnel. Apps
# installed like this are deleted when Lepton Development exits.
#
# Verified on a Frame 2026-09-25 (--split untested).
#
# Usage:
# scripts/install-apk.sh APP.apk [APP2.apk ...] # own instance each
# scripts/install-apk.sh --dev APP.apk [APP2.apk ...] # into Lepton Development
# scripts/install-apk.sh --dev --split BASE.apk SPLIT.apk ...
#
# Env: FRAME_ALIAS (default frame), LOCAL_PORT (default: first free port from 15555).
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
LEPTON_APPID=3056000
if [[ -z "${LOCAL_PORT:-}" ]]; then
for LOCAL_PORT in {15555..15575}; do
lsof -nP -iTCP:$LOCAL_PORT -sTCP:LISTEN >/dev/null 2>&1 || break
done
fi
SERIAL="127.0.0.1:$LOCAL_PORT"
CTL="${TMPDIR:-/tmp}/frame-adb-$$.sock"
split=0
dev=0
apks=()
for arg in "$@"; do
case "$arg" in
--split) split=1 ;;
--dev) dev=1 ;;
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
*) apks+=("$arg") ;;
esac
done
(( ${#apks} )) || { sed -n '14,16p' "$0" >&2; exit 2; }
if (( ! dev )); then
(( split )) && { print -u2 "--split needs --dev for now"; exit 2; }
for apk in "${apks[@]}"; do
print "==> Installing $apk as its own app"
FRAME_ALIAS=$FRAME_ALIAS python3 "${0:A:h}/../ui/frame_android.py" install "$apk"
done
exit 0
fi
command -v adb >/dev/null || { print -u2 "adb missing: brew install android-platform-tools"; exit 1; }
for apk in "${apks[@]}"; do
[[ -f "$apk" ]] || { print -u2 "not a file: $apk"; exit 1; }
# The Frame is ARM64: native code must include lib/arm64-v8a/.
libs=$(unzip -Z1 "$apk" 2>/dev/null | grep -E '^lib/[^/]+/' | cut -d/ -f2 | sort -u || true)
if [[ -n "$libs" && "$libs" != *arm64-v8a* ]]; then
print -u2 "!! $apk has native code for ${(j:, :)${(f)libs}} only; the Frame needs arm64-v8a"
exit 1
fi
done
lepton_listening() { ssh "$FRAME_ALIAS" 'ss -ltn | grep -q ":5555 "'; }
if ! lepton_listening; then
print "==> Starting Lepton Development on the Frame"
ssh "$FRAME_ALIAS" "steam steam://rungameid/$LEPTON_APPID >/dev/null 2>&1"
for i in {1..30}; do
lepton_listening && break
(( i == 30 )) && { print -u2 "Lepton didn't open port 5555 within 60s. Is Lepton Development installed?"; exit 1; }
sleep 2
done
fi
print "==> Tunnelling ADB over SSH (localhost:$LOCAL_PORT -> $FRAME_ALIAS:5555)"
ssh -f -N -M -S "$CTL" -o ExitOnForwardFailure=yes \
-L "127.0.0.1:$LOCAL_PORT:127.0.0.1:5555" "$FRAME_ALIAS"
cleanup() {
adb disconnect "$SERIAL" >/dev/null 2>&1 || true
ssh -S "$CTL" -O exit "$FRAME_ALIAS" >/dev/null 2>&1 || true
}
trap cleanup EXIT
adb connect "$SERIAL" | grep -q "connected to" || { print -u2 "adb connect $SERIAL failed"; exit 1; }
# A freshly started Lepton accepts ADB before Android has finished booting.
for i in {1..45}; do
[[ "$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null)" == 1 ]] && break
(( i == 45 )) && { print -u2 "Android in Lepton didn't finish booting within 90s"; exit 1; }
sleep 2
done
if (( split )); then
print "==> Installing split APK set (${#apks} files)"
adb -s "$SERIAL" install-multiple -r "${apks[@]}"
else
for apk in "${apks[@]}"; do
print "==> Installing $apk"
adb -s "$SERIAL" install -r "$apk"
done
fi
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: install Flatpaks on the Steam Frame over SSH (per-user, so they
# survive SteamOS updates and need no sudo / steamos-readonly changes).
#
# Verified on a Frame 2026-09-25 (remmina + --vnc-host). Idempotent.
# The "exports/share is not in the search path" warning only applies to the
# SSH shell; the headset desktop's XDG_DATA_DIRS already includes it.
#
# Usage:
# scripts/install-apps.sh remmina [--vnc-host my-mac.local]
# scripts/install-apps.sh moonlight
# scripts/install-apps.sh org.example.SomeApp # any Flathub app ID
#
# --vnc-host pre-seeds a Remmina profile pointing at the Mac's built-in
# Screen Sharing (VNC, port 5900) so nothing needs typing in the headset.
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
vnc_host=""
apps=()
while (( $# )); do
case "$1" in
--vnc-host) vnc_host=${2:?--vnc-host needs a hostname}; shift 2
[[ "$vnc_host" =~ '^[A-Za-z0-9.-]+$' ]] || { print -u2 "Bad hostname: $vnc_host"; exit 2; } ;;
-h|--help) sed -n '2,13p' "$0"; exit 0 ;;
remmina) apps+=(org.remmina.Remmina); shift ;;
moonlight) apps+=(com.moonlight_stream.Moonlight); shift ;;
[A-Za-z]*.*[A-Za-z0-9_]) [[ "$1" =~ '^[A-Za-z0-9_.-]+$' ]] || { print -u2 "Bad app ID: $1"; exit 2; }; apps+=("$1"); shift ;;
*) print -u2 "Unknown app '$1' (use remmina, moonlight, or a Flathub app ID)"; exit 2 ;;
esac
done
if (( ${#apps} == 0 )) && [[ -z "$vnc_host" ]]; then
sed -n '2,13p' "$0"; exit 2
fi
if (( ${#apps} )); then
print "==> Installing on $FRAME_ALIAS: ${apps[*]}"
ssh "$FRAME_ALIAS" "
set -e
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y flathub ${(j: :)${(@q)apps}}
"
fi
if [[ -n "$vnc_host" ]]; then
print "==> Writing Remmina profile for vnc://$vnc_host"
ssh "$FRAME_ALIAS" "
set -e
d=\$HOME/.var/app/org.remmina.Remmina/data/remmina
mkdir -p \"\$d\"
cat > \"\$d/mac-screen-sharing.remmina\" <<'EOF'
[remmina]
name=Mac Screen Sharing
protocol=VNC
server=$vnc_host:5900
colordepth=32
quality=9
viewonly=0
showcursor=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."
fi
-112
View File
@@ -1,112 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: start a Linux app on the Steam Frame as its OWN floating VR panel,
# separate from the Plasma desktop panel, so you can place it anywhere.
#
# How it works (verified 2026-09-25): gamescope runs with
# --virtual-connector-strategy PerAppId, so every distinct Steam app id gets
# its own SteamVR overlay (valve.steam.desktopgame.<id>). Steam normally sets
# that id on a game's X11 windows through the STEAM_GAME property. This script
# starts the app as an X11 client of gamescope (DISPLAY=:0), then tags each new
# top-level window with a per-panel id, which makes a new panel appear.
#
# Usage:
# scripts/panel-on-frame.sh [--id N] [--name LABEL] konsole
# scripts/panel-on-frame.sh --name notes -- kate '~/notes.md'
# scripts/panel-on-frame.sh org.mozilla.firefox # Flatpak app ID
# scripts/panel-on-frame.sh mac-screen # Remmina into the Mac
#
# Apps sharing an id share a panel. The default id is derived from --name (or
# the command), so re-running the same app reuses its panel slot.
# A leading "~/" in any argument is expanded on the Frame (quote it on the Mac).
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
id="" name=""
while (( $# )); do
case "$1" in
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
--id) id=${2:?--id needs a number}; shift 2 ;;
--name) name=${2:?--name needs a label}; shift 2 ;;
*) break ;;
esac
done
case "${1:-}" in
"") sed -n '2,20p' "$0"; exit 2 ;;
remmina) cmd=(flatpak run org.remmina.Remmina) ;;
mac-screen) cmd=(flatpak run org.remmina.Remmina -c "$REMMINA_PROFILE") ;;
--) shift; (( $# )) || { print -u2 "panel-on-frame: missing command after --"; exit 2; }
cmd=("$@") ;;
*)
if [[ "$1" =~ '^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+){2,}$' ]]; then
cmd=(flatpak run "$@")
else
cmd=("$@")
fi ;;
esac
if [[ -z "$id" ]]; then
# Stable id per label, well above real Steam app ids (< 5,000,000 today).
label=${name:-${cmd[*]}}
id=$(( 2000000000 + $(print -rn -- "$label" | cksum | cut -d' ' -f1) % 1000000 ))
fi
[[ "$id" == <1-4294967295> ]] || { print -u2 "panel-on-frame: --id must be a positive 32-bit number"; exit 2; }
# Runs on the Frame with the app id as $1 and the command as the rest.
remote=$(cat <<'EOF'
set -u
appid=$1; shift
export DISPLAY=:0
unset WAYLAND_DISPLAY
# Make toolkits pick X11 so the window lands on gamescope's Xwayland.
export QT_QPA_PLATFORM=xcb GDK_BACKEND=x11 SDL_VIDEODRIVER=x11 MOZ_ENABLE_WAYLAND=0
if ! xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1; then
echo "gamescope's X display :0 isn't reachable; is the headset awake?" >&2
exit 2
fi
toplevels() { xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}' | sort; }
before=$(toplevels)
if [ -z "$before" ]; then
echo "couldn't list windows on :0 (is xwininfo installed?)" >&2
exit 2
fi
args=()
for a in "$@"; do
case "$a" in "~/"*) a="$HOME/${a#\~/}" ;; esac
args+=("$a")
done
log=$(mktemp /tmp/panel-on-frame.XXXXXX)
setsid nohup "${args[@]}" > "$log" 2>&1 < /dev/null &
child=$!
tagged=0 first=0
# Tag new mapped windows: keep watching ~3s after the first (splash screens,
# secondary windows), up to 20s in total for slow Flatpaks.
for i in $(seq 1 40); do
sleep 0.5
[ "$tagged" -eq 0 ] && ! kill -0 "$child" 2>/dev/null && break
for w in $(comm -13 <(printf '%s\n' "$before") <(toplevels)); do
xwininfo -id "$w" 2>/dev/null | grep -q 'Map State: IsViewable' || continue
xprop -id "$w" STEAM_GAME 2>/dev/null | grep -q '= ' && continue
xprop -id "$w" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null && tagged=$((tagged + 1))
done
[ "$tagged" -gt 0 ] && [ "$first" -eq 0 ] && first=$i
[ "$first" -gt 0 ] && [ "$i" -ge $((first + 6)) ] && break
done
if [ "$tagged" -gt 0 ]; then
echo "panel: ${args[*]} -> valve.steam.desktopgame.$appid ($tagged window(s), pid $child, log $log)"
elif kill -0 "$child" 2>/dev/null; then
echo "started ${args[*]} (pid $child) but no new X11 window appeared." >&2
echo "It may be Wayland-only or single-instance (already running elsewhere). Log: $log" >&2
exit 1
else
echo "failed: ${args[*]} exited. Log:" >&2
tail -n 20 "$log" >&2
exit 1
fi
EOF
)
b64=$(print -rn -- "$remote" | base64)
ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: put text on the Steam Frame desktop clipboard.
#
# Needs the headset's desktop (Plasma) to be running. Text only; very large
# pastes (over ~100 KB) exceed the argument limit, so use push.sh for those.
#
# Usage:
# scripts/paste-to-frame.sh # sends the Mac clipboard (pbpaste)
# some-cmd | scripts/paste-to-frame.sh -
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
# Runs on the Frame. Clipboard text arrives on stdin.
# Verified 2026-09-25 (SteamOS 0.3.0 vr, build 20260922): the headset desktop is
# a nested Plasma Wayland session inside gamescope with its own D-Bus bus, and
# wl-copy/xclip are not installed. Klipper (org.kde.klipper, served by
# plasmashell) is reachable with qdbus6, so we borrow plasmashell's bus address.
remote=$(cat <<'EOF'
set -u
text=$(cat; printf x); text=${text%x}
pid=$(pgrep -u "$(id -u)" -x plasmashell | head -n 1)
if [ -z "$pid" ]; then
echo "plasmashell is not running: open the desktop in the headset first." >&2
exit 2
fi
bus=$(tr '\0' '\n' < "/proc/$pid/environ" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')
if DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper \
org.kde.klipper.klipper.setClipboardContents "$text" >/dev/null; then
echo "copied via Klipper (${#text} chars)"
else
echo "Klipper call failed (bus: ${bus:-none})" >&2
exit 2
fi
EOF
)
b64=$(print -rn -- "$remote" | base64)
if [[ "${1:-}" == "-" ]]; then
ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\""
else
pbpaste | ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\""
fi
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: upload VR videos to the Steam Frame so DeoVR can play them in 3D.
#
# Files go to ~/Videos/VR on the Frame. The script links that folder into
# DeoVR's Proton prefix as C:\users\steamuser\Videos\VR, so DeoVR's file
# browser finds it under Videos. (It's also reachable as Z:\home\steamos\Videos\VR.)
# Uploads resume if interrupted.
#
# Name files so DeoVR picks the projection: include _180 or _360 (or _fisheye190,
# _mkx200, _rf52 ...) plus the stereo layout (_LR / _SBS side by side, _TB over-
# under), e.g. "beach_180_LR.mp4". You can also change it in DeoVR's player.
#
# Usage:
# scripts/push-vr-video.sh FILE_OR_DIR... # upload
# scripts/push-vr-video.sh --launch FILE... # upload, then start DeoVR
# scripts/push-vr-video.sh --launch # just start DeoVR
# scripts/push-vr-video.sh --list # what's on the Frame
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
DEOVR_APPID=837380
REMOTE_DIR="Videos/VR"
PREFIX_VIDEOS=".local/share/Steam/steamapps/compatdata/$DEOVR_APPID/pfx/drive_c/users/steamuser/Videos"
launch=0 list=0
while (( $# )); do
case "$1" in
-h|--help) sed -n '2,17p' "$0"; exit 0 ;;
--launch) launch=1; shift ;;
--list) list=1; shift ;;
--) shift; break ;;
-*) print -u2 "push-vr-video: unknown option $1"; exit 2 ;;
*) break ;;
esac
done
(( $# || launch || list )) || { sed -n '2,17p' "$0" >&2; exit 2; }
for f in "$@"; do
[[ -e "$f" ]] || { print -u2 "push-vr-video: no such file: $f"; exit 2; }
done
# Create the folder and link it into DeoVR's prefix (the prefix exists once
# DeoVR has run). Refresh a stale link, but never replace a real directory.
if (( $# || launch )); then
ssh "$FRAME_ALIAS" "mkdir -p ~/$REMOTE_DIR
p=~/$PREFIX_VIDEOS
if [ -d \"\$p\" ] && { [ -L \"\$p/VR\" ] || [ ! -e \"\$p/VR\" ]; }; then ln -sfn ~/$REMOTE_DIR \"\$p/VR\"
elif [ -d \"\$p/VR\" ]; then echo \"warning: \$p/VR is a real folder, so uploads won't show under DeoVR's Videos; browse Z:\\\\home\\\\steamos\\\\Videos\\\\VR instead\" >&2; fi
[ -d \"\$p\" ] || echo 'note: DeoVR has not run yet; use Z:\\home\\steamos\\Videos\\VR or run this again after starting it once' >&2"
fi
if (( $# )); then
# -L: send what a symlink points at; a Mac-side link would dangle on the Frame
rsync -aL --partial --progress -- "$@" "$FRAME_ALIAS:$REMOTE_DIR/"
fi
if (( list )); then
ssh "$FRAME_ALIAS" "cd ~/$REMOTE_DIR && ls -lhR"
fi
if (( launch )); then
ssh "$FRAME_ALIAS" "command -v steam >/dev/null || { echo 'steam not found on the Frame' >&2; exit 1; }
steam steam://rungameid/$DEOVR_APPID </dev/null >/dev/null 2>&1 &"
print "DeoVR starting on the Frame. Open Local files / the file browser → Videos → VR."
fi
-18
View File
@@ -1,18 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: copy a file or folder to the Steam Frame.
#
# Verified on a Frame 2026-09-25.
#
# Usage: scripts/push.sh SOURCE [REMOTE_DEST] (default dest: ~/Downloads/)
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
src=${1:?usage: push.sh SOURCE [REMOTE_DEST]}
dest=${2:-Downloads/}
if ssh "$FRAME_ALIAS" 'command -v rsync >/dev/null'; then
rsync -a --progress "$src" "$FRAME_ALIAS:${(q)dest}" # remote shell parses the path
else
print -u2 "rsync not found on the Frame; falling back to scp"
scp -r "$src" "$FRAME_ALIAS:$dest" # modern scp uses SFTP: no remote shell parsing
fi
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: start a GUI app on the Steam Frame's in-headset desktop over SSH.
#
# The headset desktop is a nested Plasma session inside gamescope with its own
# runtime dir, Wayland socket, X display and D-Bus bus (verified 2026-09-25), so
# a plain `ssh frame some-app` can't find it. This copies those variables from
# plasmashell's environment, then starts the app detached so it outlives SSH.
#
# Usage:
# scripts/run-on-frame.sh remmina # Remmina main window
# scripts/run-on-frame.sh mac-screen # Remmina straight into the Mac profile
# scripts/run-on-frame.sh org.example.App [ARGS...] # any installed Flatpak
# scripts/run-on-frame.sh -- COMMAND [ARGS...] # any command on the Frame
#
# A leading "~/" in any argument is expanded on the Frame.
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
case "${1:-}" in
""|-h|--help) sed -n '2,15p' "$0"; exit 0 ;;
remmina) cmd=(flatpak run org.remmina.Remmina) ;;
mac-screen) cmd=(flatpak run org.remmina.Remmina -c "$REMMINA_PROFILE") ;;
--) shift; (( $# )) || { print -u2 "run-on-frame: missing command after --"; exit 2; }
cmd=("$@") ;;
*)
if [[ "$1" =~ '^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+){2,}$' ]]; then
cmd=(flatpak run "$@")
else
print -u2 "run-on-frame: unknown app '$1' (use a Flatpak app ID, or -- COMMAND)"
exit 2
fi ;;
esac
# Runs on the Frame with the command as "$@".
remote=$(cat <<'EOF'
set -u
pid=$(pgrep -u "$(id -u)" -x plasmashell | head -n 1)
if [ -z "$pid" ]; then
echo "plasmashell is not running: open the desktop in the headset first." >&2
exit 2
fi
while IFS= read -r -d '' kv; do
case "$kv" in
WAYLAND_DISPLAY=*|XDG_RUNTIME_DIR=*|DISPLAY=*|XAUTHORITY=*|DBUS_SESSION_BUS_ADDRESS=*|\
XDG_DATA_DIRS=*|XDG_CURRENT_DESKTOP=*|XDG_SESSION_TYPE=*) export "$kv" ;;
esac
done < "/proc/$pid/environ"
args=()
for a in "$@"; do
case "$a" in "~/"*) a="$HOME/${a#\~/}" ;; esac
args+=("$a")
done
log=$(mktemp /tmp/run-on-frame.XXXXXX)
setsid nohup "${args[@]}" > "$log" 2>&1 < /dev/null &
child=$!
sleep 3
if kill -0 "$child" 2>/dev/null; then
echo "started: ${args[*]} (pid $child, log $log)"
elif wait "$child"; then
# Single-instance apps (e.g. Remmina) hand off to the running copy and exit 0.
echo "done: ${args[*]} (exited 0; single-instance apps hand off to the running copy)"
rm -f "$log"
else
rc=$?
echo "failed: ${args[*]} (exit $rc). Log:" >&2
tail -n 20 "$log" >&2
exit 1
fi
EOF
)
b64=$(print -rn -- "$remote" | base64)
ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" run-on-frame ${(j: :)${(@q)cmd}}"
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env zsh
# Mac-side (fallback only): serve bootstrap-on-frame.sh over plain HTTP on the
# LAN, with this Mac's Frame public key embedded, and print the short
# one-liner to type in Konsole on the headset. Ctrl-C to stop.
#
# UNTESTED against real hardware. Serves only a public key; use on a trusted LAN.
set -euo pipefail
PORT=${PORT:-8765}
here=${0:A:h}
KEY="$HOME/.ssh/id_ed25519_frame"
if [[ ! -f "$KEY.pub" ]]; then
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
ssh-keygen -q -t ed25519 -N '' -C "mac->steam-frame" -f "$KEY"
fi
pub=$(<"$KEY.pub")
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
# index.html so the bare URL works; curl doesn't care about the name.
sed "s|__PUBKEY__|$pub|" "$here/bootstrap-on-frame.sh" > "$dir/index.html"
name="$(scutil --get LocalHostName 2>/dev/null || hostname -s).local"
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)
print "Type ONE of these in Konsole on the Frame:"
print " curl -fsS $name:$PORT|bash"
[[ -n "$ip" ]] && print " curl -fsS $ip:$PORT|bash"
print "Serving from $dir on port $PORT (Ctrl-C to stop)..."
python3 -m http.server "$PORT" --directory "$dir"
-179
View File
@@ -1,179 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: install Tailscale on the Frame in userspace mode, so the `frame` SSH
# alias (and Frame Control) work from anywhere, not just the home LAN.
#
# Everything lives in the steamos user's home, so it needs no sudo and survives
# SteamOS updates:
# ~/.local/share/tailscale/<version>/ static binaries (checksum-verified)
# ~/.local/share/tailscale/state/ node key and state
# ~/.local/bin/tailscale CLI wrapper that finds the daemon's socket
# ~/.config/systemd/user/tailscaled.service
# `tailscaled --tun=userspace-networking` needs no /dev/net/tun or root.
#
# Exposure: in userspace mode tailscaled forwards inbound tailnet connections
# to the Frame's loopback, so EVERY port is reachable from the tailnet,
# including localhost-only ones (Steam's DevTools on 8080, SteamVR, ADB).
# `tailscale set --shields-up` blocks all inbound (SSH too). See docs/tailscale.md.
#
# Usage: scripts/tailscale-on-frame.sh [--version X.Y.Z] [--hostname NAME]
# scripts/tailscale-on-frame.sh --uninstall
# The first run prints a login URL (and opens it on the Mac) to add the Frame
# to your tailnet. Env: FRAME_ALIAS (default frame).
set -euo pipefail
FRAME=${FRAME_ALIAS:-frame}
version="" hostname="frame" uninstall=0
while (( $# )); do
case "$1" in
--version) version=${2:?--version needs a value}; shift ;;
--hostname) hostname=${2:?--hostname needs a value}; shift ;;
--uninstall) uninstall=1 ;;
-h|--help) sed -n "2,21p" "$0"; exit 0 ;;
*) print -u2 "unknown argument: $1"; exit 2 ;;
esac
shift
done
[[ $hostname =~ '^[A-Za-z0-9-]+$' ]] || { print -u2 "bad hostname: $hostname"; exit 2; }
if (( uninstall )); then
ssh "$FRAME" 'set -e
systemctl --user disable --now tailscaled.service 2>/dev/null || true
rm -f ~/.config/systemd/user/tailscaled.service ~/.local/bin/tailscale
systemctl --user daemon-reload
echo "Removed the service and the CLI wrapper. Binaries and node state are still in"
echo "~/.local/share/tailscale; delete that folder and remove the machine in the"
echo "Tailscale admin console to finish. Linger stays on (loginctl disable-linger to undo)."'
exit 0
fi
# BackendState of the Frame's tailscaled (Running, NeedsLogin, Stopped, …), or
# Unreachable when the probe itself fails (SSH down, daemon restarting).
ts_state() {
ssh -o ConnectTimeout=10 "$FRAME" '~/.local/bin/tailscale status --json 2>/dev/null |
python3 -c "import json,sys; print(json.load(sys.stdin)[\"BackendState\"])"' 2>/dev/null || print Unreachable
}
if [[ -z $version ]]; then
version=$(curl -fsS "https://pkgs.tailscale.com/stable/?mode=json" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["TarballsVersion"])')
fi
[[ $version =~ '^[0-9]+\.[0-9]+\.[0-9]+$' ]] || { print -u2 "bad version: $version"; exit 2; }
print "==> Installing Tailscale $version on $FRAME (userspace networking)"
remote_out=$(ssh "$FRAME" "VERSION=$version sh -s" <<'REMOTE'
set -eu
base="$HOME/.local/share/tailscale"
dir="$base/$VERSION"
tgz="tailscale_${VERSION}_arm64.tgz"
unit="$HOME/.config/systemd/user/tailscaled.service"
sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/tailscale/tailscaled.sock"
mkdir -p "$base/state" "$HOME/.local/bin" "$HOME/.config/systemd/user"
if [ ! -x "$dir/tailscaled" ]; then
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
curl -fsSL -o "$tmp/$tgz" "https://pkgs.tailscale.com/stable/$tgz"
want=$(curl -fsSL "https://pkgs.tailscale.com/stable/$tgz.sha256" | cut -d' ' -f1)
[ -n "$want" ] || { echo "couldn't fetch $tgz.sha256" >&2; exit 1; }
got=$(sha256sum "$tmp/$tgz" | cut -d' ' -f1)
[ "$want" = "$got" ] || { echo "checksum mismatch for $tgz" >&2; exit 1; }
tar -xzf "$tmp/$tgz" -C "$tmp"
mkdir -p "$dir"
mv "$tmp/tailscale_${VERSION}_arm64/tailscale" "$tmp/tailscale_${VERSION}_arm64/tailscaled" "$dir/"
fi
# Neither exists on a first install; don't let that trip set -e.
before=$({ readlink "$base/current"; cat "$unit"; } 2>/dev/null || true)
ln -sfn "$dir" "$base/current"
# The CLI looks for the daemon at /var/run/tailscale by default; point it at ours.
cat > "$HOME/.local/bin/tailscale" <<'EOF'
#!/bin/sh
exec "$HOME/.local/share/tailscale/current/tailscale" --socket="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/tailscale/tailscaled.sock" "$@"
EOF
chmod +x "$HOME/.local/bin/tailscale"
cat > "$unit" <<'EOF'
[Unit]
Description=Tailscale (userspace networking, no root)
After=network-online.target
[Service]
RuntimeDirectory=tailscale
ExecStart=%h/.local/share/tailscale/current/tailscaled --tun=userspace-networking --statedir=%h/.local/share/tailscale/state --socket=%t/tailscale/tailscaled.sock --port=41641
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
EOF
# Linger starts user services at boot, before anyone logs in; polkit allows it without sudo.
loginctl enable-linger 2>/dev/null || echo "note: couldn't enable linger; tailscaled starts when the session does" >&2
systemctl --user daemon-reload
systemctl --user enable tailscaled.service >/dev/null 2>&1
after=$(readlink "$base/current"; cat "$unit")
restart=0
if systemctl --user is-active --quiet tailscaled.service; then
[ "$before" = "$after" ] || restart=1
else
systemctl --user start tailscaled.service
fi
for i in $(seq 1 50); do
[ -S "$sock" ] && break
sleep 0.2
done
[ -S "$sock" ] || { echo "tailscaled didn't open $sock; see: journalctl --user -u tailscaled" >&2; exit 1; }
v=$("$HOME/.local/bin/tailscale" version)
printf 'Tailscale %s\n' "$(printf '%s\n' "$v" | head -n 1)"
if [ "$restart" = 1 ]; then
# This SSH session may itself run over Tailscale, so restart detached, after
# it has ended; the Mac waits and reconnects.
systemd-run --user --quiet --on-active=3 --timer-property=AccuracySec=100ms --unit=tailscaled-restart --collect \
systemctl --user restart tailscaled.service >/dev/null
echo "RESTART_SCHEDULED"
fi
REMOTE
)
print -r -- "${remote_out//RESTART_SCHEDULED/Restarting tailscaled for the new version or unit…}"
# Wait out a scheduled restart, then read a definite state.
[[ $remote_out == *RESTART_SCHEDULED* ]] && sleep 6
state=""
for i in {1..30}; do
state=$(ts_state)
[[ $state == (Running|NeedsLogin|NeedsMachineAuth|Stopped|NoState) ]] && break
sleep 2
done
case $state in
Running) ;;
NeedsLogin|Stopped|NoState)
# `up` blocks until the login is approved, so run it as its own transient
# unit (it outlives this SSH session) and fetch the URL from its log.
ssh "$FRAME" "rm -f /tmp/tailscale-up.log; systemd-run --user --quiet --collect --unit=tailscale-up-\$\$ \
sh -c '~/.local/bin/tailscale up --hostname=$hostname --timeout=10m > /tmp/tailscale-up.log 2>&1' >/dev/null"
url=""
for i in {1..40}; do
url=$(ssh "$FRAME" 'grep -Eo "https://login\.tailscale\.com/[A-Za-z0-9/_-]+" /tmp/tailscale-up.log 2>/dev/null | head -n 1' || true)
[[ -n $url ]] && break
sleep 0.5
done
[[ -n $url ]] || { print -u2 "No login URL after 20 s; see /tmp/tailscale-up.log on the Frame."; exit 1; }
print "==> Approve the Frame in your tailnet: $url"
open "$url" 2>/dev/null || true
print " Waiting for approval (up to 10 minutes)…"
for i in {1..300}; do
state=$(ts_state)
[[ $state == Running ]] && break
sleep 2
done
[[ $state == Running ]] || { print -u2 "Not approved yet (state: $state). Re-run to get a new URL."; exit 1; }
;;
NeedsMachineAuth) print -u2 "Logged in; approve the device in the Tailscale admin console, then re-run."; exit 1 ;;
*) print -u2 "Couldn't read tailscaled's state (last: $state). Check: ssh $FRAME 'journalctl --user -u tailscaled'"; exit 1 ;;
esac
ssh "$FRAME" '~/.local/bin/tailscale status --self --peers=false; printf "Tailscale IP: "; ~/.local/bin/tailscale ip -4'
name=$(ssh "$FRAME" '~/.local/bin/tailscale status --json' | python3 -c 'import json,sys; print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')
print "==> To use the Frame from anywhere, point the alias at Tailscale:"
print " ssh-keyscan -t ed25519 $name >> ~/.ssh/known_hosts # after checking it matches"
print " scripts/connect.sh $name"
-51
View File
@@ -1,51 +0,0 @@
"""Compatibility reports without the maintainer's key: saved locally, never sent.
Run: python3 -m unittest discover -s tests
"""
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_compat_db as db # noqa: E402
class NoKey(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
state = tmp.name
for name, value in (("STATE", state), ("OUTBOX", os.path.join(state, "outbox.jsonl")),
("MIRROR", os.path.join(state, "mirror.json"))):
p = mock.patch.object(db, name, value)
p.start()
self.addCleanup(p.stop)
db._mem.update(at=0, reports=None, source=None)
env = mock.patch.dict(os.environ, {}, clear=False)
env.start()
self.addCleanup(env.stop)
os.environ.pop("FRAME_CONTROL_KEY", None)
# No Keychain entry, and any network use fails the test.
no_key = mock.patch.object(db.subprocess, "run",
return_value=mock.Mock(returncode=44, stdout=""))
no_key.start()
self.addCleanup(no_key.stop)
net = mock.patch.object(db._opener, "open", side_effect=AssertionError("network used"))
net.start()
self.addCleanup(net.stop)
def test_report_is_kept_locally(self):
self.assertFalse(db.shared())
r = db.add({"package": "org.example.app", "date": "2026-09-26T10:00:00", "rating": "works"})
reports = db.load()
self.assertEqual([x["id"] for x in reports], [r["id"]])
self.assertEqual(db._mem["source"], "mirror")
if __name__ == "__main__":
unittest.main()
-218
View File
@@ -1,218 +0,0 @@
"""Setup-script checks that need no headset: devkit pairing against a stub of Valve's
steamos-devkit-service, the ~/.ssh/config block, and the mDNS output parsers.
Run: python3 -m unittest discover -s tests
"""
import json
import socket
import sys
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_connect as fc # noqa: E402
PUB = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC+/x= frame-control@old\n"
class StubDevkit(BaseHTTPRequestHandler):
"""Answers like steamos-devkit-service; `reply` picks the /register outcome."""
reply = (200, b"Registered\n")
replies = [] # if set, each /register takes the next one instead of `reply`
properties = {"txtvers": 1, "login": "steamos", "settings": "{}", "devkit1": ["devkit-1"]}
bodies = []
def log_message(self, *args):
pass
def do_GET(self):
if self.path == "/properties.json":
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(self.properties).encode())
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"]))
StubDevkit.bodies.append((self.path, self.headers["Content-Type"], body))
code, text = StubDevkit.replies.pop(0) if StubDevkit.replies else self.reply
self.send_response(code)
self.send_header("Content-type", "text/plain")
self.end_headers()
self.wfile.write(text)
class DevkitPairing(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), StubDevkit)
cls.port = cls.server.server_address[1]
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
def setUp(self):
StubDevkit.reply = (200, b"Registered\n")
StubDevkit.bodies = []
StubDevkit.replies = []
self.said = []
self._say, fc.say = fc.say, self.said.append
def tearDown(self):
fc.say = self._say
def test_register_body(self):
body = fc.register_body(PUB, "frame-control@mac")
self.assertEqual(body, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC+/x= frame-control@mac "
"900b919520e4cf601998a71eec318fec\n")
# approve-ssh-key shows split(' ')[2] as the key name.
self.assertEqual(body.split(" ")[2], "frame-control@mac")
with self.assertRaises(ValueError):
fc.register_body("ssh-ed25519 AAAAC3Nz x", "c")
def test_key_comment_is_one_word(self):
self.assertEqual(fc.key_comment("Alex's MacBook Pro.local"), "frame-control@Alex-s-MacBook-Pro")
self.assertEqual(fc.key_comment(""), "frame-control@computer")
self.assertNotIn(" ", fc.key_comment(" a b\nc "))
def test_parse_login(self):
self.assertEqual(fc.parse_login(b'{"login": "steamos", "txtvers": 1}'), "steamos")
for raw in (b'{"txtvers": 1}', b'{"login": "root"}', b'{"login": "x\\nHost *"}', b'{"login": 5}'):
self.assertIsNone(fc.parse_login(raw), raw)
for raw in (b"not json", b"[1]"):
with self.assertRaises(ValueError):
fc.parse_login(raw)
def test_devkit_error(self):
self.assertEqual(fc.devkit_error(403, b'{"error": "Steam is not running"}\n'), "Steam is not running")
self.assertEqual(fc.devkit_error(500, b"install-ssh-key:\nboom"), "install-ssh-key:\nboom")
self.assertEqual(fc.devkit_error(403, b""), "HTTP 403")
def test_pair_ok(self):
logins = []
reason = fc.devkit_pair("127.0.0.1", PUB, "frame-control@test", self.port, logins.append)
self.assertIsNone(reason)
self.assertEqual(logins, ["steamos"])
path, ctype, body = StubDevkit.bodies[0]
self.assertEqual((path, ctype), ("/register", "text/plain"))
self.assertEqual(body.decode(), fc.register_body(PUB, "frame-control@test"))
self.assertTrue(any("Pair new host" in s for s in self.said))
NOT_PAIRING = (403, b'{"error": "devkit approve-ssh-key: please put the Steam client in pairing mode: '
b'Settings -> Developer -> Pair new host"}')
def test_pair_waits_for_pairing_mode(self):
# The headset refuses until Steam is on "Pair new host", then prompts.
StubDevkit.replies = [self.NOT_PAIRING, self.NOT_PAIRING, (200, b"Registered\n")]
sleep, fc.time.sleep = fc.time.sleep, lambda s: None
try:
reason = fc.devkit_pair("127.0.0.1", PUB, "c", self.port)
finally:
fc.time.sleep = sleep
self.assertIsNone(reason)
self.assertEqual(len(StubDevkit.bodies), 3)
def test_pair_gives_up_without_pairing_mode(self):
StubDevkit.reply = self.NOT_PAIRING
wait, fc.PAIRING_MODE_WAIT = fc.PAIRING_MODE_WAIT, 0
try:
reason = fc.devkit_pair("127.0.0.1", PUB, "c", self.port)
finally:
fc.PAIRING_MODE_WAIT = wait
self.assertIn("pairing mode", reason)
self.assertEqual(len(StubDevkit.bodies), 1)
def test_pair_refused_falls_back(self):
StubDevkit.reply = (403, b'{"error": "timeout - Steam did not respond to the pairing request"}')
logins = []
reason = fc.devkit_pair("127.0.0.1", PUB, "c", self.port, logins.append)
self.assertIn("timeout - Steam did not respond", reason)
# The login is still reported, so the password fallback uses the right user.
self.assertEqual(logins, ["steamos"])
def test_pair_without_service_falls_back(self):
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
closed = s.getsockname()[1]
logins = []
reason = fc.devkit_pair("127.0.0.1", PUB, "c", closed, logins.append)
self.assertIn("not reachable", reason)
self.assertEqual((logins, StubDevkit.bodies), ([], []))
def test_pair_times_out(self):
# Accepts the connection but never answers, like a prompt nobody taps.
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
s.listen()
ok, msg = fc.register("127.0.0.1", "x", s.getsockname()[1], timeout=0.5)
self.assertFalse(ok)
self.assertIn("no answer", msg)
class ConfigBlock(unittest.TestCase):
def test_both_keys(self):
block = fc.config_block("frame.local", 22, "steamos")
self.assertEqual(block[0], fc.BEGIN)
self.assertEqual(block[-1], fc.END)
self.assertIn(" User steamos", block)
self.assertNotIn(" Port 22", block)
files = [line for line in block if line.startswith(" IdentityFile")]
self.assertEqual(files, [" IdentityFile ~/.ssh/id_ed25519_frame", " IdentityFile ~/.ssh/id_rsa_frame_devkit"])
self.assertIn(" IdentitiesOnly yes", block)
self.assertEqual(block[-2], "Host *")
self.assertIn(" Port 2222", fc.config_block("10.0.0.5", 2222))
def test_write_config_replaces_block(self):
import tempfile
with tempfile.TemporaryDirectory() as d:
saved = fc.SSH_DIR, fc.CONFIG
fc.SSH_DIR, fc.CONFIG = Path(d), Path(d) / "config"
try:
fc.CONFIG.write_text("Host other\n User me\n", encoding="utf-8")
fc.write_config("frame.local")
self.assertEqual(fc.configured_user(), "steamos")
fc.write_config("10.0.0.5", 22, "deck")
text = fc.CONFIG.read_text(encoding="utf-8")
self.assertEqual(fc.configured_user(), "deck") # not "me" from Host other
finally:
fc.SSH_DIR, fc.CONFIG = saved
self.assertEqual(text.count(fc.BEGIN), 1)
self.assertIn("HostName 10.0.0.5", text)
self.assertIn("User deck", text)
self.assertNotIn("frame.local", text)
self.assertTrue(text.endswith("Host other\n User me\n"))
class MdnsParsers(unittest.TestCase):
def test_dns_sd(self):
browse = ("Browsing for _steamos-devkit._tcp\n"
"Timestamp A/R Flags if Domain Service Type Instance Name\n"
"19:34:35.419 Add 3 15 local. _steamos-devkit._tcp. frame\n"
"19:34:35.611 Add 2 1 local. _steamos-devkit._tcp. frame\n"
"19:34:35.700 Add 2 15 local. _steamos-devkit._tcp. My Frame\n"
"19:34:36.000 Rmv 0 15 local. _steamos-devkit._tcp. gone\n")
self.assertEqual(fc.parse_dns_sd_browse(browse), ["frame", "My Frame"])
resolve = ("Lookup frame._steamos-devkit._tcp.local.\n"
"19:34:44.601 frame._steamos-devkit._tcp.local. can be reached at frame.local.:32000 (interface 15)\n")
self.assertEqual(fc.parse_dns_sd_resolve(resolve), "frame.local")
self.assertIsNone(fc.parse_dns_sd_resolve("Lookup frame\n"))
def test_avahi(self):
out = ('+;wlan0;IPv4;frame;_steamos-devkit._tcp;local\n'
'=;wlan0;IPv6;frame;_steamos-devkit._tcp;local;frame.local;fe80::1;32000;"login=steamos"\n'
'=;wlan0;IPv4;frame;_steamos-devkit._tcp;local;frame.local;192.168.1.50;32000;"login=steamos"\n')
self.assertEqual(fc.parse_avahi(out), ["frame.local", "192.168.1.50"])
if __name__ == "__main__":
unittest.main()
-198
View File
@@ -1,198 +0,0 @@
"""frame_apk against a small APK built here: binary manifest plus resource table."""
import io
import os
import struct
import sys
import tempfile
import tracemalloc
import unittest
import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
import frame_apk # noqa: E402
def pool(strings, utf8=False):
"""A ResStringPool chunk."""
data, offsets = b'', []
for s in strings:
offsets.append(len(data))
if utf8:
b = s.encode()
data += bytes([len(s), len(b)]) + b + b'\0'
else:
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
data += b'\0' * (-len(data) % 4)
start = 28 + 4 * len(strings)
body = struct.pack(f'<{len(strings)}I', *offsets) + data
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
package_raw=False drops the package's raw string (as some repackers do);
foreign_label adds a non-android `label` attribute after android:label.
"""
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
'application', package, 'junk', 'label'] # the second 'label' has no android id
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
def element(name, attrs):
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
for aname, raw, dtype, value in attrs:
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
none = 0xffffffff
chunks = (pool(strings) + resmap
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
(2, none, frame_apk.T_REF, version_ref)])
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
def resources(values):
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
pkg_body = b''
for (tid, lang, density), entries in values.items():
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
count = max(entries) + 1
offsets, data = [], b''
for i in range(count):
if i in entries:
offsets.append(len(data))
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
else:
offsets.append(0xffffffff)
header = 20 + 64
estart = header + 4 * count
body = struct.pack(f'<{count}I', *offsets) + data
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
pkg = pkg_header + pkg_body
table = pool(strings, utf8=True) + pkg
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
def apk(files):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
for name, data in files.items():
z.writestr(name, data)
return buf.getvalue()
class ApkInfo(unittest.TestCase):
def read(self, data):
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, 'app.apk')
with open(p, 'wb') as f:
f.write(data)
return frame_apk.apk_info(p)
def test_resolves_references(self):
# string type 1: label (entry 0), version (entry 1); mipmap type 2: icon at three densities.
arsc = resources({(1, 'fr', 0): {0: 0}, (1, '', 0): {0: 1, 1: 2},
(2, '', 160): {0: 3}, (2, '', 640): {0: 4}, (2, '', 0xfffe): {0: 5}})
info = self.read(apk({
'AndroidManifest.xml': manifest('com.example.demo', 0x7f010000, 0x7f010001, 26),
'resources.arsc': arsc,
'res/icon_lo.png': b'lo', 'res/icon_hi.png': b'hi', 'res/icon.xml': b'<xml/>',
'lib/arm64-v8a/libx.so': b'', 'lib/x86_64/libx.so': b'',
}))
self.assertEqual(info['package'], 'com.example.demo')
self.assertEqual(info['label'], 'App label') # the default, not French
self.assertEqual(info['version'], '2.1')
self.assertEqual(info['min_sdk'], 26)
self.assertEqual(info['abis'], ['arm64-v8a', 'x86_64'])
self.assertEqual(info['icon_png'], b'hi') # largest-density PNG, skipping the XML icon
def test_missing_label_falls_back_to_package(self):
info = self.read(apk({'AndroidManifest.xml': manifest('com.example.bare', 0x7f010000, 0x7f010001, 21)}))
self.assertEqual(info['label'], 'com.example.bare')
self.assertEqual(info['version'], '')
self.assertEqual(info['abis'], [])
def test_repacked_manifest(self):
# Package kept only as a typed value; a foreign `label` mustn't beat android:label.
arsc = resources({(1, '', 0): {0: 1, 1: 2}})
info = self.read(apk({
'AndroidManifest.xml': manifest('com.example.repacked', 0x7f010000, 0x7f010001, 24,
package_raw=False, foreign_label=True),
'resources.arsc': arsc,
}))
self.assertEqual(info['package'], 'com.example.repacked')
self.assertEqual(info['label'], 'App label')
self.assertIsNone(info['icon_png'])
def test_rejects_non_apks(self):
for data in (b'not a zip', apk({'classes.dex': b''}), apk({'AndroidManifest.xml': b'<manifest/>'})):
with self.assertRaises(frame_apk.ApkError):
self.read(data)
def test_refuses_oversized_members(self):
# An APK from a website mustn't make the server inflate gigabytes.
data = apk({'AndroidManifest.xml': manifest('com.example.big', 0x7f010000, 0x7f010001, 21)})
limit, frame_apk.MAX_MANIFEST = frame_apk.MAX_MANIFEST, 16
try:
with self.assertRaises(frame_apk.ApkError):
self.read(data)
finally:
frame_apk.MAX_MANIFEST = limit
def test_forged_sizes_dont_inflate_everything(self):
# The central directory claims 1 byte; the deflated data holds 16 MB of zeros.
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr('AndroidManifest.xml', bytes(16 * 1024**2))
data = bytearray(buf.getvalue())
for sig, field in ((b'PK\x01\x02', 24), (b'PK\x03\x04', 22)):
at = data.index(sig)
data[at + field:at + field + 4] = struct.pack('<I', 1)
limit, frame_apk.MAX_MANIFEST = frame_apk.MAX_MANIFEST, 1024**2
tracemalloc.start()
try:
with self.assertRaises(frame_apk.ApkError):
self.read(bytes(data))
peak = tracemalloc.get_traced_memory()[1]
finally:
tracemalloc.stop()
frame_apk.MAX_MANIFEST = limit
self.assertLess(peak, 8 * 1024**2)
def test_refuses_compression_android_cant_read(self):
for method in (zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', method) as z:
z.writestr('AndroidManifest.xml', manifest('com.example.odd', 0x7f010000, 0x7f010001, 21))
with self.assertRaisesRegex(frame_apk.ApkError, 'compression'):
self.read(buf.getvalue())
def test_reference_cycles_and_fan_out_are_bounded(self):
res = frame_apk.Resources(b'')
ref = frame_apk.T_REF
res.entries = {1: [('', 0, ref, 1)] * 5} # five references to itself
self.assertEqual(res.values(1), [])
# Five references at each of five hops: 3125 leaves without a budget.
res.entries = {i: [('', 0, ref, i + 1)] * 5 for i in range(1, 6)}
res.entries[6] = [('', 0, frame_apk.T_STRING, 0)]
self.assertEqual(len(res.values(1)), frame_apk.MAX_VALUES)
# Forty references at each hop round a four-id cycle: millions of dead ends.
looked = []
class Counting(dict):
def get(self, key, default=None):
looked.append(key)
return dict.get(self, key, default)
res.entries = Counting({i: [('', 0, ref, i % 4 + 1)] * 40 for i in range(1, 5)})
self.assertEqual(res.values(1), [])
self.assertLess(len(looked), frame_apk.MAX_STEPS + 10)
if __name__ == '__main__':
unittest.main()
-415
View File
@@ -1,415 +0,0 @@
"""frame_titles without a headset: executable headers, launch targets, zips, runtimes."""
import json
import os
import shutil
import struct
import sys
import tempfile
import unittest
import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
import frame_titles # noqa: E402
from frame_titles import FrameError # noqa: E402
def elf(machine, e_type=3, interp=True, pad=0):
"""A 64-bit little-endian ELF header plus one program header (PT_INTERP or PT_LOAD)."""
ident = b'\x7fELF' + bytes([2, 1, 1]) + b'\0' * 9
header = ident + struct.pack('<HHIQQQIHHHHHH', e_type, machine, 1, 0, 64, 0, 0, 64, 56, 1, 0, 0, 0)
phdr = struct.pack('<IIQQQQQQ', 3 if interp else 1, 4, 0, 0, 0, 0, 0, 0)
return header + phdr + b'\0' * pad
def pe(machine, dll=False, pad=0):
"""An MZ stub pointing at a PE signature and COFF header."""
mz = b'MZ' + b'\0' * 0x3A + struct.pack('<I', 0x40)
coff = b'PE\0\0' + struct.pack('<HHIIIHH', machine, 1, 0, 0, 0, 0xF0, 0x2022 if dll else 0x0022)
return mz + coff + b'\0' * pad
class Classify(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.dir)
def check(self, data, name='f'):
p = os.path.join(self.dir, name)
with open(p, 'wb') as f:
f.write(data)
return frame_titles.classify(p)
def test_elf_machines(self):
self.assertEqual(self.check(elf(0xB7)), {'format': 'elf', 'arch': 'arm64', 'exe': True})
self.assertEqual(self.check(elf(0x3E))['arch'], 'x86_64')
self.assertEqual(self.check(elf(0x3E, e_type=2, interp=False))['exe'], True) # ET_EXEC
def test_shared_library_is_not_a_program(self):
self.assertFalse(self.check(elf(0xB7, interp=False))['exe'])
def test_pe_machines(self):
self.assertEqual(self.check(pe(0x8664)), {'format': 'pe', 'arch': 'x86_64', 'exe': True})
self.assertEqual(self.check(pe(0xAA64))['arch'], 'arm64')
self.assertEqual(self.check(pe(0x14C))['arch'], 'x86')
self.assertFalse(self.check(pe(0x8664, dll=True))['exe'])
def test_lying_headers_are_not_programs(self):
bad = bytearray(elf(0xB7))
struct.pack_into('<HH', bad, 54, 1, 1) # one-byte program header entries
self.assertFalse(self.check(bytes(bad))['exe'])
self.assertIsNone(self.check(b'MZ' + b'\0' * 0x3A + struct.pack('<I', 0xFFFFFFF0)))
def test_scripts_and_data(self):
self.assertEqual(self.check(b'#!/bin/sh\necho hi\n')['format'], 'script')
self.assertIsNone(self.check(b'MZ-not-really'))
self.assertIsNone(self.check(b'just text'))
class Targets(unittest.TestCase):
def tree(self, files):
root = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, root)
for rel, data in files.items():
p = os.path.join(root, *rel.split('/'))
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, 'wb') as f:
f.write(data)
return root
def plan(self, files, name):
return frame_titles.inspect(self.tree(files), name)
def test_unity_windows_build(self):
# UnityCrashHandler64.exe is bigger than the game's own exe; the name decides.
p = self.plan({'MyGame/MyGame.exe': pe(0x8664, pad=600),
'MyGame/UnityCrashHandler64.exe': pe(0x8664, pad=5000),
'MyGame/UnityPlayer.dll': pe(0x8664, dll=True, pad=9000),
'MyGame/MyGame_Data/Plugins/x86_64/steam_api64.dll': pe(0x8664, dll=True)}, 'MyGame')
self.assertEqual(p['target'], 'MyGame.exe')
self.assertEqual(p['runtime'], 'proton-experimental')
self.assertEqual(p['runtimes'], ['proton-experimental', 'proton-stable'])
crash = next(c for c in p['candidates'] if c['path'] == 'UnityCrashHandler64.exe')
self.assertTrue(crash['skip'])
self.assertNotIn('UnityPlayer.dll', [c['path'] for c in p['candidates']])
def test_unreal_prefers_top_level_bootstrap(self):
p = self.plan({'Game.exe': pe(0x8664, pad=200),
'Game/Binaries/Win64/Game-Win64-Shipping.exe': pe(0x8664, pad=9000),
'Engine/Extras/Redist/en-us/UEPrereqSetup_x64.exe': pe(0x8664, pad=9000)}, 'Game')
self.assertEqual(p['target'], 'Game.exe')
def test_installers_lose_to_the_game(self):
p = self.plan({'setup.exe': pe(0x8664, pad=9000), 'unins000.exe': pe(0x14C, pad=9000),
'_CommonRedist/vc_redist.x64.exe': pe(0x8664, pad=9000),
'Tool.exe': pe(0x8664)}, 'Something')
self.assertEqual(p['target'], 'Tool.exe')
def test_arm64_linux_build(self):
p = self.plan({'game.arm64': elf(0xB7, pad=100), 'lib/libfoo.so': elf(0xB7, interp=False, pad=900)}, 'game')
self.assertEqual((p['target'], p['runtime']), ('game.arm64', 'SteamLinuxRuntime_4-arm64'))
self.assertEqual(p['runtimes'], ['SteamLinuxRuntime_4-arm64'])
def test_x86_64_linux_build_warns_it_may_not_start(self):
p = self.plan({'game.x86_64': elf(0x3E)}, 'game')
self.assertEqual(p['runtime'], 'SteamLinuxRuntime_4')
self.assertIn("won't start", p['note'])
def test_native_arm64_beats_x86_64(self):
p = self.plan({'game.x86_64': elf(0x3E, pad=900), 'game.arm64': elf(0xB7)}, 'game')
self.assertEqual(p['target'], 'game.arm64')
def test_top_level_script_beats_nested_binary(self):
p = self.plan({'run.sh': b'#!/bin/sh\nexec bin/game\n', 'bin/game': elf(0xB7)}, 'game')
self.assertEqual(p['target'], 'run.sh')
self.assertEqual(p['runtime'], 'SteamLinuxRuntime_4-arm64')
def test_binary_beside_script_wins(self):
p = self.plan({'start.sh': b'#!/bin/sh\n', 'game': elf(0x3E)}, 'game')
self.assertEqual(p['target'], 'game')
def test_other_architectures_are_refused(self):
with self.assertRaisesRegex(FrameError, 'x86 Linux'):
self.plan({'game': elf(0x03)}, 'game')
with self.assertRaisesRegex(FrameError, 'no Linux or Windows program'):
self.plan({'readme.txt': b'hello'}, 'game')
def test_runtime_override_and_exe_choice(self):
p = self.plan({'A.exe': pe(0x8664), 'B.exe': pe(0x8664)}, 'A')
frame_titles._choose(p, 'B.exe', 'proton-stable')
self.assertEqual((p['target'], p['runtime']), ('B.exe', 'proton-stable'))
with self.assertRaises(FrameError):
frame_titles._choose(p, 'A.exe', 'SteamLinuxRuntime_4-arm64')
with self.assertRaises(FrameError):
frame_titles._choose(p, '../outside.exe')
def test_exe_path_from_above_the_unwrapped_folder(self):
# A manifest names the program as it is in the archive: Game/B.exe, not B.exe.
p = self.plan({'Game/A.exe': pe(0x8664), 'Game/B.exe': pe(0x8664)}, 'Game')
self.assertEqual(p['unwrapped'], 'Game')
frame_titles._choose(p, 'Game/B.exe')
self.assertEqual(p['target'], 'B.exe')
frame_titles._choose(p, 'Game\\A.exe')
self.assertEqual(p['target'], 'A.exe')
with self.assertRaises(FrameError):
frame_titles._choose(p, 'Game/../../outside.exe')
def test_root_relative_path_wins_over_archive_prefix(self):
# Game/Game/A.exe and Game/A.exe: 'Game/A.exe' is a real path under the root Game/.
p = self.plan({'Game/Game/A.exe': pe(0x8664), 'Game/A.exe': pe(0x8664)}, 'Game')
self.assertEqual(p['unwrapped'], 'Game')
frame_titles._choose(p, 'Game/A.exe')
self.assertEqual(p['target'], 'Game/A.exe')
@unittest.skipIf(os.name == 'nt', 'needs symlinks')
def test_prefix_is_taken_before_staging(self):
# A folder with a link is staged into a temporary copy; the prefix still names
# the folders stepped into in the original.
d = self.tree({'Game/A.exe': pe(0x8664)})
os.symlink('A.exe', os.path.join(d, 'Game', 'link.exe'))
p = frame_titles.inspect(d, 'Game')
try:
self.assertEqual(p['unwrapped'], 'Game')
self.assertTrue(p['work'])
finally:
frame_titles.discard(p)
class Zips(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.dir)
def zip(self, members, name='Cool Game-v1.2-win64.zip'):
p = os.path.join(self.dir, name)
with zipfile.ZipFile(p, 'w') as z:
for n, data in members.items():
z.writestr(n, data)
return p
def test_wrapper_folder_and_name(self):
plan = frame_titles.inspect(self.zip({'Cool Game/Cool Game.exe': pe(0x8664),
'__MACOSX/Cool Game/._Cool Game.exe': b'x'}))
try:
self.assertEqual(plan['name'], 'Cool Game')
self.assertEqual(plan['id'], 'Cool_Game')
self.assertEqual(plan['target'], 'Cool Game.exe')
self.assertTrue(os.path.isfile(os.path.join(plan['root'], 'Cool Game.exe')))
finally:
frame_titles.discard(plan)
self.assertFalse(os.path.exists(plan['work']))
def test_zip_slip_is_refused(self):
for bad in ('../evil.exe', 'ok/../../evil.exe', '/abs/evil.exe', 'C:/evil.exe', '..\\evil.exe'):
with self.subTest(bad=bad):
out = tempfile.mkdtemp(dir=self.dir)
with self.assertRaisesRegex(FrameError, 'absolute|climbs'):
frame_titles.extract_zip(self.zip({bad: pe(0x8664)}, 'bad.zip'), out)
self.assertFalse(os.path.exists(os.path.join(self.dir, 'evil.exe')))
def test_link_out_of_the_zip_is_refused(self):
p = os.path.join(self.dir, 'link.zip')
with zipfile.ZipFile(p, 'w') as z:
info = zipfile.ZipInfo('game/escape')
info.external_attr = (0o120777 << 16)
z.writestr(info, '../../etc/passwd')
with self.assertRaisesRegex(FrameError, 'outside'):
frame_titles.extract_zip(p, tempfile.mkdtemp(dir=self.dir))
def link_zip(self, members):
"""members: (name, data, is_link) in order."""
p = os.path.join(self.dir, 'links.zip')
with zipfile.ZipFile(p, 'w') as z:
for name, data, is_link in members:
info = zipfile.ZipInfo(name)
info.external_attr = ((0o120777 if is_link else 0o100644) << 16)
z.writestr(info, data)
return p
def test_chained_links_cannot_escape(self):
# alias -> . ; alias/alias/escape -> ../.. ; escape/victim would land outside if links were real.
p = self.link_zip([('alias', '.', True), ('alias/alias/escape', '../..', True), ('escape/victim', b'x', False)])
out = tempfile.mkdtemp(dir=self.dir)
frame_titles.extract_zip(p, out)
self.assertTrue(os.path.isfile(os.path.join(out, 'escape', 'victim'))) # stayed inside
self.assertFalse(os.path.exists(os.path.join(self.dir, 'victim')))
self.assertFalse(os.path.exists(os.path.join(os.path.dirname(self.dir), 'victim')))
for root, dirs, files in os.walk(out):
self.assertFalse([n for n in dirs + files if os.path.islink(os.path.join(root, n))])
def test_folder_links_are_dropped_and_order_does_not_matter(self):
# b -> a/file listed before a -> dir; and a folder link that would contain itself.
p = self.link_zip([('dir/file', b'data', False), ('b', 'a/file', True), ('a', 'dir', True),
('dir/sub/loop', '../../a', True)])
out = tempfile.mkdtemp(dir=self.dir)
frame_titles.extract_zip(p, out)
with open(os.path.join(out, 'b'), 'rb') as f:
self.assertEqual(f.read(), b'data')
self.assertFalse(os.path.lexists(os.path.join(out, 'a')))
self.assertFalse(os.path.lexists(os.path.join(out, 'dir', 'sub', 'loop')))
def test_link_components_resolve_before_parent_steps(self):
# alias -> dirlink/../game.exe, dirlink -> deep/subdir: that's deep/game.exe, not game.exe.
p = self.link_zip([('deep/subdir/x', b'', False), ('deep/game.exe', b'deep one', False),
('game.exe', b'top one', False), ('dirlink', 'deep/subdir', True),
('alias', 'dirlink/../game.exe', True)])
out = tempfile.mkdtemp(dir=self.dir)
frame_titles.extract_zip(p, out)
with open(os.path.join(out, 'alias'), 'rb') as f:
self.assertEqual(f.read(), b'deep one')
def test_many_links_to_one_file_count_against_the_limit(self):
# The zip (1 KB) and the copies (15 KB) each fit under the limit; together they don't.
members = [('big', b'x' * 1000, False)] + [(f'alias{i}', 'big', True) for i in range(15)]
old = frame_titles.MAX_UNPACKED
frame_titles.MAX_UNPACKED = 15500
out = tempfile.mkdtemp(dir=self.dir)
try:
with self.assertRaisesRegex(FrameError, 'links would copy'):
frame_titles.extract_zip(self.link_zip(members), out)
finally:
frame_titles.MAX_UNPACKED = old
self.assertEqual(os.listdir(out), ['big']) # refused before copying any link
def test_oversized_link_is_refused(self):
p = self.link_zip([('big', 'x' * 5000, True)])
with self.assertRaisesRegex(FrameError, 'oversized link'):
frame_titles.extract_zip(p, tempfile.mkdtemp(dir=self.dir))
@unittest.skipIf(os.name == 'nt', 'needs symlinks')
def test_unwrap_never_steps_through_a_link(self):
# A folder whose only entry links elsewhere (a junction on Windows) stays the boundary.
outside, game = os.path.join(self.dir, 'outside'), os.path.join(self.dir, 'Game')
os.makedirs(outside)
os.makedirs(game)
with open(os.path.join(outside, 'Other.exe'), 'wb') as f:
f.write(pe(0x8664))
os.symlink(outside, os.path.join(game, 'inner'))
with self.assertRaisesRegex(FrameError, 'no Linux or Windows program'):
frame_titles.inspect(game)
@unittest.skipIf(os.name == 'nt', 'needs symlinks')
def test_folder_with_outside_link_is_staged_without_it(self):
game, secret = os.path.join(self.dir, 'Game'), os.path.join(self.dir, 'secret')
os.makedirs(game)
os.makedirs(secret)
with open(os.path.join(secret, 'key'), 'wb') as f:
f.write(b'private')
with open(os.path.join(game, 'Game.exe'), 'wb') as f:
f.write(pe(0x8664))
os.symlink(secret, os.path.join(game, 'leak'))
os.symlink(os.path.join(secret, 'key'), os.path.join(game, 'leak-file'))
os.symlink('Game.exe', os.path.join(game, 'Alias.exe'))
plan = frame_titles.inspect(game)
try:
self.assertNotEqual(os.path.realpath(plan['root']), os.path.realpath(game))
self.assertEqual(sorted(os.listdir(plan['root'])), ['Alias.exe', 'Game.exe'])
self.assertFalse(os.path.islink(os.path.join(plan['root'], 'Alias.exe')))
finally:
frame_titles.discard(plan)
def test_links_become_copies(self):
# No symlinks on disk (Windows may not allow them); the library a link names is still there.
p = self.link_zip([('game/lib/libfoo.so.1.2', b'ELF-ish', False), ('game/lib/libfoo.so.1', 'libfoo.so.1.2', True),
('game/lib/libfoo.so', 'libfoo.so.1', True), ('game/dangling', 'nowhere', True)])
out = tempfile.mkdtemp(dir=self.dir)
frame_titles.extract_zip(p, out)
for name in ('libfoo.so.1', 'libfoo.so'):
path = os.path.join(out, 'game', 'lib', name)
self.assertFalse(os.path.islink(path))
with open(path, 'rb') as f:
self.assertEqual(f.read(), b'ELF-ish')
self.assertFalse(os.path.lexists(os.path.join(out, 'game', 'dangling')))
def test_drive_qualified_parts_are_refused(self):
for bad in ('sub/C:../C:../victim.txt', 'game/file.exe:stream'):
with self.subTest(bad=bad):
with self.assertRaisesRegex(FrameError, 'drive or stream'):
frame_titles.extract_zip(self.zip({bad: b'x'}, 'drive.zip'), tempfile.mkdtemp(dir=self.dir))
def test_absurd_size_is_refused(self):
p = self.zip({'game.exe': pe(0x8664)}, 'bomb.zip')
old = frame_titles.MAX_UNPACKED
frame_titles.MAX_UNPACKED = 10
try:
with self.assertRaisesRegex(FrameError, 'looks wrong'):
frame_titles.extract_zip(p, tempfile.mkdtemp(dir=self.dir))
finally:
frame_titles.MAX_UNPACKED = old
def test_not_a_zip(self):
p = os.path.join(self.dir, 'x.zip')
with open(p, 'wb') as f:
f.write(b'nope')
with self.assertRaisesRegex(FrameError, 'not a readable zip'):
frame_titles.inspect(p)
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('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(len(frame_titles.title_id('x' * 200)), 64)
with self.assertRaises(FrameError):
frame_titles.title_id('!!!')
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')
self.assertEqual(frame_titles.display_name('Game_v1.0.3_Win64.zip'), 'Game')
class Parms(unittest.TestCase):
def test_proton_parms(self):
p = frame_titles.shortcut_parms('Cool_Game', '/home/steamos/devkit-game/Cool_Game',
'Cool Game.exe', 'proton-experimental')
self.assertEqual(p, {'gameid': 'Cool_Game', 'directory': '/home/steamos/devkit-game/Cool_Game',
'argv': ['"Cool Game.exe"'], 'env': {},
'settings': {'steam_play': '1', 'steam_play_debug': '0',
'steam_play_debug_version': '2019',
'compat_tool': 'proton-experimental'},
'clear_settings': True, 'force_appid': '', 'lepton_args': ''})
json.dumps(p)
def test_linux_parms(self):
p = frame_titles.shortcut_parms('g', '/home/steamos/devkit-game/g', 'bin/game', 'SteamLinuxRuntime_4-arm64')
self.assertEqual(p['argv'], ['bin/game'])
self.assertEqual(p['settings'], {'steam_play': '0', 'compat_tool': 'SteamLinuxRuntime_4-arm64'})
def test_cleanup_names_only_this_title(self):
# A glob like Game-*.json would also delete Game-Deluxe's files.
self.assertEqual(frame_titles._json_files('Game').split(),
['devkit-game/Game-argv.json', 'devkit-game/Game-env.json',
'devkit-game/Game-settings.json', 'devkit-game/Game-framecontrol.json'])
def test_launch_needs_steam_to_answer(self):
# steam-devkit-rpc exits 0 after a timeout; only its 'success' line means Steam took it.
calls = []
old = frame_titles.ssh, frame_titles._check_id, frame_titles.ensure_utils
frame_titles._check_id, frame_titles.ensure_utils = (lambda g: g), (lambda: False)
try:
frame_titles.ssh = lambda cmd, **kw: calls.append(cmd) or 'Found steam client pid 1\ntimeout\n'
with self.assertRaisesRegex(FrameError, "didn't confirm"):
frame_titles.launch('Game')
frame_titles.ssh = lambda cmd, **kw: 'Found steam client pid 1\nsuccess\n{}'
self.assertEqual(frame_titles.launch('Game'), {'id': 'Game'})
finally:
frame_titles.ssh, frame_titles._check_id, frame_titles.ensure_utils = old
self.assertIn('steam-devkit-rpc run-game gameid=Game', calls[0])
def test_remove_waits_for_installs(self):
with frame_titles._install_lock:
with self.assertRaisesRegex(FrameError, 'install is running'):
frame_titles.remove('Game')
def test_vendored_utils_are_present(self):
for name in ('steamos-prepare-upload', 'steam-client-create-shortcut', 'steam-devkit-rpc',
'steamos-delete', 'devkit_utils/__init__.py', 'LICENSE'):
self.assertTrue(os.path.isfile(os.path.join(frame_titles.UTILS_LOCAL, name)), name)
self.assertEqual(len(frame_titles.utils_stamp()), 20)
if __name__ == '__main__':
unittest.main()
-207
View File
@@ -1,207 +0,0 @@
"""Frame Control server checks that need no headset.
Starts ui/server.py against an SSH alias that can't resolve, then exercises the
request guards and input validation, which all run before any SSH call.
Run: python3 -m unittest discover -s tests
"""
import http.client
import io
import json
import os
import socket
import struct
import subprocess
import sys
import tempfile
import time
import unittest
import zipfile
from pathlib import Path
from urllib.parse import quote
ROOT = Path(__file__).resolve().parent.parent
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class ServerGuards(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.port = free_port()
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1"}
cls.log = tempfile.TemporaryFile()
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
env=env, stdout=cls.log, stderr=subprocess.STDOUT)
for _ in range(100):
try:
if cls.request("GET", "/")[0] == 200:
return
except Exception:
pass
time.sleep(0.05)
cls.proc.kill()
cls.log.seek(0)
raise RuntimeError("server didn't start:\n" + cls.log.read().decode(errors="replace"))
@classmethod
def tearDownClass(cls):
cls.proc.terminate()
cls.proc.wait(timeout=10)
cls.log.close()
@classmethod
def request(cls, method, path, body=None, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", cls.port, timeout=10)
data = body if isinstance(body, bytes) else json.dumps(body).encode() if body is not None else None
conn.request(method, path, body=data, headers=headers or {})
r = conn.getresponse()
payload = r.read()
conn.close()
return r.status, dict(r.getheaders()), payload
def post(self, path, body):
status, _, payload = self.request("POST", path, body, {"X-Frame-UI": "1", "Content-Type": "application/json"})
return status, json.loads(payload)
def test_page_served_with_identifying_and_anti_framing_headers(self):
status, headers, payload = self.request("GET", "/")
self.assertEqual(status, 200)
self.assertTrue(headers["Server"].startswith("FrameControl"))
self.assertEqual(headers["X-Frame-Options"], "DENY")
self.assertIn(b"<html", payload.lower())
def test_foreign_host_rejected(self):
# DNS rebinding: a hostile name pointed at 127.0.0.1.
for path in ("/", "/api/status"):
status, _, _ = self.request("GET", path, headers={"Host": f"evil.example:{self.port}", "X-Frame-UI": "1"})
self.assertEqual(status, 403, path)
def test_api_needs_custom_header(self):
# <img src> and plain form posts from other sites can't set it.
self.assertEqual(self.request("GET", "/api/status")[0], 403)
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
self.assertEqual(self.request("GET", "/api/shots")[0], 403)
self.assertEqual(self.request("GET", "/api/stream")[0], 403)
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403)
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
def test_captures_are_not_cacheable(self):
# Headset captures show everything on screen; nothing may cache them.
_, headers, _ = self.request("GET", "/api/screenshot", headers={"X-Frame-UI": "1"})
self.assertEqual(headers.get("Cache-Control"), "no-store")
self.assertIn("frame-ancestors 'none'", headers.get("Content-Security-Policy", ""))
def test_input_validation(self):
cases = [
("/api/launch", {"appid": "620; rm -rf ~"}),
("/api/launch", {"appid": ""}),
("/api/flatpak", {"id": "org.example.App;id", "action": "install"}),
("/api/flatpak", {"id": "org.example.App", "action": "explode"}),
("/api/volume", {"level": 1.5}),
("/api/clipboard", {"text": ""}),
("/api/open", {"what": "anything-else"}),
("/api/shots/save", {"ids": []}),
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
("/api/shots/save", {"ids": [1]}),
("/api/shots/save", {"ids": ["1/250820/../../.ssh/id_ed25519"]}),
("/api/shots/save", {"ids": ["1/250820/20260925225208_1.jpg; rm -rf ~"]}),
]
for path, body in cases:
status, payload = self.post(path, body)
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
def test_screenshot_ids_checked_before_ssh(self):
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
self.assertEqual(status, 400, shot)
def test_stream_settings_checked_before_ssh(self):
for query in ("h=480", "fps=24", "h=abc", "h=1080&fps=120"):
status, _, _ = self.request("GET", f"/api/stream?{query}", headers={"X-Frame-UI": "1"})
self.assertEqual(status, 400, query)
def test_bad_bodies(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"})
self.assertEqual(conn.getresponse().status, 400)
conn.close()
status, _ = self.post("/api/launch", ["not", "an", "object"])
self.assertEqual(status, 400)
def test_title_upload_is_inspected_then_discarded(self):
# A zip holding a Windows x86-64 program: inspected locally, no SSH until install.
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("Tiny Game/Tiny Game.exe",
b"MZ" + b"\0" * 0x3A + struct.pack("<I", 0x40) + b"PE\0\0" + struct.pack("<HHIIIHH", 0x8664, 1, 0, 0, 0, 0xF0, 0x22))
status, _, payload = self.request("POST", "/api/upload", buf.getvalue(),
{"X-Frame-UI": "1", "X-Mode": "title", "X-Filename": quote("Tiny Game-win64.zip")})
r = json.loads(payload)
self.assertEqual(status, 200, r)
self.assertEqual((r["plan"]["id"], r["plan"]["target"], r["plan"]["runtime"]),
("Tiny_Game", "Tiny Game.exe", "proton-experimental"))
self.assertNotIn("root", r["plan"])
self.assertEqual(self.post("/api/titles", {"action": "discard", "token": r["token"]})[0], 200)
self.assertEqual(self.post("/api/titles", {"action": "install", "token": r["token"]})[0], 400)
def test_title_input_validation(self):
status, _, _ = self.request("POST", "/api/upload", b"not a zip",
{"X-Frame-UI": "1", "X-Mode": "title", "X-Filename": "x.zip"})
self.assertEqual(status, 400)
for body in ({"action": "inspect", "path": "relative/game.zip"},
{"action": "inspect", "path": "/nonexistent/frame-control/game.zip"},
{"action": "install", "token": "nope"},
{"action": "launch", "id": "x; rm -rf ~"},
{"action": "remove", "id": "../etc"},
{"action": "explode"}):
status, payload = self.post("/api/titles", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
self.assertEqual(self.request("GET", "/api/titles/job?token=nope", headers={"X-Frame-UI": "1"})[0], 404)
self.assertEqual(self.request("POST", "/api/titles", {"action": "list"})[0], 403)
def test_web_install_needs_the_app_page(self):
# A website can only open frame-control:// links; it can't call these itself.
link = {"url": "https://cdn.example.com/game.apk"}
self.assertEqual(self.request("POST", "/api/webinstall/check", link)[0], 403)
self.assertEqual(self.request("POST", "/api/webinstall/start", {"id": "x"})[0], 403)
status, _, _ = self.request("POST", "/api/webinstall/check", link,
{"X-Frame-UI": "1", "Host": f"evil.example:{self.port}"})
self.assertEqual(status, 403)
def test_web_install_validation(self):
for body in ({}, {"url": 5}, {"url": "http://cdn.example.com/game.apk"}, {"url": "https://10.0.0.2/game.apk"},
{"url": "https://u:p@example.com/game.apk"}, {"url": "https://example.com/"},
{"url": "https://1.1.1.1/game.sh"}, {"manifest": "file:///etc/passwd"},
{"manifest": "https://example.com/m.json", "url": "https://example.com/g.apk"}):
status, payload = self.post("/api/webinstall/check", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
# Only an id from /check starts an install, and only once.
self.assertEqual(self.post("/api/webinstall/start", {"id": "made-up"})[0], 400)
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_unknown_routes(self):
self.assertEqual(self.request("GET", "/nope")[0], 404)
self.assertEqual(self.post("/api/nope", {})[0], 404)
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")
def test_runs_off_device_and_prints_one_json_object(self):
# The probe runs on the Frame; elsewhere every field must degrade to null/empty.
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_status.py")],
capture_output=True, text=True, timeout=60)
self.assertEqual(out.returncode, 0, out.stderr)
data = json.loads(out.stdout)
for key in ("hostname", "battery", "disk", "services", "games", "flatpaks"):
self.assertIn(key, data)
if __name__ == "__main__":
unittest.main()
-105
View File
@@ -1,105 +0,0 @@
"""Get games checks that need no headset or network.
Run: python3 -m unittest discover -s tests
"""
import json
import subprocess
import sys
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_store # noqa: E402
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
class SteamRoutes(test_server.ServerGuards):
"""Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH."""
def test_steam_input_validation(self):
for body in ({"action": "install", "appid": "620; reboot"}, {"action": "install", "appid": ""},
{"action": "uninstall", "appid": "620"}, {"appid": "620"}):
status, payload = self.post("/api/steam", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
def test_search_needs_country(self):
for query in ("q=portal", "q=portal&cc=A", "q=portal&cc=AU1", "q=portal&cc=%27x"):
status, _, _ = self.request("GET", f"/api/steam/search?{query}", headers={"X-Frame-UI": "1"})
self.assertEqual(status, 400, query)
def test_steam_routes_need_custom_header(self):
self.assertEqual(self.request("GET", "/api/steam/owned")[0], 403)
self.assertEqual(self.request("GET", "/api/steam/search?q=x&cc=AU")[0], 403)
self.assertEqual(self.request("POST", "/api/steam", {"action": "install", "appid": "620"})[0], 403)
# Don't rerun the inherited ServerGuards tests under this module.
for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]:
setattr(SteamRoutes, name, None)
class FrameSteamHelper(unittest.TestCase):
def test_bad_usage_prints_json_error(self):
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_steam.py"), *args],
capture_output=True, text=True, timeout=30)
self.assertEqual(out.returncode, 1, args)
self.assertIn("error", json.loads(out.stdout), args)
class HelperErrors(unittest.TestCase):
def test_json_error_found_despite_ssh_stderr(self):
import server
noise = server.Failure("Warning: Permanently added 'frame' to the list of known hosts.")
noise.stdout = '{"error": "Steam\'s UI isn\'t answering"}\n'
with mock.patch.object(server, "ssh", side_effect=noise):
with self.assertRaises(server.Failure) as cm:
server.steam_frame("owned")
self.assertEqual(str(cm.exception), "Steam's UI isn't answering")
def test_other_failures_pass_through(self):
import server
with mock.patch.object(server, "ssh", side_effect=server.Failure("Timed out talking to frame")):
with self.assertRaises(server.Failure) as cm:
server.steam_frame("owned")
self.assertEqual(str(cm.exception), "Timed out talking to frame")
class StoreSearch(unittest.TestCase):
def setUp(self):
frame_store._compat.clear()
def test_keeps_apps_and_attaches_frame_rating(self):
def fake_get(path, params, timeout=10):
if path == "api/storesearch":
self.assertEqual(params["cc"], "AU")
return {"items": [{"type": "app", "id": 620, "name": "Portal 2", "price": {"final": 1450}},
{"type": "sub", "id": 7, "name": "Bundle"}]}
return {"results": {"frame_resolved_category": 3}}
with mock.patch.object(frame_store, "_get", side_effect=fake_get):
results = frame_store.search("portal", "AU")
self.assertEqual([(r["id"], r["frame"]) for r in results], [(620, 3)])
def test_rating_failure_is_unknown_and_not_cached(self):
with mock.patch.object(frame_store, "_get", side_effect=OSError("offline")):
self.assertEqual(frame_store.frame_rating(620), 0)
self.assertNotIn(620, frame_store._compat)
def test_malformed_rating_is_unknown(self):
for payload in ({"results": []}, {"results": {"frame_resolved_category": {}}},
{"results": {"frame_resolved_category": 9}}):
frame_store._compat.clear()
with mock.patch.object(frame_store, "_get", return_value=payload):
self.assertEqual(frame_store.frame_rating(620), 0, payload)
def test_blank_query_makes_no_request(self):
with mock.patch.object(frame_store, "_get") as get:
self.assertEqual(frame_store.search(" ", "AU"), [])
get.assert_not_called()
if __name__ == "__main__":
unittest.main()
-438
View File
@@ -1,438 +0,0 @@
"""Install links from websites (ui/frame_webinstall.py, app/install-link.js). No network:
name lookups are stubbed and downloads come from a server on 127.0.0.1, which
the localhost-testing rule allows.
Run: python3 -m unittest discover -s tests
"""
import hashlib
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_webinstall as wi # noqa: E402
E = wi.WebInstallError
PAYLOAD = b"not really an apk, but bytes are bytes\n" * 1000
def fake_dns(*ips):
return lambda host, port, **_: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port)) for ip in ips]
class Urls(unittest.TestCase):
def test_https_ok(self):
self.assertEqual(wi.check_url("https://cdn.example.com/g/mygame.apk"), ("https", "cdn.example.com", 443, False))
self.assertEqual(wi.file_name("https://cdn.example.com/g/my%20game.apk?sig=1"), "my game.apk")
def test_http_only_for_localhost(self):
with self.assertRaises(E):
wi.check_url("http://cdn.example.com/mygame.apk")
self.assertTrue(wi.check_url("http://localhost:8000/mygame.apk", allow_local=True)[3])
self.assertTrue(wi.check_url("http://127.0.0.1:8000/mygame.apk", allow_local=True)[3])
def test_localhost_only_when_the_link_starts_there(self):
for url in ("http://localhost/x.apk", "https://127.0.0.1/x.apk"):
with self.assertRaises(E):
wi.check_url(url, allow_local=False)
def test_other_schemes_rejected(self):
for url in ("file:///etc/passwd", "ftp://example.com/x.apk", "javascript:alert(1)", "//example.com/x.apk", ""):
with self.assertRaises(E, msg=url):
wi.check_url(url)
def test_private_and_local_addresses_rejected(self):
for host in ("10.0.0.5", "192.168.1.20", "172.16.3.4", "127.0.0.2", "169.254.169.254", "100.64.1.1",
"0.0.0.0", "[::1]", "[fe80::1]", "[fd00::1]", "[fec0::1]", "[::ffff:192.168.1.1]",
"[2002:c0a8:101::1]", "224.0.0.1"):
with self.assertRaises(E, msg=host):
wi.check_url(f"https://{host}/x.apk")
wi.check_url("https://93.184.216.34/x.apk")
def test_names_resolving_to_private_addresses_rejected(self):
with mock.patch.object(wi, "_getaddrinfo", fake_dns("192.168.1.9")):
with self.assertRaises(E):
wi._resolve("sneaky.example.com", 443, False)
# Every address counts, not just the first.
with mock.patch.object(wi, "_getaddrinfo", fake_dns("93.184.216.34", "10.1.2.3")):
with self.assertRaises(E):
wi._resolve("mixed.example.com", 443, False)
with mock.patch.object(wi, "_getaddrinfo", fake_dns("93.184.216.34")):
self.assertEqual(wi._resolve("cdn.example.com", 443, False), "93.184.216.34")
def test_credentials_rejected(self):
for url in ("https://user:pw@example.com/x.apk", "https://user@example.com/x.apk", "https://:pw@example.com/x.apk"):
with self.assertRaises(E, msg=url):
wi.check_url(url)
def test_directory_urls_rejected(self):
for url in ("https://example.com/", "https://example.com", "https://example.com/games/",
"https://example.com/%2e%2e", "https://example.com/.hidden.apk", "https://example.com/a%2Fb.apk"):
with self.assertRaises(E, msg=url):
wi.file_name(url)
def test_file_types(self):
self.assertEqual(wi.file_kind("Game.APK"), "apk")
self.assertEqual(wi.file_kind("game.zip"), "title")
self.assertEqual(wi.file_kind("setup.exe"), "title")
for name in ("game.sh", "game.tar.gz", "game"):
with self.assertRaises(E, msg=name):
wi.file_kind(name)
class Manifests(unittest.TestCase):
FILE = {"url": "https://cdn.example.com/mygame-arm64.apk"}
def test_both_schemas(self):
for schema in ("framedrop.install/v1", "frame-control.install/v1"):
m = wi.parse_manifest({"schema": schema, "name": "My Game", "files": [dict(self.FILE, sha256="AB" * 32)]})
self.assertEqual(m["name"], "My Game")
self.assertEqual(m["file"]["url"], self.FILE["url"])
self.assertEqual(m["file"]["sha256"], "ab" * 32)
def test_bad_schema(self):
for schema in (None, "framedrop.install/v2", "something"):
with self.assertRaises(E, msg=schema):
wi.parse_manifest({"schema": schema, "files": [self.FILE]})
def test_missing_or_bad_fields(self):
base = {"schema": "framedrop.install/v1"}
for obj in ([], base, dict(base, files=[]), dict(base, files="x"), dict(base, files=[{}]),
dict(base, files=[{"url": ""}]), dict(base, files=[dict(self.FILE, sha256="abc")]),
dict(base, files=[dict(self.FILE, size=-1)]), dict(base, name=5, files=[self.FILE])):
with self.assertRaises(E, msg=obj):
wi.parse_manifest(obj)
def test_name_optional_and_cleaned(self):
self.assertIsNone(wi.parse_manifest({"schema": "framedrop.install/v1", "files": [self.FILE]})["name"])
m = wi.parse_manifest({"schema": "framedrop.install/v1", "name": " A\x1b[31mB\n ", "files": [self.FILE]})
self.assertEqual(m["name"], "A[31mB")
def test_multiple_files_refused_clearly(self):
with self.assertRaisesRegex(E, "2 files"):
wi.parse_manifest({"schema": "framedrop.install/v1", "files": [self.FILE, self.FILE]})
class Stub(BaseHTTPRequestHandler):
routes = {}
def log_message(self, *_):
pass
def do_HEAD(self):
self.do_GET(body=False)
def do_GET(self, body=True):
route = self.routes.get(self.path)
if route is None:
self.send_response(404)
self.end_headers()
return
status, headers, data = route
self.send_response(status)
for k, v in headers.items():
self.send_header(k, v)
if "Content-Length" not in headers:
self.send_header("Content-Length", str(len(data)))
self.end_headers()
if body:
self.wfile.write(data)
class Downloads(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
cls.base = f"http://127.0.0.1:{cls.httpd.server_address[1]}"
threading.Thread(target=cls.httpd.serve_forever, daemon=True).start()
sha = hashlib.sha256(PAYLOAD).hexdigest()
Stub.routes = {
"/game.apk": (200, {}, PAYLOAD),
"/game.zip": (200, {}, PAYLOAD),
"/redirect.apk": (302, {"Location": "/game.apk"}, b""),
"/to-lan.apk": (302, {"Location": "https://192.168.1.5/game.apk"}, b""),
"/to-http.apk": (302, {"Location": "http://cdn.example.com/game.apk"}, b""),
"/loop.apk": (302, {"Location": "/loop.apk"}, b""),
"/manifest.json": (200, {}, json.dumps({"schema": "framedrop.install/v1", "name": "Stub Game",
"files": [{"url": f"{cls.base}/game.apk", "sha256": sha}]}).encode()),
"/bad-sha.json": (200, {}, json.dumps({"schema": "frame-control.install/v1", "name": "Bad",
"files": [{"url": f"{cls.base}/game.apk", "sha256": "0" * 64}]}).encode()),
"/huge.json": (200, {}, b"{" + b" " * (wi.MAX_MANIFEST + 10) + b"}"),
"/notjson.json": (200, {}, b"<html>"),
"/short.apk": (200, {"Content-Length": str(len(PAYLOAD) + 100)}, PAYLOAD),
}
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
def setUp(self):
self.tmp = tempfile.mkdtemp()
env = mock.patch.dict(os.environ, {wi.LOCAL_LINKS_ENV: "1"})
env.start()
self.addCleanup(env.stop)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_localhost_links_need_the_developer_switch(self):
# Without it, a website's link can't make the app fetch from local services.
with mock.patch.dict(os.environ, {wi.LOCAL_LINKS_ENV: ""}):
for kw in ({"manifest": f"{self.base}/manifest.json"}, {"url": f"{self.base}/game.apk"}):
with self.assertRaisesRegex(wi.WebInstallError, wi.LOCAL_LINKS_ENV):
wi.plan(**kw)
def test_manifest_round_trip(self):
p = wi.plan(manifest=f"{self.base}/manifest.json")
self.assertEqual((p["name"], p["file"], p["kind"], p["host"], p["size"]),
("Stub Game", "game.apk", "apk", "127.0.0.1", len(PAYLOAD)))
seen = []
path = wi.download(p, self.tmp, progress=lambda done, total: seen.append((done, total)))
self.assertEqual(Path(path).read_bytes(), PAYLOAD)
self.assertEqual(seen[-1], (len(PAYLOAD), len(PAYLOAD)))
self.assertEqual(os.listdir(self.tmp), ["game.apk"])
def test_direct_url_and_redirect(self):
p = wi.plan(url=f"{self.base}/redirect.apk")
self.assertEqual((p["name"], p["file"]), ("redirect.apk", "redirect.apk"))
self.assertEqual(Path(wi.download(p, self.tmp)).read_bytes(), PAYLOAD)
def test_redirects_checked_again(self):
for path in ("/to-lan.apk", "/to-http.apk", "/loop.apk"):
with self.assertRaises(E, msg=path):
wi._open(f"{self.base}{path}", allow_local=True)
def test_sha256_mismatch_leaves_nothing(self):
p = wi.plan(manifest=f"{self.base}/bad-sha.json")
with self.assertRaisesRegex(E, "sha256"):
wi.download(p, self.tmp)
self.assertEqual(os.listdir(self.tmp), [])
def test_size_cap(self):
with mock.patch.object(wi, "MAX_FILE", 1000):
with self.assertRaisesRegex(E, "limit"):
wi.plan(url=f"{self.base}/game.apk")
p = {"url": f"{self.base}/game.apk", "file": "game.apk", "allowLocal": True, "size": None, "sha256": None}
with self.assertRaisesRegex(E, "limit"):
wi.download(p, self.tmp)
self.assertEqual(os.listdir(self.tmp), [])
def test_bad_manifests(self):
for path in ("/huge.json", "/notjson.json", "/missing.json"):
with self.assertRaises(E, msg=path):
wi.plan(manifest=f"{self.base}{path}")
def test_cut_off_download(self):
p = {"url": f"{self.base}/short.apk", "file": "short.apk", "allowLocal": True, "size": None, "sha256": None}
with self.assertRaises(E):
wi.download(p, self.tmp)
self.assertEqual(os.listdir(self.tmp), [])
def test_aborted_connection_never_connects(self):
port = self.httpd.server_address[1]
for cls in (wi._HTTPConnection, wi._HTTPSConnection):
conn = cls("127.0.0.1", "127.0.0.1", port, 5)
wi.abort(conn) # before connect, e.g. cancelled while looking up the name
with self.assertRaisesRegex(OSError, "aborted"):
conn.connect()
def test_cancel(self):
p = wi.plan(url=f"{self.base}/game.apk")
with self.assertRaises(wi.Cancelled):
wi.download(p, self.tmp, cancelled=lambda: True)
self.assertEqual(os.listdir(self.tmp), [])
class Dispatch(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def file(self, name):
path = os.path.join(self.tmp, name)
Path(path).write_bytes(PAYLOAD)
return path
def test_apk_goes_to_the_android_installer(self):
import frame_android
with mock.patch.object(frame_android, "install", return_value={"label": "Stub"}) as install:
res = wi.dispatch(self.file("game.apk"), name="Ignored", source="https://example.com/game.apk")
install.assert_called_once_with(os.path.join(self.tmp, "game.apk"), source="https://example.com/game.apk")
self.assertEqual(res["kind"], "apk")
self.assertIn("Stub", res["message"])
def test_titles_without_the_titles_module(self):
with mock.patch.dict(sys.modules, {"frame_titles": None}):
with self.assertRaisesRegex(E, "newer Frame Control"):
wi.dispatch(self.file("game.zip"))
def test_titles_go_to_frame_titles(self):
fake = mock.Mock()
fake.install.return_value = {"message": "Installed Stub"}
with mock.patch.dict(sys.modules, {"frame_titles": fake}):
res = wi.dispatch(self.file("game.exe"), name="Stub", exe=None)
fake.install.assert_called_once_with(os.path.join(self.tmp, "game.exe"), name="Stub", exe=None, progress=None)
self.assertEqual(res["message"], "Installed Stub")
def test_other_files_refused(self):
with self.assertRaises(E):
wi.dispatch(self.file("game.sh"))
class ServerJobs(unittest.TestCase):
"""The server's install worker (ui/server.py), with download and dispatch stubbed."""
@classmethod
def setUpClass(cls):
with mock.patch.dict(os.environ, {"FRAME_ALIAS": "frame-control-test.invalid"}):
import server
cls.server = server
def run_job(self, download=None, mkdtemp_error=None):
s = self.server
job = {"phase": "download", "done": 0, "total": None, "detail": "", "message": None, "error": None, "cancel": False}
plan = {"name": "Stub", "exe": None, "url": "https://example.com/stub.apk"}
with mock.patch.object(s, "ensure_master"), \
mock.patch.object(s.frame_webinstall, "download", side_effect=lambda *a, **k: download(job, a[1])), \
mock.patch.object(s.tempfile, "mkdtemp", side_effect=mkdtemp_error or tempfile.mkdtemp), \
mock.patch.object(s.frame_webinstall, "dispatch", return_value={"message": "ok"}) as dispatch:
s._webinstall_run(plan, job)
return job, dispatch
def test_cancel_after_the_last_chunk_still_stops_the_install(self):
def download(job, tmp):
job["cancel"] = True # arrives after the downloader's last check
return os.path.join(tmp, "stub.apk")
job, dispatch = self.run_job(download)
dispatch.assert_not_called()
self.assertEqual(job["phase"], "error")
def test_finished_download_is_dispatched(self):
job, dispatch = self.run_job(lambda job, tmp: os.path.join(tmp, "stub.apk"))
dispatch.assert_called_once()
self.assertEqual((job["phase"], job["message"]), ("done", "ok"))
def stall_then_shutdown(self, scheme, reply):
if os.name == "nt":
# shutdown() from another thread doesn't wake a blocked recv on Windows, and
# closing the handle under a TLS read isn't safe; see web-install.md.
self.skipTest("Windows: a stalled download is only dropped when the app stops the server")
"""Start a download from a server that stalls after sending reply; shutdown must stop it quickly."""
stall = socket.socket()
stall.bind(("127.0.0.1", 0))
stall.listen(1)
port = stall.getsockname()[1]
stalled = threading.Event()
def serve():
c, _ = stall.accept()
if reply is not None:
c.recv(65536)
c.sendall(reply)
stalled.set()
time.sleep(20) # longer than the test may take; TIMEOUT is 30 s
c.close()
threading.Thread(target=serve, daemon=True).start()
s = self.server
pid = "shutdown-test"
s._web_plans[pid] = {"name": "Stub", "exe": None, "url": f"{scheme}://127.0.0.1:{port}/stub.apk",
"file": "stub.apk", "allowLocal": True, "size": None, "sha256": None,
"sizeFromManifest": False}
try:
s.webinstall_start({"id": pid})
job = s._web_jobs[pid]
self.assertTrue(stalled.wait(5))
time.sleep(0.1) # let the client block
t0 = time.time()
s.webinstall_shutdown()
self.assertLess(time.time() - t0, 3)
self.assertEqual(s._web_workers, set())
self.assertEqual((job["phase"], job["error"]), ("error", "download cancelled"))
s._web_plans["late"] = {"size": None}
with self.assertRaises(s.Failure) as caught: # nothing new starts once quitting
s.webinstall_start({"id": "late"})
self.assertEqual(caught.exception.status, 503)
finally:
s._web_closing = False
s._web_jobs.clear()
s._web_plans.clear()
stall.close()
def test_shutdown_interrupts_a_stalled_body(self):
self.stall_then_shutdown("http", b"HTTP/1.0 200 OK\r\nContent-Length: 1000000\r\n\r\npartial")
def test_shutdown_interrupts_stalled_headers(self):
self.stall_then_shutdown("http", b"HTTP/1.1 200 OK\r\n")
def test_shutdown_interrupts_a_stalled_tls_handshake(self):
self.stall_then_shutdown("https", None)
def test_dead_servers_leftovers_swept(self):
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
# Downloads and title staging (unzipped titles) are both swept.
for prefix in (self.server.WEB_TMP_PREFIX, self.server.frame_titles.TMP_PREFIX):
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-")
live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-")
try:
self.server.sweep_tmp()
self.assertFalse(os.path.exists(gone), prefix)
self.assertTrue(os.path.exists(live), prefix)
finally:
shutil.rmtree(gone, ignore_errors=True)
shutil.rmtree(live, ignore_errors=True)
def test_temp_dir_failure_ends_the_job(self):
job, dispatch = self.run_job(mkdtemp_error=OSError("disk full"))
dispatch.assert_not_called()
self.assertEqual(job["phase"], "error")
self.assertIn("disk full", job["error"])
@unittest.skipUnless(shutil.which("node"), "needs node")
class LinkParsing(unittest.TestCase):
def parse(self, links):
script = ("const { parseInstallLink, linkFromArgv } = require(process.argv[1]);"
"const links = JSON.parse(process.argv[2]);"
"console.log(JSON.stringify({ parsed: links.map(parseInstallLink),"
" argv: linkFromArgv(['/x/frame-control', '--flag', links[0]]) }));")
out = subprocess.run(["node", "-e", script, str(ROOT / "app" / "install-link.js"), json.dumps(links)],
capture_output=True, text=True, timeout=30)
self.assertEqual(out.returncode, 0, out.stderr)
return json.loads(out.stdout)
def test_links(self):
m = "https://example.com/m.json"
good = ["frame-control://install?manifest=" + "https%3A%2F%2Fexample.com%2Fm.json",
"frame-control://install/?url=https%3A%2F%2Fcdn.example.com%2Fg.apk",
"FRAME-CONTROL://install?manifest=http%3A%2F%2Flocalhost%3A8000%2Fm.json"]
bad = ["framedrop://install?manifest=" + m, "frame-control://uninstall?manifest=" + m,
"frame-control://install?manifest=" + m + "&url=" + m, "frame-control://install?manifest=a&manifest=b",
"frame-control://install?manifest=file%3A%2F%2F%2Fetc%2Fpasswd", "frame-control://install?other=" + m,
"frame-control://install?url=https%3A%2F%2Fu%3Ap%40example.com%2Fg.apk", "frame-control://install",
"frame-control://install/sub?url=" + m, "https://example.com"]
res = self.parse(good + bad)
self.assertEqual(res["parsed"][0], {"kind": "manifest", "target": m})
self.assertEqual(res["parsed"][1], {"kind": "url", "target": "https://cdn.example.com/g.apk"})
self.assertEqual(res["parsed"][2]["kind"], "manifest")
self.assertEqual(res["parsed"][len(good):], [None] * len(bad))
self.assertEqual(res["argv"], good[0])
if __name__ == "__main__":
unittest.main()
-295
View File
@@ -1,295 +0,0 @@
"""Android apps on the Frame, each in its own persistent Lepton instance.
Every APK gets ~/Applications/Android/<package>/ on the Frame with app.apk,
launch.sh (frame/android/lepton-app.sh), instance.id, meta.json and, for 2D
apps, the lepton-show-flatscreen marker; plus a non-Steam shortcut, so it shows
in the Steam library and gets its own SteamVR panel. Nothing goes through
Lepton Development, which wipes its apps on exit. See docs/apks.md.
Python stdlib only. CLI: python3 ui/frame_android.py {install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
"""
import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
import frame_apk
import frame_host
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
APPS_DIR = 'Applications/Android' # relative to the Frame's $HOME
COMPAT = '.local/share/Steam/steamapps/compatdata'
SHADERS = '.local/share/Steam/steamapps/shadercache'
LAUNCHER = os.path.join(ROOT, 'frame', 'android', 'lepton-app.sh')
SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py')
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
class FrameError(RuntimeError):
pass
def ssh(cmd, input=None, timeout=120):
try:
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
except subprocess.TimeoutExpired:
raise FrameError(f'timed out talking to {FRAME}')
if p.returncode != 0:
raise FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
return p.stdout
def shortcut_tool(*args, timeout=60):
with open(SHORTCUTS) as f:
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
timeout=timeout).strip()
def instance_id(pkg):
# Stable per package, well above real Steam app ids, below 2^32. T3 Code's
# hand-picked 2873873873 sits outside this range.
return 2800000000 + zlib.crc32(pkg.encode()) % 70000000
def game_id(shortcut_appid):
return (int(shortcut_appid) << 32) | 0x02000000
def apk_info(path):
"""Package, label, version, native ABIs and the best PNG icon inside the APK."""
try:
return frame_apk.apk_info(path)
except frame_apk.ApkError as e:
raise FrameError(f'{os.path.basename(path)}: {e}')
def check_installable(info):
if info['min_sdk'] and info['min_sdk'] > 30:
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
if info['abis'] and 'arm64-v8a' not in info['abis']:
raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only")
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
def _copy(src, dest, executable=False, timeout=600):
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
name = os.path.basename(src)
rsync = None if frame_host.WINDOWS else shutil.which('rsync') # see server.push_file
if rsync:
cmd = ['rsync', '-a', *(['--chmod=u+x'] if executable else []),
'-e', shlex.join(['ssh', *SSH_OPTS]), src, f'{FRAME}:{dest}']
else:
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
try:
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
raise FrameError(f'copying {name} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
if executable and not rsync:
ssh(f'chmod u+x {shlex.quote(dest)}')
def _shortcut_ids():
try:
return {int(x['appid']) for x in json.loads(shortcut_tool('list'))}
except (ValueError, TypeError, KeyError) as e:
raise FrameError(f'could not read the Steam shortcut list: {e}')
def _write_meta(d, meta):
# Write then rename, so a dropped connection can't leave torn JSON behind.
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None):
info = apk_info(apk_path)
if icon_png:
info['icon_png'] = icon_png
check_installable(info)
pkg = info['package']
if not PKG_RE.match(pkg):
raise FrameError(f'unexpected package name {pkg!r}')
with _install_lock:
return _install(apk_path, info, pkg, flatscreen, name, source)
def _install(apk_path, info, pkg, flatscreen, name, source):
iid = instance_id(pkg)
d = f'{APPS_DIR}/{pkg}'
existing = read_meta(pkg)
ok = False
try:
ssh(f'mkdir -p {d}')
_copy(apk_path, f'{d}/app.apk.part')
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
icon = ''
if info['icon_png']:
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
icon = f'$HOME/{d}/icon.png'
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
home = ssh('echo $HOME').strip()
shortcut = _int((existing or {}).get('shortcut'))
if not shortcut or shortcut not in _shortcut_ids():
reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}',
icon.replace('$HOME', home))
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
if not shortcut:
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
'source': source or os.path.basename(apk_path)}
_write_meta(d, meta)
ok = True
return meta
finally:
if not ok and not existing:
# A first install that failed part-way: don't leave an orphan folder behind.
try:
ssh(f'rm -rf {d}', timeout=30)
except FrameError:
pass
def _int(v):
try:
n = int(v)
return n if n > 0 else None
except (TypeError, ValueError):
return None
def read_meta(pkg):
try:
m = json.loads(ssh(f'cat {APPS_DIR}/{pkg}/meta.json 2>/dev/null || true') or 'null')
except (ValueError, FrameError):
return None
return _clean_meta(m)
def _clean_meta(m):
"""A usable meta dict with integer ids, or None if it's missing what we need."""
if not isinstance(m, dict) or not PKG_RE.match(str(m.get('package', ''))):
return None
iid, shortcut = _int(m.get('instance')), _int(m.get('shortcut'))
if not iid:
return None
m.update(instance=iid, shortcut=shortcut, game_id=game_id(shortcut) if shortcut else None,
label=str(m.get('label') or m['package']), version=str(m.get('version') or ''))
return m
def running_instances():
"""Lepton container name -> adb port, for the instances that are running now."""
out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}" 2>/dev/null || true')
return dict(line.split()[:2] for line in out.splitlines() if len(line.split()) >= 2)
def list_apps():
out = ssh(f'for f in {APPS_DIR}/*/meta.json; do [ -f "$f" ] && cat "$f" && echo; echo "@@"; done 2>/dev/null || true')
running = running_instances()
apps = []
for chunk in out.split('@@'):
chunk = chunk.strip()
if not chunk:
continue
try:
m = _clean_meta(json.loads(chunk))
except ValueError:
continue
if m:
m['running'] = f"lepton-steamlaunch-{m['instance']}" in running
apps.append(m)
return sorted(apps, key=lambda m: m['label'].lower())
def _meta_or_fail(pkg):
if not PKG_RE.match(pkg or ''):
raise FrameError(f'bad package name {pkg!r}')
m = read_meta(pkg)
if not m:
raise FrameError(f'{pkg} is not installed')
return m
def launch(pkg):
m = _meta_or_fail(pkg)
if not m['game_id']:
raise FrameError(f"{m['label']} has no Steam shortcut; reinstall it")
ssh(f"steam steam://rungameid/{int(m['game_id'])} >/dev/null 2>&1 &")
return m
def stop(pkg):
m = _meta_or_fail(pkg)
ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60)
return m
def remove(pkg, keep_data=False):
m = _meta_or_fail(pkg)
stop(pkg)
if m['shortcut']:
try:
shortcut_tool('remove', str(int(m['shortcut'])))
except FrameError:
pass # already gone from Steam
iid = int(m['instance'])
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
return m
def probe(pkg, wait=20):
"""Launch the app's instance and report whether it stays up (for compat reports)."""
m = _meta_or_fail(pkg)
ctr = f"lepton-steamlaunch-{int(m['instance'])}"
launch(pkg)
t0 = time.time()
while time.time() - t0 < 90 and ctr not in running_instances():
time.sleep(3)
if ctr not in running_instances():
return {'package': pkg, 'version': m['version'], 'result': 'instance_failed',
'detail': 'Lepton instance did not start within 90 s'}
# Android boots inside the container; then give the app time to crash, or not.
time.sleep(wait)
sh = f'podman exec {ctr} /system/bin/sh -c'
alive = ssh(f"{sh} 'pidof {pkg}' 2>/dev/null || true").strip()
crash = ssh(f"{sh} 'logcat -d -b crash' 2>/dev/null | tail -n 60 || true")
reason = next((l.split('AndroidRuntime: ', 1)[1] for l in crash.splitlines()
if 'AndroidRuntime: ' in l and ('Exception' in l or 'Error' in l)), '')
if not reason and 'Fatal signal' in crash:
reason = next(l[l.find('Fatal signal'):] for l in crash.splitlines() if 'Fatal signal' in l)
if 'ClipboardManager' in reason:
reason = 'no clipboard service: ' + reason
return {'package': pkg, 'version': m['version'], 'result': 'runs' if alive else 'crashes',
'detail': reason[:300], 'seconds': wait,
'container_up': ctr in running_instances()}
def main():
cmd, *args = sys.argv[1:] or ['help']
try:
if cmd == 'install':
r = install(args[0], flatscreen='--vr' not in args)
elif cmd == 'list':
r = list_apps()
elif cmd in ('launch', 'stop', 'probe'):
r = globals()[cmd](args[0])
elif cmd == 'remove':
r = remove(args[0], keep_data='--keep-data' in args)
else:
sys.exit(__doc__)
except FrameError as e:
sys.exit(f'error: {e}')
print(json.dumps(r, indent=1))
if __name__ == '__main__':
main()
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more