mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f770dc88a | ||
|
|
d7fc0189b5 | ||
|
|
8f379db15e | ||
|
|
2f5ba5c086 | ||
|
|
5592ee1570 | ||
|
|
b86dd3b07d | ||
|
|
1cd839e0f1 | ||
|
|
91aafc1371 | ||
|
|
eb78eba0dc | ||
|
|
fd0a284942 | ||
|
|
dfacf43e55 | ||
|
|
0530a6d045 | ||
|
|
39d28790a1 | ||
|
|
1580dae42e | ||
|
|
84ac687399 | ||
|
|
ccf5f3e123 | ||
|
|
0478626061 | ||
|
|
2ab2ffa65a | ||
|
|
d145537d9a | ||
|
|
636a4a47b7 | ||
|
|
26fff2a06c | ||
|
|
031ab6aa12 | ||
|
|
6c4d387ef3 | ||
|
|
dd9c009206 | ||
|
|
770f26c703 | ||
|
|
a90ffeda5f | ||
|
|
2544255825 | ||
|
|
1a0e54d8bd | ||
|
|
6fd35a0a78 | ||
|
|
46043f7e95 | ||
|
|
5c7ee97cd3 | ||
|
|
34ce457332 | ||
|
|
8a90e3e34f | ||
|
|
03729ce950 | ||
|
|
37153f69ae | ||
|
|
52d01bd815 | ||
|
|
e210407f31 | ||
|
|
fc2fa65f0d | ||
|
|
2a4a709ced | ||
|
|
cfb7465c22 | ||
|
|
07de29d58a | ||
|
|
f6e77cd98c |
No files matched your search
@@ -0,0 +1,8 @@
|
||||
# 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
|
||||
@@ -27,8 +27,33 @@ jobs:
|
||||
esac
|
||||
done
|
||||
- name: Python compiles
|
||||
run: python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py
|
||||
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
|
||||
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
|
||||
@@ -0,0 +1,61 @@
|
||||
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
|
||||
@@ -1,253 +1,199 @@
|
||||
# Steam Frame ↔ Mac
|
||||
<div align="center">
|
||||
|
||||
**Frame Control** is a Mac app for managing a Valve Steam Frame (standalone VR
|
||||
headset: SteamOS 3, Arch-based, arm64, Snapdragon 8 Gen 3) over SSH: live
|
||||
headset view, battery and status, your Steam library, Android (Lepton) apps,
|
||||
file and clipboard transfer. This repo also holds the scripts behind it and
|
||||
field notes on how the Frame's software works, all aimed at as little typing
|
||||
on the headset's virtual keyboard as possible.
|
||||
<img src="docs/img/icon.png" width="112" alt="Frame Control icon">
|
||||
|
||||
It's an unofficial hobby project, not affiliated with Valve.
|
||||
# Frame Control
|
||||
|
||||
Status: written 2026-09-25 and checked against a real Frame the same day
|
||||
(SteamOS 0.3.0, variant `vr`, build 20260922). The **Frame Control** Mac app
|
||||
and most scripts are **verified** on the device. The scripts table below marks
|
||||
each one, and [docs/open-questions.md](docs/open-questions.md#verified-on-device-2026-09-25)
|
||||
lists what's still unchecked.
|
||||
**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.
|
||||
|
||||
## Trying it out
|
||||
[](https://github.com/saphid/steam-frame/releases/latest)
|
||||
[](#install)
|
||||
[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
|
||||
[](LICENSE)
|
||||
|
||||
You need:
|
||||
[**Download**](#install) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
|
||||
|
||||
- A Steam Frame with **Developer Mode** on (next section; it's a toggle).
|
||||
- A Mac with Apple Silicon (M1 or later). Tested on macOS 26. There's no Intel
|
||||
build.
|
||||
- `python3` on the Mac (`xcode-select --install` provides it).
|
||||
- Optional: `adb` for Android apps (`brew install android-platform-tools`).
|
||||
<br>
|
||||
|
||||
Steps:
|
||||
<img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
|
||||
|
||||
1. Download the DMG from the
|
||||
[latest release](https://github.com/saphid/steam-frame/releases/latest),
|
||||
open it and drag **Frame Control** to Applications.
|
||||
2. The app isn't notarized (no paid Apple developer account), so macOS will
|
||||
say it's damaged or can't be checked. Clear the download quarantine once:
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||
```
|
||||
3. Open it. With no `frame` SSH alias yet, it offers to run the connection
|
||||
setup in Terminal. That asks for the Developer Mode password once, then
|
||||
uses a key from then on.
|
||||
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||
|
||||
**Feedback:** please open a
|
||||
[GitHub issue](https://github.com/saphid/steam-frame/issues) with what you
|
||||
tried, your SteamOS build (Steam Settings → System) and the server log
|
||||
(**Frame → Show Server Log**, at `~/Library/Logs/Frame Control/server.log`).
|
||||
Features are marked **verified** or not below; the unverified ones are the
|
||||
most useful to hear about.
|
||||
</div>
|
||||
|
||||
**What it changes on your Frame:** only what you click. Installs go to your
|
||||
user account (`--user` Flatpaks, Lepton instances, Steam downloads), and
|
||||
nothing needs `sudo` except the power buttons. On the Mac it adds a `Host
|
||||
frame` entry to `~/.ssh/config` and a key at `~/.ssh/id_ed25519_frame`.
|
||||
---
|
||||
|
||||
## Minimum typing on the headset
|
||||
## Features
|
||||
|
||||
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.
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
On the Frame:
|
||||
**👓 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.
|
||||
|
||||
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.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
On the Mac:
|
||||
**🔋 Battery and status**<br>
|
||||
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
|
||||
|
||||
To use the scripts from a checkout instead of the app:
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
```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
|
||||
```
|
||||
**🎮 Steam games**<br>
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
|
||||
|
||||
`connect.sh` does four things:
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
- finds the headset (`frame.local`, then `frame`, or the IP/host you pass in)
|
||||
- creates a dedicated key (`~/.ssh/id_ed25519_frame`)
|
||||
- adds a `Host frame` block to `~/.ssh/config`
|
||||
- runs `ssh-copy-id`, which asks for the Developer Mode password once
|
||||
**🤖 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.
|
||||
|
||||
Run `./scripts/connect.sh --harden` later if you want to turn off SSH password
|
||||
logins.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
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).
|
||||
**📁 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.
|
||||
|
||||
**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](docs/ssh.md#fallback-bootstrap-one-liner).
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
## Recommended options
|
||||
**📸 Screenshots**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||
|
||||
| Goal | Recommended | Confidence |
|
||||
</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 |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| **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`) |
|
||||
|
||||
Details: [docs/ssh.md](docs/ssh.md), [docs/streaming.md](docs/streaming.md),
|
||||
[docs/file-transfer.md](docs/file-transfer.md),
|
||||
[docs/open-questions.md](docs/open-questions.md). For how the Frame's software
|
||||
fits together, see [docs/how-the-frame-works.md](docs/how-the-frame-works.md).
|
||||
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.
|
||||
|
||||
## Windows anywhere in the room
|
||||
<details>
|
||||
<summary><b>macOS: the app isn't notarized</b></summary>
|
||||
|
||||
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:
|
||||
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
|
||||
./scripts/panel-on-frame.sh konsole
|
||||
./scripts/panel-on-frame.sh mac-screen # the Mac's screen, in its own panel
|
||||
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||
```
|
||||
|
||||
Then use the SteamVR dashboard's **Float in World**, **Move** and **Size**
|
||||
controls to place each panel. See [docs/panels.md](docs/panels.md).
|
||||
The first time, macOS also asks to allow local network access (for SSH) and
|
||||
control of Terminal (for the password prompts).
|
||||
</details>
|
||||
|
||||
## Frame Control (Mac app)
|
||||
<details>
|
||||
<summary><b>Windows: SmartScreen warning</b></summary>
|
||||
|
||||
As of 2026-09-25 no other Mac 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. **Frame Control** is a Mac app over
|
||||
the scripts below. Install it from the DMG (see [Mac app](#mac-app)), or run
|
||||
the same UI in a browser without packaging:
|
||||
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
|
||||
./scripts/frame-ui.sh # opens http://127.0.0.1:47810 in its own window
|
||||
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>
|
||||
|
||||
- **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.
|
||||
- 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
|
||||
- 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 `docs/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 Mac and change the verdicts you see. They aren't
|
||||
uploaded anywhere: the shared database is maintainer-only for now (see
|
||||
`compat-db/README.md`)
|
||||
- **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
|
||||
- Drag and drop files to `~/Downloads`; `.apk` files install as their own Android app
|
||||
- Send typed text, or the Mac clipboard, to the Frame clipboard
|
||||
- Install and remove Flatpaks (quick picks: Moonlight, Firefox, VLC, Remmina)
|
||||
- One-click SSH or SFTP in Terminal, Steam Link, and Windows App (RDP).
|
||||
Sleep, restart and shut down open Terminal because SteamOS asks for the
|
||||
sudo password over SSH.
|
||||
## Set up the headset
|
||||
|
||||
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. It keeps a single multiplexed SSH connection open, so
|
||||
status and each capture take about 0.3s. 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:** 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, and the power buttons. Each of these calls a
|
||||
command or script that was verified separately.
|
||||
You type one password on the headset, once. Everything else happens on your
|
||||
computer.
|
||||
|
||||
### Mac app
|
||||
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.
|
||||
|
||||
`app/` wraps the same UI as a standalone Mac app (Electron). The app bundles
|
||||
`ui/`, `scripts/`, `frame/android/` and the rated catalogue from `apk-catalog/`.
|
||||
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. A prebuilt DMG for Apple Silicon is
|
||||
attached to each [GitHub release](https://github.com/saphid/steam-frame/releases).
|
||||
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).
|
||||
|
||||
```sh
|
||||
cd app
|
||||
npm install
|
||||
npm run dist # → app/dist/Frame Control-<version>-arm64.dmg (and a .zip)
|
||||
npm start # run from the checkout without packaging
|
||||
```
|
||||
**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).
|
||||
|
||||
Open the DMG and drag **Frame Control** to Applications. You need `python3` on
|
||||
the Mac (Xcode Command Line Tools or Homebrew). The app reads `PATH` from your
|
||||
login shell, so Homebrew's `rsync` and `adb` work when you launch it from
|
||||
Finder. Each time it starts while there's no `frame` SSH alias, the app offers
|
||||
to run `connect.sh` in Terminal. **Frame → Set Up Connection…** does the same
|
||||
at any time. The Frame menu also shows the server log at
|
||||
`~/Library/Logs/Frame Control/server.log`. Installing APKs needs `adb`
|
||||
(`brew install android-platform-tools`). The F-Droid ratings are bundled with the app.
|
||||
## Feedback
|
||||
|
||||
The build is ad-hoc signed and not notarized. A copy you build yourself opens
|
||||
normally. A copy downloaded from GitHub Releases is quarantined; clear it with
|
||||
`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). **Verified 2026-09-25:**
|
||||
installed from the DMG, launched from Finder, connected to the Frame, and
|
||||
showed live status and the library.
|
||||
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:
|
||||
|
||||
## Scripts
|
||||
- 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
|
||||
|
||||
| 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](docs/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](docs/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` |
|
||||
## Going further
|
||||
|
||||
## Security notes
|
||||
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,
|
||||
@@ -256,7 +202,7 @@ showed live status and the library.
|
||||
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 lives in the macOS Keychain and is never written to the repo.
|
||||
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.
|
||||
@@ -264,19 +210,18 @@ showed live status and the library.
|
||||
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 guards, validation, Steam helpers; no headset needed
|
||||
cd app && npm install && npm run dist # build the DMG
|
||||
python3 -m unittest discover -s tests # server tests; no headset needed
|
||||
cd app && npm install && npm start # run the app from the checkout
|
||||
```
|
||||
|
||||
GitHub Actions runs the tests on Python 3.9, which is the oldest `python3` the app
|
||||
may find (Xcode Command Line Tools), plus syntax checks for every script and the
|
||||
Electron main process (`.github/workflows/checks.yml`). Anything that touches the
|
||||
headset is verified by hand against a real Frame, and the docs label it
|
||||
**verified** or **inferred**.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/deps/
|
||||
@@ -0,0 +1,134 @@
|
||||
// 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); });
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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 };
|
||||
+164
-48
@@ -1,7 +1,7 @@
|
||||
// Frame Control as a Mac app: 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, dialog, shell } = require("electron");
|
||||
// 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");
|
||||
@@ -9,14 +9,20 @@ 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);
|
||||
|
||||
// Packaged: Contents/Resources/{ui,scripts}. Dev: the repo checkout.
|
||||
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 = path.join(os.homedir(), "Library", "Logs", "Frame Control");
|
||||
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";
|
||||
@@ -25,15 +31,18 @@ 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. Take PATH from the login shell instead.
|
||||
// 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 || "/bin/zsh";
|
||||
const extra = ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")];
|
||||
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"'],
|
||||
@@ -46,19 +55,44 @@ async function loginPath() {
|
||||
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) {
|
||||
for (const dir of env.PATH.split(":")) {
|
||||
const p = path.join(dir, "python3");
|
||||
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 is a stub until the Command Line Tools are installed.
|
||||
await run(p, ["-c", "import http.server"], { timeout: 10000, env });
|
||||
// /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();
|
||||
@@ -80,17 +114,19 @@ function ping(target) {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" };
|
||||
const python = await findPython(env);
|
||||
if (!python) {
|
||||
throw new Error("Frame Control needs python3. Install the Xcode Command Line Tools "
|
||||
+ "(xcode-select --install) or Homebrew's python, then reopen the app.");
|
||||
}
|
||||
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`);
|
||||
const child = spawn(python, [SERVER, "--port", String(port)], { env, stdio: ["ignore", log, log] });
|
||||
// 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;
|
||||
@@ -112,13 +148,20 @@ async function startServer() {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (server === child) server = null;
|
||||
child.kill("SIGTERM");
|
||||
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() {
|
||||
// server.py handles SIGTERM by closing its shared SSH connection.
|
||||
if (server) server.kill("SIGTERM");
|
||||
if (server) endServer(server);
|
||||
}
|
||||
|
||||
function errorPage(message) {
|
||||
@@ -139,12 +182,12 @@ async function restartServer() {
|
||||
const old = server;
|
||||
server = null;
|
||||
url = null;
|
||||
if (old) old.kill("SIGTERM");
|
||||
if (old) endServer(old);
|
||||
await load();
|
||||
}
|
||||
|
||||
// The page's sticky header becomes the title bar, clear of the traffic lights.
|
||||
const CHROME_CSS = `
|
||||
// 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; }
|
||||
`;
|
||||
@@ -183,23 +226,77 @@ async function firstRunCheck() {
|
||||
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 script: it finds the "
|
||||
+ "headset, creates a key, and asks for that password once in Terminal.",
|
||||
+ "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() : "");
|
||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||
|
||||
// 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,
|
||||
titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 },
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||
...(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());
|
||||
win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
|
||||
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);
|
||||
@@ -208,40 +305,51 @@ function createWindow() {
|
||||
win.webContents.on("will-navigate", (e, target) => {
|
||||
if (!url || new URL(target).origin !== new URL(url).origin) e.preventDefault();
|
||||
});
|
||||
win.on("closed", () => { win = null; });
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Runs in Terminal because ssh-copy-id asks for the Developer Mode password.
|
||||
function runInTerminal(command) {
|
||||
const quoted = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
execFile("osascript", ["-e", 'tell application "Terminal"', "-e", `do script "${quoted}"`,
|
||||
"-e", "activate", "-e", "end tell"], (err) => {
|
||||
if (err) dialog.showErrorBox("Couldn't open Terminal", String(err.message || err));
|
||||
});
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
const sh = (s) => `'${s.replace(/'/g, "'\\''")}'`;
|
||||
|
||||
function setUpConnection() {
|
||||
runInTerminal(`env ${sh(`FRAME_ALIAS=${FRAME}`)} zsh ${sh(path.join(SCRIPTS, "connect.sh"))}`);
|
||||
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 = [
|
||||
{ role: "appMenu" },
|
||||
...(IS_MAC ? [{ role: "appMenu" }] : []),
|
||||
{ role: "fileMenu" },
|
||||
{ role: "editMenu" },
|
||||
{
|
||||
label: "Frame",
|
||||
submenu: [
|
||||
{ label: "Set Up Connection…", click: setUpConnection },
|
||||
{ label: "Open SSH in Terminal", click: () => runInTerminal(`ssh ${sh(FRAME)}`) },
|
||||
{ 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) },
|
||||
{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) },
|
||||
...(IS_WIN ? [] : [{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -253,7 +361,7 @@ function buildMenu() {
|
||||
{ type: "separator" }, { role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
{ role: "windowMenu" },
|
||||
...(IS_MAC ? [{ role: "windowMenu" }] : []),
|
||||
{
|
||||
role: "help",
|
||||
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
|
||||
@@ -265,10 +373,18 @@ function buildMenu() {
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
// 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();
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+82
-6
@@ -1,16 +1,18 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.2.0",
|
||||
"description": "Mac app for managing a Valve Steam Frame over SSH",
|
||||
"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": "electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "electron-builder --mac --arm64 --dir"
|
||||
"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",
|
||||
@@ -19,13 +21,24 @@
|
||||
"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",
|
||||
"package.json"
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
@@ -51,6 +64,14 @@
|
||||
"*.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../frame/devkit-utils",
|
||||
"to": "frame/devkit-utils",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!**/__pycache__/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../apk-catalog",
|
||||
"to": "apk-catalog",
|
||||
@@ -59,6 +80,20 @@
|
||||
"pins.json",
|
||||
"site/apps.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "build/deps/${os}-${arch}/python",
|
||||
"to": "python",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "build/deps/${os}-${arch}/tools",
|
||||
"to": "tools",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
@@ -73,7 +108,8 @@
|
||||
"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}"
|
||||
@@ -82,6 +118,46 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 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 can open Set Up Connection when the headset can't be reached.
|
||||
// It also receives frame-control://install links (docs/web-install.md): only
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||
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
-1
@@ -18,7 +18,8 @@ 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. `aapt2` reads the package, label, version, ABIs and icon. APKs that need
|
||||
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
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# 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
|
||||
|
||||
The window has four tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports) and **Tools** (sending files and text,
|
||||
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
|
||||
anywhere in the window. When the Frame can't be reached, one banner says why in
|
||||
plain words and the app retries every few seconds, filling everything in once it
|
||||
answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
counts them while they run.
|
||||
|
||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||
floating panels, dashboard and controllers). Shows the left eye, like pointing
|
||||
a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
|
||||
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`).
|
||||
@@ -44,11 +44,14 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| 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)). 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) |
|
||||
| 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
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 892 KiB After Width: | Height: | Size: 824 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,63 @@
|
||||
<!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>
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# 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` |
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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.
|
||||
+41
-1
@@ -33,7 +33,8 @@ 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`. If neither works, it tells you to re-run it with the IP.
|
||||
- `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
|
||||
@@ -55,10 +56,49 @@ 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).
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# 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.
|
||||
+83
-19
@@ -3,6 +3,10 @@
|
||||
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.
|
||||
|
||||
A standalone, public version of this build (build script, the SO_PEERCRED
|
||||
patch, and a Frame-side installer that adds "Chromium XR" to the Steam library)
|
||||
is at [saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame).
|
||||
|
||||
## Why Flathub Chromium can't
|
||||
|
||||
**Verified 2026-09-25** (Frame BUILD_ID 20260922.6101926, Flathub
|
||||
@@ -46,12 +50,22 @@ Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`).
|
||||
[`scripts/build-chromium-xr.sh`](../scripts/build-chromium-xr.sh)
|
||||
cross-compiles arm64 Linux Chromium on an x64 Linux host. It doesn't need
|
||||
sudo: the arm64 sysroot comes from Chromium's own script. It needs about
|
||||
90 GB of disk. It shallow-fetches the CL ref, runs `gclient sync --no-history`,
|
||||
installs the sysroot, builds `chrome` with `symbol_level=0` and proprietary
|
||||
codecs, and packs `chromium-xr-arm64.tar.xz`. Progress is logged to
|
||||
`~/chromium-xr/stage`. The build aborts if `/` drops below 12 GB free.
|
||||
90 GB of disk. It shallow-fetches the CL ref (patchset 44), runs
|
||||
`gclient sync --no-history`, installs the sysroot, applies one extra seccomp
|
||||
fix (below), builds `chrome` with `symbol_level=0` and proprietary codecs, and
|
||||
packs `chromium-xr-arm64.tar.xz` (about 145 MB, GPU libraries included).
|
||||
Progress is logged to `~/chromium-xr/stage`. The build aborts if the disk
|
||||
holding `~/chromium-xr` drops below 12 GB free.
|
||||
|
||||
First run: a 12-core, 31 GB x64 Linux box, started 2026-09-25.
|
||||
First run, 2026-09-25, on a 12-core, 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.
|
||||
|
||||
**The extra fix.** The CL's 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 script
|
||||
allows that one option. That's needed but not enough: `launch` still turns
|
||||
seccomp off (below), so the patch only matters once that's fixed too.
|
||||
|
||||
## Running it on the Frame
|
||||
|
||||
@@ -59,24 +73,74 @@ First run: a 12-core, 31 GB x64 Linux box, started 2026-09-25.
|
||||
|
||||
```sh
|
||||
BUILD_HOST=my-linux-box scripts/chromium-xr.sh install # your build host; scp, unpack to ~/chromium-xr
|
||||
scripts/chromium-xr.sh launch [URL] # headset desktop, --enable-features=OpenXR
|
||||
scripts/chromium-xr.sh launch [URL] # its own VR panel, --enable-features=OpenXR
|
||||
scripts/chromium-xr.sh steam # adds "Chromium XR" to the Steam library
|
||||
scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr')
|
||||
```
|
||||
|
||||
It runs natively, not as a Flatpak, so the XR sandbox and SteamVR's IPC work
|
||||
as the CL expects. It uses its own profile (`~/.config/chromium-xr`) and
|
||||
DevTools on loopback port 9223, so it doesn't collide with the Flatpak's 9222.
|
||||
It runs natively, not as a Flatpak. `launch` opens it as its own panel on
|
||||
gamescope's X display, the same way as [`panel-on-frame.sh`](../scripts/panel-on-frame.sh) ([panels.md](panels.md)), so
|
||||
the Plasma desktop doesn't need to be open. It uses its own profile
|
||||
(`~/.config/chromium-xr`) and DevTools on loopback port 9223, so it doesn't
|
||||
collide with the Flatpak's 9222. When a page enters VR, Chrome asks
|
||||
**Allow VR?** in the browser panel; choose *Allow this time* or *Allow while
|
||||
visiting the site*.
|
||||
|
||||
**Verified 2026-09-25:**
|
||||
Both ways of starting it run
|
||||
[`frame/chromium-xr/launch.sh`](../frame/chromium-xr/launch.sh), copied to
|
||||
`~/Applications/ChromiumXR/launch.sh` on the Frame, which holds Chrome's flags.
|
||||
It sits outside `~/chromium-xr` so `install` doesn't delete it.
|
||||
|
||||
- Vulkan is there: Turnip (Mesa) on Adreno 750, API 1.4.359.
|
||||
- Unprivileged user namespaces work (`unshare -Ur true`), so Chromium's
|
||||
namespace sandbox shouldn't need the setuid `chrome_sandbox`.
|
||||
**From the Steam library (verified 2026-09-26).** `steam` adds a non-Steam
|
||||
shortcut called "Chromium XR" (with the Flathub Chromium icon, if that's
|
||||
installed) through the Steam client's DevTools port, the same way as T3 Code
|
||||
([apks.md](apks.md)), without restarting Steam. It saves the app id in
|
||||
`~/Applications/ChromiumXR/shortcut-appid`, so rerunning it, even after you
|
||||
rename the shortcut in the library, doesn't add a second one. On this Frame
|
||||
the shortcut app id is 2240749789. Launching it from the library gives
|
||||
Chromium its own panel, `valve.steam.desktopgame.2240749789`, like any other
|
||||
app. A Steam launch doesn't open a DevTools port, so `check` needs `launch`.
|
||||
Chromium runs one browser per profile: while the Steam-launched one is open,
|
||||
`launch` opens its URL in that window, without DevTools, then prints
|
||||
`failed: ... exited` because no new window appeared. Close it first.
|
||||
|
||||
**Unverified (inferred):**
|
||||
**Seccomp is off.** The wrapper 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. DevTools on
|
||||
port 9223 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's GPU process may still fall back from Vulkan to GL on Turnip.
|
||||
- An immersive session started from a window on the nested desktop may not
|
||||
hand over cleanly to the SteamVR compositor.
|
||||
- If the sandbox fails to start, `--no-sandbox` is the fallback for a first
|
||||
test.
|
||||
**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.
|
||||
|
||||
**Not verified yet:**
|
||||
|
||||
- Frame rate and dropped frames during playback (nothing was measured; it
|
||||
looked fine).
|
||||
- Third-party VR180 players (DeoVR and DL8 web embeds).
|
||||
- Controller and hand input inside a WebXR page.
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Frame-side: start the WebXR Chromium build (~/chromium-xr). The Steam
|
||||
# library shortcut "Chromium XR" runs this, and so does
|
||||
# `scripts/chromium-xr.sh launch` (which adds a DevTools port). Extra
|
||||
# arguments go to Chrome, so a URL opens that page.
|
||||
#
|
||||
# Lives in ~/Applications/ChromiumXR, outside ~/chromium-xr, so reinstalling
|
||||
# the build doesn't delete it.
|
||||
set -euo pipefail
|
||||
|
||||
CHROME="$HOME/chromium-xr/chrome"
|
||||
[[ -x "$CHROME" ]] || { echo "launch.sh: no build at $CHROME (run chromium-xr.sh install)" >&2; exit 1; }
|
||||
|
||||
# Without --no-first-run and --password-store=basic, startup can stop at a
|
||||
# first-run or keyring prompt.
|
||||
# --disable-seccomp-filter-sandbox: under the XR seccomp policy, SteamVR's
|
||||
# client reads /proc/self/status through the file broker, gets the broker's
|
||||
# pid, and SteamVR binds the app to the wrong process, so xrCreateInstance
|
||||
# fails. The namespace sandbox stays on, but seccomp is off for every
|
||||
# process, so keep this profile for VR sites.
|
||||
exec "$CHROME" \
|
||||
--user-data-dir="$HOME/.config/chromium-xr" \
|
||||
--enable-features=OpenXR \
|
||||
--ozone-platform=x11 \
|
||||
--no-first-run --no-default-browser-check --password-store=basic \
|
||||
--disable-seccomp-filter-sandbox \
|
||||
"$@"
|
||||
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,4 @@
|
||||
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
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/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))
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/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'])
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/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))
|
||||
Executable
+443
@@ -0,0 +1,443 @@
|
||||
#!/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'}")
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/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))
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/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))
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/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
|
||||
))
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
|
||||
# (8441736 + 8132979, bug 506004811) so WebXR immersive-vr works on the Frame.
|
||||
# (8441736 + 8132979, bug 506004811), plus a one-option seccomp fix, so WebXR
|
||||
# immersive-vr works on the Frame.
|
||||
# Needs ~90 GB free, no sudo. Takes hours; run it detached on the build host:
|
||||
# scp scripts/build-chromium-xr.sh buildhost:chromium-xr/build.sh
|
||||
# ssh buildhost 'cd ~/chromium-xr && tmux new -d -s chromium-xr "./build.sh > build.log 2>&1"'
|
||||
@@ -35,14 +36,69 @@ if ! git -C src rev-parse -q --verify HEAD >/dev/null 2>&1; then
|
||||
fi
|
||||
guard
|
||||
stage "src at $(git -C src log -1 --format='%h %s')"
|
||||
stage "gclient sync"
|
||||
gclient sync --nohooks --no-history -D --shallow --revision "src@$(git -C src rev-parse HEAD)" -j 8
|
||||
guard
|
||||
stage "runhooks"
|
||||
gclient runhooks
|
||||
src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64
|
||||
rev=$(git -C src rev-parse HEAD)
|
||||
# Sync once per revision: once the patch below is applied, gclient sync
|
||||
# refuses to run on the modified checkout, so re-runs must skip it.
|
||||
if [ "$(cat "$W/synced" 2>/dev/null)" != "$rev" ]; then
|
||||
stage "gclient sync"
|
||||
gclient sync --nohooks --no-history -D --shallow --revision "src@$rev" -j 8
|
||||
guard
|
||||
stage "runhooks"
|
||||
gclient runhooks
|
||||
src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64
|
||||
echo "$rev" > "$W/synced"
|
||||
fi
|
||||
guard
|
||||
cd src
|
||||
# CL 8441736's XR seccomp policy refuses getsockopt, and SteamVR's IPC client
|
||||
# calls getsockopt(SO_PEERCRED) inside xrCreateInstance, which crashes the XR
|
||||
# process (verified on the Frame 2026-09-26). Allow only that option.
|
||||
IFS= read -r -d '' PEERCRED_PATCH <<'P' || true
|
||||
diff --git a/sandbox/policy/linux/bpf_xr_policy_linux.cc b/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||
index 435e13d396..297453f582 100644
|
||||
--- a/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||
+++ b/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "sandbox/linux/system_headers/linux_syscalls.h"
|
||||
#include "sandbox/policy/linux/sandbox_linux.h"
|
||||
|
||||
+using sandbox::bpf_dsl::AllOf;
|
||||
using sandbox::bpf_dsl::Allow;
|
||||
using sandbox::bpf_dsl::Arg;
|
||||
using sandbox::bpf_dsl::Error;
|
||||
@@ -27,8 +28,8 @@ XrProcessPolicy::~XrProcessPolicy() = default;
|
||||
ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
|
||||
switch (system_call_number) {
|
||||
// The runtime reaches its compositor over an AF_UNIX socket and passes fds
|
||||
- // with SCM_RIGHTS, neither of which the GPU policy allows. get/setsockopt
|
||||
- // stay disallowed; add a narrow level/optname restriction if ever needed.
|
||||
+ // with SCM_RIGHTS, neither of which the GPU policy allows. setsockopt
|
||||
+ // stays disallowed; getsockopt is limited to SO_PEERCRED below.
|
||||
#if defined(__NR_getpeername)
|
||||
case __NR_getpeername:
|
||||
#endif
|
||||
@@ -49,6 +50,16 @@ ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
|
||||
case __NR_get_robust_list:
|
||||
#endif
|
||||
return Allow();
|
||||
+#if defined(__NR_getsockopt)
|
||||
+ case __NR_getsockopt: {
|
||||
+ // SteamVR's IPC client checks who is on the other end of its socket
|
||||
+ // with SO_PEERCRED. Nothing else is readable.
|
||||
+ const Arg<int> level(1);
|
||||
+ const Arg<int> optname(2);
|
||||
+ return If(AllOf(level == SOL_SOCKET, optname == SO_PEERCRED), Allow())
|
||||
+ .Else(Error(EPERM));
|
||||
+ }
|
||||
+#endif
|
||||
#if defined(__NR_kill)
|
||||
case __NR_kill: {
|
||||
// SteamVR probes its sibling processes for liveness with kill(pid, 0).
|
||||
P
|
||||
if ! printf '%s\n' "$PEERCRED_PATCH" | git apply --reverse --check 2>/dev/null; then
|
||||
printf '%s\n' "$PEERCRED_PATCH" | git apply
|
||||
stage "applied SO_PEERCRED patch"
|
||||
fi
|
||||
mkdir -p out/XR
|
||||
cat > out/XR/args.gn <<'A'
|
||||
target_os = "linux"
|
||||
|
||||
+48
-6
@@ -9,7 +9,8 @@
|
||||
#
|
||||
# Usage:
|
||||
# scripts/chromium-xr.sh install [TARBALL] # default: scp from $BUILD_HOST
|
||||
# scripts/chromium-xr.sh launch [URL] # opens in the headset desktop
|
||||
# scripts/chromium-xr.sh launch [URL] # opens as its own panel in the headset
|
||||
# scripts/chromium-xr.sh steam # adds "Chromium XR" to the Steam library
|
||||
# scripts/chromium-xr.sh check # isSessionSupported via DevTools
|
||||
set -euo pipefail
|
||||
|
||||
@@ -17,7 +18,16 @@ FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
BUILD_HOST=${BUILD_HOST:-}
|
||||
BUILD_TARBALL=${BUILD_TARBALL:-chromium-xr/chromium-xr-arm64.tar.xz}
|
||||
DEVTOOLS_PORT=${DEVTOOLS_PORT:-9223}
|
||||
STEAM_NAME=${STEAM_NAME:-Chromium XR}
|
||||
here=${0:A:h}
|
||||
wrapper='~/Applications/ChromiumXR/launch.sh'
|
||||
|
||||
# frame/chromium-xr/launch.sh holds Chrome's flags; both launch paths run it.
|
||||
push_wrapper() {
|
||||
# Write then rename, so a dropped connection can't leave a torn script.
|
||||
ssh "$FRAME_ALIAS" 'mkdir -p ~/Applications/ChromiumXR && cd ~/Applications/ChromiumXR && cat > launch.sh.new && chmod +x launch.sh.new && mv launch.sh.new launch.sh' \
|
||||
< "$here/../frame/chromium-xr/launch.sh"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
install)
|
||||
@@ -35,13 +45,45 @@ case "${1:-}" in
|
||||
ssh "$FRAME_ALIAS" '~/chromium-xr.new/chrome --version && rm -rf ~/chromium-xr && mv ~/chromium-xr.new ~/chromium-xr'
|
||||
;;
|
||||
launch)
|
||||
# run-on-frame starts in $HOME on the Frame, so the profile path is relative.
|
||||
exec "$here/run-on-frame.sh" -- '~/chromium-xr/chrome' \
|
||||
--user-data-dir=.config/chromium-xr \
|
||||
--enable-features=OpenXR \
|
||||
# Its own VR panel on gamescope's X display, so the Plasma desktop doesn't
|
||||
# need to be open. DevTools is only on for this path (for `check`).
|
||||
push_wrapper
|
||||
exec "$here/panel-on-frame.sh" --name chromium-xr -- "$wrapper" \
|
||||
--remote-debugging-port="$DEVTOOLS_PORT" \
|
||||
"${2:-https://immersive-web.github.io/webxr-samples/}"
|
||||
;;
|
||||
steam)
|
||||
# A non-Steam shortcut, added through the Steam client's DevTools port
|
||||
# without restarting Steam (see docs/apks.md). Launching it from the
|
||||
# library gives Chromium its own panel like any game. Safe to rerun: it
|
||||
# refreshes the wrapper and only adds the shortcut if it's missing.
|
||||
ssh "$FRAME_ALIAS" 'test -x ~/chromium-xr/chrome' ||
|
||||
{ print -u2 "No build in ~/chromium-xr on the Frame: run 'chromium-xr.sh install' first"; exit 1; }
|
||||
push_wrapper
|
||||
# The app id is kept next to the wrapper, so renaming the shortcut in the
|
||||
# library doesn't make a rerun add a second one.
|
||||
shortcuts=$here/../frame/android/steam_shortcuts.py
|
||||
home=$(ssh "$FRAME_ALIAS" 'printf %s "$HOME"')
|
||||
saved=$(ssh "$FRAME_ALIAS" 'cat ~/Applications/ChromiumXR/shortcut-appid 2>/dev/null || true')
|
||||
existing=$(ssh "$FRAME_ALIAS" python3 - list < "$shortcuts" |
|
||||
python3 -c 'import json,sys
|
||||
apps = json.load(sys.stdin)
|
||||
ids = [a["appid"] for a in apps if str(a["appid"]) == sys.argv[2]] or [a["appid"] for a in apps if a["name"] == sys.argv[1]]
|
||||
print(ids[0] if ids else "")' "$STEAM_NAME" "$saved")
|
||||
if [[ -n "$existing" ]]; then
|
||||
print -r -- "The shortcut is already in the Steam library (app id $existing)"
|
||||
else
|
||||
icon=''
|
||||
for dir in /var/lib/flatpak '~/.local/share/flatpak'; do
|
||||
candidate=$dir/exports/share/icons/hicolor/256x256/apps/org.chromium.Chromium.png
|
||||
if ssh "$FRAME_ALIAS" "test -f $candidate"; then icon=${candidate/#\~/$home}; break; fi
|
||||
done
|
||||
existing=$(ssh "$FRAME_ALIAS" python3 - add ${(q)STEAM_NAME} ${(q)home}/Applications/ChromiumXR/launch.sh ${(q)home} ${(q)icon} < "$shortcuts")
|
||||
[[ "$existing" == <-> ]] || { print -u2 -r -- "Steam didn't return a shortcut app id: $existing"; exit 1; }
|
||||
print -r -- "Added $STEAM_NAME to the Steam library (shortcut app id $existing)"
|
||||
fi
|
||||
ssh "$FRAME_ALIAS" "printf '%s\n' $existing > ~/Applications/ChromiumXR/shortcut-appid"
|
||||
;;
|
||||
check)
|
||||
# DevTools listens on the Frame's loopback only; evaluate there.
|
||||
ssh "$FRAME_ALIAS" python3 - "$DEVTOOLS_PORT" <<'EOF'
|
||||
@@ -94,5 +136,5 @@ while reply is None:
|
||||
print("immersive-vr supported:", reply["result"]["result"].get("value"))
|
||||
EOF
|
||||
;;
|
||||
*) sed -n '2,13p' "$0"; exit 2 ;;
|
||||
*) sed -n '2,14p' "$0"; exit 2 ;;
|
||||
esac
|
||||
+183
-42
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: find the Steam Frame, create a key, add a `Host frame` alias to
|
||||
# ~/.ssh/config, copy the key, and optionally disable SSH password logins.
|
||||
# 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). Idempotent: safe to re-run.
|
||||
# Verified on a Frame 2026-09-25 (except --harden and devkit pairing). Idempotent.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/connect.sh [HOST_OR_IP] # set up key + alias
|
||||
@@ -11,93 +13,232 @@
|
||||
# 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,11p' "$0"; exit 0 ;;
|
||||
-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" 22 >/dev/null 2>&1
|
||||
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")
|
||||
candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
|
||||
[[ -z "$host_arg" ]] && candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
|
||||
local h
|
||||
for h in "${candidates[@]}"; do
|
||||
if port_open "$h"; then
|
||||
if reachable "$h"; then
|
||||
print -r -- "$h"; return 0
|
||||
fi
|
||||
print -u2 " - $h: not resolvable or port 22 closed"
|
||||
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."
|
||||
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 key"
|
||||
print "==> SSH keys"
|
||||
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
|
||||
if [[ ! -f "$KEY" ]]; then
|
||||
ssh-keygen -q -t ed25519 -N '' -C "mac->steam-frame" -f "$KEY"
|
||||
print " created $KEY"
|
||||
else
|
||||
print " exists: $KEY"
|
||||
fi
|
||||
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"
|
||||
touch "$CONFIG" && chmod 600 "$CONFIG"
|
||||
tmp=$(mktemp)
|
||||
# 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"
|
||||
{
|
||||
print -r -- "$BEGIN_MARK"
|
||||
print -r -- "Host $FRAME_ALIAS"
|
||||
print -r -- " HostName $HOST"
|
||||
print -r -- " User $FRAME_USER"
|
||||
print -r -- " IdentityFile $KEY"
|
||||
print -r -- " IdentitiesOnly yes"
|
||||
print -r -- " ServerAliveInterval 30"
|
||||
print -r -- "Host *"
|
||||
print -r -- "$END_MARK"
|
||||
cat "$tmp"
|
||||
} > "$CONFIG"
|
||||
rm -f "$tmp"
|
||||
write_config
|
||||
|
||||
print "==> Checking key login"
|
||||
if ssh -o BatchMode=yes -o ConnectTimeout=5 "$FRAME_ALIAS" true 2>/dev/null; then
|
||||
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"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=5 "$FRAME_ALIAS" true \
|
||||
|| { print -u2 "Key login still failing after ssh-copy-id."; exit 1; }
|
||||
key_login_works || { print -u2 "Key login still failing after ssh-copy-id."; exit 1; }
|
||||
print " key login OK"
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,198 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,415 @@
|
||||
"""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()
|
||||
+118
-1
@@ -6,14 +6,17 @@ 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
|
||||
|
||||
@@ -54,7 +57,7 @@ class ServerGuards(unittest.TestCase):
|
||||
@classmethod
|
||||
def request(cls, method, path, body=None, headers=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", cls.port, timeout=10)
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
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()
|
||||
@@ -130,12 +133,126 @@ class ServerGuards(unittest.TestCase):
|
||||
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_unreachable_frame_is_one_clear_offline_error(self):
|
||||
status, _, payload = self.request("GET", "/api/status", headers={"X-Frame-UI": "1"})
|
||||
body = json.loads(payload)
|
||||
self.assertEqual(status, 503, body)
|
||||
self.assertTrue(body["offline"])
|
||||
self.assertIn("Can't find the Frame", body["error"])
|
||||
self.assertIn("frame-control-test.invalid", body["detail"]) # ssh's own words stay available
|
||||
|
||||
def test_flatpak_install_runs_as_a_job(self):
|
||||
status, started = self.post("/api/flatpak", {"id": "org.example.App", "action": "install"})
|
||||
self.assertEqual(status, 200, started)
|
||||
for _ in range(200):
|
||||
status, _, payload = self.request("GET", f"/api/job?id={started['job']}", headers={"X-Frame-UI": "1"})
|
||||
job = json.loads(payload)
|
||||
if job["done"]:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(job["done"])
|
||||
self.assertIn("Can't find the Frame", job["error"])
|
||||
self.assertEqual(self.request("GET", "/api/job?id=nope", headers={"X-Frame-UI": "1"})[0], 404)
|
||||
|
||||
def test_android_install_checks_the_package_before_starting(self):
|
||||
status, body = self.post("/api/android", {"action": "install", "package": "org.example.not.in.catalogue"})
|
||||
self.assertNotEqual(status, 200, body)
|
||||
self.assertNotIn("job", body)
|
||||
|
||||
def test_unknown_routes(self):
|
||||
self.assertEqual(self.request("GET", "/nope")[0], 404)
|
||||
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
||||
|
||||
|
||||
class UnreachableMessages(unittest.TestCase):
|
||||
"""Only ssh's own connection failures are reworded; other errors keep their text."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
import server
|
||||
cls.server = server
|
||||
|
||||
def test_ssh_connection_failures(self):
|
||||
cases = {
|
||||
"ssh: Could not resolve hostname frame: nodename nor servname provided": "Can't find",
|
||||
"ssh: connect to host frame.local port 22: Operation timed out": "isn't answering",
|
||||
"ssh: connect to host 192.168.1.9 port 22: Host is down": "isn't answering",
|
||||
"ssh: connect to host 192.168.1.9 port 22: No route to host": "isn't answering",
|
||||
"ssh: connect to host 192.168.1.9 port 22: Connection refused": "refused",
|
||||
"steamos@192.168.1.9: Permission denied (publickey,password).": "SSH key",
|
||||
"Host key verification failed.": "identity changed",
|
||||
"kex_exchange_identification: read: Connection reset by peer": "dropped",
|
||||
"Timed out talking to frame": "too long",
|
||||
}
|
||||
for raw, words in cases.items():
|
||||
body, status = self.server.error_body(raw)
|
||||
self.assertEqual(status, 503, raw)
|
||||
self.assertIn(words, body["error"], raw)
|
||||
self.assertTrue(body["offline"])
|
||||
|
||||
def test_other_errors_pass_through(self):
|
||||
for raw in ("bad Flatpak app ID", "error: No remote refs found for 'org.example.App'",
|
||||
"cp: cannot open 'x': Permission denied", "timed out waiting for Steam"):
|
||||
self.assertEqual(self.server.error_body(raw), ({"error": raw}, None), raw)
|
||||
|
||||
|
||||
class StatusProbe(unittest.TestCase):
|
||||
# frame_status.py only ever runs on the Frame (Linux); it needs os.statvfs.
|
||||
@unittest.skipIf(os.name == "nt", "Frame-side script; POSIX only")
|
||||
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")],
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
"""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()
|
||||
+26
-47
@@ -8,7 +8,10 @@ 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 glob, json, os, re, shlex, subprocess, sys, threading, time, zipfile, zlib
|
||||
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')
|
||||
@@ -27,7 +30,9 @@ class FrameError(RuntimeError):
|
||||
|
||||
def ssh(cmd, input=None, timeout=120):
|
||||
try:
|
||||
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], input=input, capture_output=True,
|
||||
# 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}')
|
||||
@@ -52,47 +57,12 @@ def game_id(shortcut_appid):
|
||||
return (int(shortcut_appid) << 32) | 0x02000000
|
||||
|
||||
|
||||
def aapt2():
|
||||
found = sorted(glob.glob(os.path.expanduser('~/.homebrew/share/android-commandlinetools/build-tools/*/aapt2'))
|
||||
+ glob.glob('/opt/homebrew/share/android-commandlinetools/build-tools/*/aapt2')
|
||||
+ glob.glob(os.path.expanduser('~/Library/Android/sdk/build-tools/*/aapt2')))
|
||||
return found[-1] if found else None
|
||||
|
||||
|
||||
def apk_info(path):
|
||||
"""Package, label, version, native ABIs and the best PNG icon inside the APK."""
|
||||
tool = aapt2()
|
||||
if not tool:
|
||||
raise FrameError('aapt2 not found: brew install --cask android-commandlinetools, then '
|
||||
'sdkmanager "build-tools;36.0.0"')
|
||||
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, text=True).stdout
|
||||
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
|
||||
if not m:
|
||||
raise FrameError(f'not a readable APK: {os.path.basename(path)}')
|
||||
label = re.search(r"application-label(?:-en(?:-US)?)?:'([^']*)'", out) or \
|
||||
re.search(r"application: label='([^']*)'", out)
|
||||
icons = re.findall(r"application-icon-(\d+):'([^']+)'", out)
|
||||
abis = re.search(r"native-code: (.*)", out)
|
||||
sdk = re.search(r"(?:minSdkVersion|sdkVersion):'(\d+)'", out)
|
||||
info = {'package': m[1], 'version': m[2], 'label': (label[1] if label else '') or m[1],
|
||||
'abis': re.findall(r"'([^']+)'", abis[1]) if abis else [],
|
||||
'min_sdk': int(sdk[1]) if sdk else None, 'icon_png': None}
|
||||
try:
|
||||
z = zipfile.ZipFile(path)
|
||||
except (zipfile.BadZipFile, OSError) as e:
|
||||
raise FrameError(f'not a readable APK: {e}')
|
||||
with z:
|
||||
names = set(z.namelist())
|
||||
for _, icon in sorted(icons, key=lambda d: -int(d[0])):
|
||||
if icon.endswith('.png') and icon in names:
|
||||
info['icon_png'] = z.read(icon)
|
||||
break
|
||||
else: # adaptive icons are XML; fall back to the largest launcher PNG
|
||||
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
|
||||
key=lambda n: z.getinfo(n).file_size)
|
||||
if pngs:
|
||||
info['icon_png'] = z.read(pngs[-1])
|
||||
return info
|
||||
return frame_apk.apk_info(path)
|
||||
except frame_apk.ApkError as e:
|
||||
raise FrameError(f'{os.path.basename(path)}: {e}')
|
||||
|
||||
|
||||
def check_installable(info):
|
||||
@@ -105,14 +75,23 @@ def check_installable(info):
|
||||
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
|
||||
|
||||
|
||||
def _rsync(src, dest, *extra, timeout=600):
|
||||
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(['rsync', '-a', *extra, '-e', 'ssh ' + ' '.join(SSH_OPTS), src, f'{FRAME}:{dest}'],
|
||||
check=True, capture_output=True, text=True, timeout=timeout)
|
||||
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise FrameError(f'copying {os.path.basename(src)} to the Frame timed out')
|
||||
raise FrameError(f'copying {name} to the Frame timed out')
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise FrameError(f'copying {os.path.basename(src)} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
|
||||
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():
|
||||
@@ -146,8 +125,8 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
ok = False
|
||||
try:
|
||||
ssh(f'mkdir -p {d}')
|
||||
_rsync(apk_path, f'{d}/app.apk.part')
|
||||
_rsync(LAUNCHER, f'{d}/launch.sh', '--chmod=u+x', timeout=120)
|
||||
_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'])
|
||||
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
"""Read an APK's package, label, version, SDK level, ABIs and icon, stdlib only.
|
||||
|
||||
Replaces `aapt2 dump badging`, so installing APKs needs no Android SDK. It
|
||||
parses the binary AndroidManifest.xml and, for values the manifest points at
|
||||
(the label, version name and icon are often @string or @mipmap references),
|
||||
the resource table in resources.arsc.
|
||||
"""
|
||||
import struct
|
||||
import zipfile
|
||||
|
||||
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
|
||||
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
|
||||
0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion'}
|
||||
T_REF, T_STRING, T_INT_DEC, T_INT_HEX = 0x01, 0x03, 0x10, 0x11
|
||||
# APKs can come from websites (install links), so nothing read from one may be
|
||||
# unbounded. zipfile stops at a member's declared size, so checking it is enough.
|
||||
MAX_MANIFEST = 16 * 1024**2
|
||||
MAX_ARSC = 128 * 1024**2 # real ones are a few MB; the largest apps' tens of MB
|
||||
MAX_ICON = 8 * 1024**2
|
||||
MAX_VALUES = 256 # resolved values per reference, across all its hops
|
||||
MAX_STEPS = 4096 # entries examined per reference, dead ends and cycles included
|
||||
|
||||
|
||||
class ApkError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _string_pool(buf, off):
|
||||
"""Strings of the ResStringPool chunk at off."""
|
||||
_, hsize, _, count, _, flags, start = struct.unpack_from('<HHIIIII', buf, off)
|
||||
utf8 = flags & 0x100
|
||||
offsets = struct.unpack_from(f'<{count}I', buf, off + hsize)
|
||||
base = off + start
|
||||
out = []
|
||||
for o in offsets:
|
||||
p = base + o
|
||||
if utf8:
|
||||
for _ in range(2): # UTF-16 length, then UTF-8 byte length
|
||||
n = buf[p]
|
||||
if n & 0x80:
|
||||
n = ((n & 0x7f) << 8) | buf[p + 1]
|
||||
p += 2
|
||||
else:
|
||||
p += 1
|
||||
out.append(buf[p:p + n].decode('utf-8', 'replace'))
|
||||
else:
|
||||
n, = struct.unpack_from('<H', buf, p)
|
||||
p += 2
|
||||
if n & 0x8000:
|
||||
n = ((n & 0x7fff) << 16) | struct.unpack_from('<H', buf, p)[0]
|
||||
p += 2
|
||||
out.append(buf[p:p + 2 * n].decode('utf-16-le', 'replace'))
|
||||
return out
|
||||
|
||||
|
||||
def _chunks(buf, off, end):
|
||||
while off + 8 <= end:
|
||||
ctype, hsize, size = struct.unpack_from('<HHI', buf, off)
|
||||
if size < 8 or off + size > end:
|
||||
break
|
||||
yield ctype, hsize, off, size
|
||||
off += size
|
||||
|
||||
|
||||
def manifest_elements(data):
|
||||
"""[(tag, {attr: (type, data, raw string or None)})] for each start tag."""
|
||||
if len(data) < 8 or struct.unpack_from('<H', data, 0)[0] != 0x0003:
|
||||
raise ApkError('AndroidManifest.xml is not binary XML')
|
||||
strings, resmap, out = [], [], []
|
||||
for ctype, hsize, off, size in _chunks(data, 8, len(data)):
|
||||
if ctype == 0x0001:
|
||||
strings = _string_pool(data, off)
|
||||
elif ctype == 0x0180:
|
||||
resmap = struct.unpack_from(f'<{(size - hsize) // 4}I', data, off + hsize)
|
||||
elif ctype == 0x0102:
|
||||
name, astart, asize, count = struct.unpack_from('<4xIHHH', data, off + hsize)
|
||||
attrs = {}
|
||||
for i in range(count):
|
||||
a = off + hsize + astart + i * asize
|
||||
aname, raw, dtype, value = struct.unpack_from('<4xII3xBI', data, a)
|
||||
raw = strings[raw] if raw < len(strings) else None
|
||||
if raw is None and dtype == T_STRING and value < len(strings):
|
||||
raw = strings[value] # some repackers keep only the typed value
|
||||
android = ATTR.get(resmap[aname]) if aname < len(resmap) else None
|
||||
if android: # android: attributes win over same-named ones in other namespaces
|
||||
attrs[android] = (dtype, value, raw)
|
||||
else:
|
||||
attrs.setdefault(strings[aname] if aname < len(strings) else '', (dtype, value, raw))
|
||||
out.append((strings[name] if name < len(strings) else '', attrs))
|
||||
return out
|
||||
|
||||
|
||||
class Resources:
|
||||
"""Just enough of resources.arsc to resolve a reference to its values."""
|
||||
|
||||
def __init__(self, data):
|
||||
self.entries = {} # resid -> [(language, density, type, data)]
|
||||
self.strings = []
|
||||
if len(data) < 12 or struct.unpack_from('<H', data, 0)[0] != 0x0002:
|
||||
return
|
||||
hsize, = struct.unpack_from('<H', data, 2)
|
||||
for ctype, _, off, size in _chunks(data, hsize, len(data)):
|
||||
if ctype == 0x0001 and not self.strings:
|
||||
self.strings = _string_pool(data, off)
|
||||
elif ctype == 0x0200:
|
||||
self._package(data, off, size)
|
||||
|
||||
def _package(self, data, off, size):
|
||||
pid, = struct.unpack_from('<I', data, off + 8)
|
||||
phsize, = struct.unpack_from('<H', data, off + 2)
|
||||
for ctype, thsize, t, tsize in _chunks(data, off + phsize, off + size):
|
||||
if ctype != 0x0201:
|
||||
continue
|
||||
tid, flags, count, estart = struct.unpack_from('<BB2xII', data, t + 8)
|
||||
cfg = t + 20
|
||||
language = data[cfg + 8:cfg + 10].rstrip(b'\0').decode('latin-1')
|
||||
density, = struct.unpack_from('<H', data, cfg + 14)
|
||||
if flags & 0x01: # sparse: (entry index, offset / 4) pairs
|
||||
pairs = [struct.unpack_from('<HH', data, t + thsize + 4 * i) for i in range(count)]
|
||||
offsets = [(i, o * 4) for i, o in pairs]
|
||||
elif flags & 0x02: # 16-bit offsets / 4
|
||||
offsets = [(i, o * 4) for i, o in enumerate(struct.unpack_from(f'<{count}H', data, t + thsize))
|
||||
if o != 0xffff]
|
||||
else:
|
||||
offsets = [(i, o) for i, o in enumerate(struct.unpack_from(f'<{count}I', data, t + thsize))
|
||||
if o != 0xffffffff]
|
||||
for index, o in offsets:
|
||||
e = t + estart + o
|
||||
esize, eflags = struct.unpack_from('<HH', data, e)
|
||||
if eflags & 0x08: # compact entry: type in the flags' high byte
|
||||
dtype, value = eflags >> 8, struct.unpack_from('<I', data, e + 4)[0]
|
||||
elif eflags & 0x01: # bag (style, plural...): not a plain value
|
||||
continue
|
||||
else:
|
||||
dtype, value = struct.unpack_from('<3xBI', data, e + esize)
|
||||
resid = (pid << 24) | (tid << 16) | index
|
||||
self.entries.setdefault(resid, []).append((language, density, dtype, value))
|
||||
|
||||
def values(self, resid, depth=0, seen=frozenset(), steps=None):
|
||||
"""[(language, density, type, data)] with references followed: never round a
|
||||
cycle, at most MAX_VALUES results and MAX_STEPS entries examined in all."""
|
||||
steps = steps if steps is not None else [MAX_STEPS]
|
||||
out = []
|
||||
seen = seen | {resid}
|
||||
for lang, dens, dtype, value in self.entries.get(resid, []):
|
||||
steps[0] -= 1
|
||||
if steps[0] < 0 or len(out) >= MAX_VALUES:
|
||||
break
|
||||
if dtype == T_REF and depth < 5:
|
||||
if value not in seen:
|
||||
out += [(lang or l2, dens or d2, t2, v2)
|
||||
for l2, d2, t2, v2 in self.values(value, depth + 1, seen, steps)]
|
||||
else:
|
||||
out.append((lang, dens, dtype, value))
|
||||
return out[:MAX_VALUES]
|
||||
|
||||
def string(self, dtype, value):
|
||||
return self.strings[value] if dtype == T_STRING and value < len(self.strings) else None
|
||||
|
||||
|
||||
def _text(attr, res):
|
||||
"""An attribute's string value, preferring the default then English resource."""
|
||||
if not attr:
|
||||
return None
|
||||
dtype, value, raw = attr
|
||||
if raw is not None:
|
||||
return raw
|
||||
if dtype in (T_INT_DEC, T_INT_HEX):
|
||||
return str(value)
|
||||
if dtype == T_REF:
|
||||
vals = [(lang, res.string(t, v)) for lang, _, t, v in res.values(value)]
|
||||
vals = [(lang, s) for lang, s in vals if s is not None]
|
||||
for want in ('', 'en'):
|
||||
for lang, s in vals:
|
||||
if lang == want:
|
||||
return s
|
||||
return vals[0][1] if vals else None
|
||||
return None
|
||||
|
||||
|
||||
def _icons(attr, res):
|
||||
"""Icon file paths, largest density first."""
|
||||
if not attr or attr[0] != T_REF:
|
||||
return []
|
||||
vals = [(0 if d in (0xfffe, 0xffff) else d, res.string(t, v)) for _, d, t, v in res.values(attr[1])]
|
||||
return [s for _, s in sorted(vals, key=lambda x: -x[0]) if s]
|
||||
|
||||
|
||||
def _read(z, name, limit):
|
||||
"""A member's bytes, inflating at most limit + 1 of them whatever its header claims
|
||||
(ZipFile.read inflates everything first, then trims to the declared size)."""
|
||||
info = z.getinfo(name)
|
||||
# Android only reads stored and deflated entries, and only those bound what
|
||||
# a read inflates (Python 3.9's bzip2 and lzma readers don't).
|
||||
if info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
|
||||
raise ApkError(f'{name} in the APK uses a compression Android does not')
|
||||
size = info.file_size
|
||||
if size > limit:
|
||||
raise ApkError(f'{name} in the APK is {size / 1024**2:.0f} MB, more than a real one ({limit // 1024**2} MB)')
|
||||
with z.open(name) as f:
|
||||
data = f.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
raise ApkError(f'{name} in the APK is larger than a real one ({limit // 1024**2} MB)')
|
||||
return data
|
||||
|
||||
|
||||
def apk_info(path):
|
||||
"""Package, label, version, min_sdk, abis and the best PNG icon inside the APK."""
|
||||
try:
|
||||
z = zipfile.ZipFile(path)
|
||||
except (zipfile.BadZipFile, OSError) as e:
|
||||
raise ApkError(f'not a readable APK: {e}')
|
||||
with z:
|
||||
names = set(z.namelist())
|
||||
if 'AndroidManifest.xml' not in names:
|
||||
raise ApkError('not an APK: no AndroidManifest.xml')
|
||||
try:
|
||||
elements = manifest_elements(_read(z, 'AndroidManifest.xml', MAX_MANIFEST))
|
||||
res = Resources(_read(z, 'resources.arsc', MAX_ARSC) if 'resources.arsc' in names else b'')
|
||||
except (struct.error, IndexError, zipfile.BadZipFile) as e:
|
||||
raise ApkError(f'could not read the APK manifest: {e}')
|
||||
tags = {}
|
||||
for tag, attrs in elements:
|
||||
tags.setdefault(tag, attrs)
|
||||
manifest, app, sdk = tags.get('manifest', {}), tags.get('application', {}), tags.get('uses-sdk', {})
|
||||
package = _text(manifest.get('package'), res)
|
||||
if not package:
|
||||
raise ApkError('the APK manifest has no package name')
|
||||
min_sdk = sdk.get('minSdkVersion')
|
||||
info = {
|
||||
'package': package,
|
||||
'version': _text(manifest.get('versionName'), res) or '',
|
||||
'label': _text(app.get('label'), res) or package,
|
||||
'abis': sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2}),
|
||||
'min_sdk': min_sdk[1] if min_sdk and min_sdk[0] in (T_INT_DEC, T_INT_HEX) else None,
|
||||
'icon_png': None,
|
||||
}
|
||||
try:
|
||||
info['icon_png'] = _icon_png(z, names, _icons(app.get('icon'), res))
|
||||
except Exception: # noqa: BLE001 - any unreadable icon just means no icon
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
def _icon_png(z, names, icons):
|
||||
for icon in icons:
|
||||
if icon.endswith('.png') and icon in names:
|
||||
return _read(z, icon, MAX_ICON)
|
||||
# Adaptive icons are XML; fall back to the largest launcher PNG.
|
||||
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
|
||||
key=lambda n: z.getinfo(n).file_size)
|
||||
return _read(z, pngs[-1], MAX_ICON) if pngs else None
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
for p in sys.argv[1:]:
|
||||
i = apk_info(p)
|
||||
i['icon_png'] = len(i['icon_png'] or b'')
|
||||
print(p, i)
|
||||
+4
-1
@@ -10,9 +10,12 @@ sys.path.insert(0, CATALOG)
|
||||
import build as catalog_build # noqa: E402
|
||||
import reports # noqa: E402
|
||||
import frame_android # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_compat_db as compat_db # noqa: E402
|
||||
|
||||
CACHE = (os.path.expanduser('~/Library/Caches/Frame Control/apk') if '.app/Contents/Resources' in CATALOG
|
||||
# Inside the installed app the catalogue folder is read-only, so downloads go to
|
||||
# the per-user cache (FRAME_CONTROL_APP is set by app/main.js).
|
||||
CACHE = (str(frame_host.cache_dir('apk')) if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG
|
||||
else os.path.join(CATALOG, 'data', 'cache'))
|
||||
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
|
||||
_lock = threading.Lock()
|
||||
|
||||
+43
-26
@@ -1,26 +1,30 @@
|
||||
"""Frame Control's compatibility database: a private Lakebed capsule
|
||||
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
|
||||
write, using a key kept in the macOS Keychain (service frame-control-compat-db,
|
||||
account app-key).
|
||||
write, using a key from $FRAME_CONTROL_KEY or the macOS Keychain (service
|
||||
frame-control-compat-db, account app-key). Without one (anyone but the
|
||||
maintainer), reports stay local.
|
||||
|
||||
New reports go to a local outbox first and are sent from there, so nothing is
|
||||
lost offline. A mirror of every report is kept for offline reads. Both live in
|
||||
~/Library/Application Support/Frame Control/compat-db/. Python stdlib only.
|
||||
frame_host.data_dir('compat-db'). Python stdlib only.
|
||||
|
||||
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
|
||||
(import restores a backup; reports already in the database are skipped.)
|
||||
"""
|
||||
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
|
||||
|
||||
import frame_host
|
||||
|
||||
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
|
||||
KEYCHAIN = ('frame-control-compat-db', 'app-key')
|
||||
STATE = os.path.expanduser('~/Library/Application Support/Frame Control/compat-db')
|
||||
STATE = str(frame_host.data_dir('compat-db'))
|
||||
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
|
||||
MIRROR = os.path.join(STATE, 'compat-mirror.json')
|
||||
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
|
||||
'label', 'source')
|
||||
TTL = 60 # seconds a fetched copy is reused
|
||||
_lock = threading.Lock()
|
||||
_load_lock = threading.Lock() # refreshing the cached reports (load); separate from _lock, which flush takes
|
||||
_mem = {'at': 0, 'reports': None, 'source': None}
|
||||
|
||||
|
||||
@@ -32,17 +36,19 @@ def key():
|
||||
k = os.environ.get('FRAME_CONTROL_KEY')
|
||||
if k:
|
||||
return k
|
||||
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
||||
capture_output=True, text=True)
|
||||
if p.returncode != 0 or not p.stdout.strip():
|
||||
raise DBError('No compatibility-database key in the Keychain '
|
||||
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
|
||||
p = None
|
||||
if frame_host.MAC:
|
||||
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
||||
capture_output=True, text=True)
|
||||
if p is None or p.returncode != 0 or not p.stdout.strip():
|
||||
raise DBError('No compatibility-database key (set FRAME_CONTROL_KEY, or on macOS the Keychain '
|
||||
f'item service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
|
||||
return p.stdout.strip()
|
||||
|
||||
|
||||
def shared():
|
||||
"""Whether reports reach the shared database. Without the key (anyone but the
|
||||
maintainer), reports stay in this Mac's outbox and ratings come from the catalogue."""
|
||||
maintainer), reports stay in this computer's outbox and ratings come from the catalogue."""
|
||||
try:
|
||||
key()
|
||||
return True
|
||||
@@ -174,25 +180,36 @@ def _read_mirror():
|
||||
return []
|
||||
|
||||
|
||||
def _save_mirror(reports):
|
||||
"""Keep a copy for offline use. Failing to write it mustn't fail the read."""
|
||||
try:
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with open(MIRROR + '.tmp', 'w') as f:
|
||||
json.dump({'fetched': time.strftime('%Y-%m-%dT%H:%M:%S'), 'reports': reports}, f)
|
||||
os.replace(MIRROR + '.tmp', MIRROR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load():
|
||||
"""All reports: the database (cached for TTL s), else the offline mirror; plus unsent ones."""
|
||||
now = time.time()
|
||||
if _mem['reports'] is None or now - _mem['at'] > TTL:
|
||||
try:
|
||||
if not shared():
|
||||
raise DBError('no key')
|
||||
# The page asks for the catalogue and the reports at once: one refresh at a
|
||||
# time, so they share a fetch and never write the mirror's .tmp together.
|
||||
with _load_lock:
|
||||
now = time.time()
|
||||
if _mem['reports'] is None or now - _mem['at'] > TTL:
|
||||
try:
|
||||
flush()
|
||||
except Exception:
|
||||
pass # sending can fail for any reason; reading must still work
|
||||
reports, source = fetch_all(), 'lakebed'
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with open(MIRROR + '.tmp', 'w') as f:
|
||||
json.dump({'fetched': time.strftime('%Y-%m-%dT%H:%M:%S'), 'reports': reports}, f)
|
||||
os.replace(MIRROR + '.tmp', MIRROR)
|
||||
except DBError:
|
||||
reports, source = _read_mirror(), 'mirror'
|
||||
_mem.update(at=now, reports=reports, source=source)
|
||||
if not shared():
|
||||
raise DBError('no key')
|
||||
try:
|
||||
flush()
|
||||
except Exception:
|
||||
pass # sending can fail for any reason; reading must still work
|
||||
reports, source = fetch_all(), 'lakebed'
|
||||
_save_mirror(reports)
|
||||
except DBError:
|
||||
reports, source = _read_mirror(), 'mirror'
|
||||
_mem.update(at=now, reports=reports, source=source)
|
||||
sent = {r.get('id') for r in _mem['reports']}
|
||||
return _mem['reports'] + [r for r in _outbox() if r['id'] not in sent]
|
||||
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Connect this computer to the Steam Frame: find it, create keys, add a `Host frame`
|
||||
alias to ~/.ssh/config and get a key onto the headset. It first asks Valve's
|
||||
SteamOS devkit service (port 32000) to pair, which needs only a tap on the
|
||||
headset; if that service isn't there or says no, it copies the key over SSH,
|
||||
asking for the Developer Mode password once. The Linux and Windows twin of
|
||||
scripts/connect.sh (which the Mac app uses); same config block, so either can
|
||||
re-run over the other. Idempotent.
|
||||
|
||||
Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]]
|
||||
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
||||
USER_FROM_ENV = "FRAME_USER" in os.environ
|
||||
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
||||
SSH_DIR = Path.home() / ".ssh"
|
||||
KEY = SSH_DIR / "id_ed25519_frame"
|
||||
# The devkit service only accepts ssh-rsa keys (write_key in Valve's
|
||||
# steamos-devkit-service), so pairing uses a second key next to the ed25519 one.
|
||||
DEVKIT_KEY = SSH_DIR / "id_rsa_frame_devkit"
|
||||
CONFIG = SSH_DIR / "config"
|
||||
NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
|
||||
# Both go into ~/.ssh/config, so nothing that could add a line or a directive.
|
||||
for _name, _value in (("FRAME_ALIAS", FRAME_ALIAS), ("FRAME_USER", FRAME_USER)):
|
||||
if not NAME_RE.fullmatch(_value):
|
||||
sys.exit(f"{_name} must be a plain name, not {_value!r}")
|
||||
BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>"
|
||||
END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<"
|
||||
|
||||
# Appends the key from stdin unless it's already there. base64 keeps it intact
|
||||
# through Windows' command-line quoting.
|
||||
ADD_KEY = """umask 077
|
||||
mkdir -p ~/.ssh
|
||||
k=$(cat)
|
||||
grep -qxF "$k" ~/.ssh/authorized_keys 2>/dev/null || printf '%s\\n' "$k" >> ~/.ssh/authorized_keys
|
||||
"""
|
||||
ADD_KEY_CMD = 'sh -c "$(echo %s | base64 -d)"' % base64.b64encode(ADD_KEY.encode()).decode()
|
||||
|
||||
|
||||
def say(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
# --- Valve's SteamOS devkit pairing (steamos-devkit-service on the headset). HTTP on
|
||||
# port 32000: GET /properties.json names the user to log in as; 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.
|
||||
# The prompt only appears while Steam is on Settings > Developer > Pair new host;
|
||||
# otherwise /register answers 403 "please put the Steam client in pairing mode".
|
||||
|
||||
DEVKIT_PORT = 32000
|
||||
DEVKIT_SERVICE = "_steamos-devkit._tcp"
|
||||
MAGIC_PHRASE = "900b919520e4cf601998a71eec318fec" # fixed token Valve's client appends
|
||||
REGISTER_TIMEOUT = 60
|
||||
PAIRING_MODE_WAIT = 120 # seconds to keep asking while the user opens "Pair new host"
|
||||
# A LAN host: never go through an HTTP(S)_PROXY from the environment.
|
||||
_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
|
||||
|
||||
def key_comment(node):
|
||||
""""frame-control@<short host name>" as one word: the headset splits the body on spaces."""
|
||||
name = re.sub(r"[^A-Za-z0-9._-]+", "-", (node or "").split(".")[0]).strip("-.") or "computer"
|
||||
return f"frame-control@{name}"
|
||||
|
||||
|
||||
def register_body(pub, comment):
|
||||
fields = pub.split()
|
||||
if len(fields) < 2 or fields[0] != "ssh-rsa":
|
||||
raise ValueError("the devkit service only takes ssh-rsa keys")
|
||||
return f"ssh-rsa {fields[1]} {comment} {MAGIC_PHRASE}\n"
|
||||
|
||||
|
||||
def parse_login(raw):
|
||||
"""The `login` from /properties.json if it's a plain user name, else None. "root"
|
||||
means several users are configured and Valve's client switches between them; we
|
||||
can't, so it counts as no answer."""
|
||||
props = json.loads(raw)
|
||||
if not isinstance(props, dict):
|
||||
raise ValueError("properties.json isn't a JSON object")
|
||||
login = props.get("login")
|
||||
if isinstance(login, str) and NAME_RE.fullmatch(login) and login != "root":
|
||||
return login
|
||||
return None
|
||||
|
||||
|
||||
def devkit_error(status, raw):
|
||||
"""A readable reason from a failed /register: its {"error": ...} JSON, or the text."""
|
||||
text = raw.decode("utf-8", "replace").strip()
|
||||
try:
|
||||
err = json.loads(text).get("error")
|
||||
except (ValueError, AttributeError):
|
||||
err = None
|
||||
return str(err or text or f"HTTP {status}")[:300]
|
||||
|
||||
|
||||
def devkit_url(host, port, path):
|
||||
return f"http://[{host}]:{port}{path}" if ":" in host else f"http://{host}:{port}{path}"
|
||||
|
||||
|
||||
def why(e):
|
||||
return str(getattr(e, "reason", None) or e)
|
||||
|
||||
|
||||
def fetch_login(host, port=DEVKIT_PORT, timeout=5):
|
||||
"""GET /properties.json. Raises OSError (HTTP errors included) or ValueError."""
|
||||
with _opener.open(devkit_url(host, port, "/properties.json"), timeout=timeout) as r:
|
||||
return parse_login(r.read())
|
||||
|
||||
|
||||
def register(host, body, port=DEVKIT_PORT, timeout=REGISTER_TIMEOUT):
|
||||
"""POST /register, which waits while someone answers the prompt. -> (ok, message)"""
|
||||
req = urllib.request.Request(devkit_url(host, port, "/register"), data=body.encode("ascii"),
|
||||
headers={"Content-Type": "text/plain"}, method="POST")
|
||||
try:
|
||||
with _opener.open(req, timeout=timeout) as r:
|
||||
return True, r.read().decode("utf-8", "replace").strip()
|
||||
except urllib.error.HTTPError as e:
|
||||
with e:
|
||||
return False, devkit_error(e.code, e.read())
|
||||
except OSError as e:
|
||||
return False, f"no answer ({why(e)})"
|
||||
|
||||
|
||||
def devkit_pair(host, pub, comment, port=DEVKIT_PORT, on_login=None):
|
||||
"""The password-free route. -> None once paired, else the reason, which means: fall
|
||||
back to copying the key with the password. on_login(user) runs before the prompt
|
||||
with the login properties.json names, so the fallback uses that user too."""
|
||||
try:
|
||||
login = fetch_login(host, port)
|
||||
except (OSError, ValueError) as e:
|
||||
return f"devkit service not reachable on port {port}: {why(e)}"
|
||||
if login and on_login:
|
||||
on_login(login)
|
||||
say(" In the headset: Steam Settings > Developer > Pair new host, then approve the request")
|
||||
body = register_body(pub, comment)
|
||||
ok, msg = register(host, body, port)
|
||||
# The headset refuses at once unless Steam is on its "Pair new host" screen
|
||||
# (verified on a Frame, 2026-09-26), so keep asking while the user opens it.
|
||||
deadline = time.monotonic() + PAIRING_MODE_WAIT
|
||||
while not ok and "pairing mode" in msg and time.monotonic() < deadline:
|
||||
time.sleep(3)
|
||||
ok, msg = register(host, body, port)
|
||||
return None if ok else f"devkit pairing failed: {msg}"
|
||||
|
||||
|
||||
def split_port(arg):
|
||||
""""host:2222" -> ("host", 2222); anything else (IPv6 too) keeps port 22."""
|
||||
host, sep, port = arg.rpartition(":")
|
||||
if sep and port.isdigit() and ":" not in host:
|
||||
return host, int(port)
|
||||
return arg, 22
|
||||
|
||||
|
||||
def port_open(host, port=22):
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=3):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def reachable(host, port):
|
||||
"""sshd, or the devkit service, which turns sshd on once a pairing is approved."""
|
||||
try:
|
||||
socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
except OSError:
|
||||
return False
|
||||
return port_open(host, port) or port_open(host, DEVKIT_PORT)
|
||||
|
||||
|
||||
# --- mDNS. There's no stdlib client, so this borrows dns-sd (macOS; Bonjour for
|
||||
# Windows) or avahi-browse (Linux) when present, with short timeouts.
|
||||
|
||||
def run_for(args, seconds):
|
||||
"""What a command printed within `seconds`; dns-sd never exits by itself."""
|
||||
try:
|
||||
out = subprocess.run(args, capture_output=True, timeout=seconds).stdout
|
||||
except subprocess.TimeoutExpired as e:
|
||||
out = e.stdout
|
||||
except OSError:
|
||||
out = b""
|
||||
return (out or b"").decode("utf-8", "replace")
|
||||
|
||||
|
||||
def parse_dns_sd_browse(text):
|
||||
"""Instance names from `dns-sd -B _steamos-devkit._tcp`, deduplicated, in order."""
|
||||
pat = re.compile(r"\sAdd\s+\d+\s+\d+\s+\S+\s+" + re.escape(DEVKIT_SERVICE) + r"\.\s+(.+?)\s*$")
|
||||
names = []
|
||||
for line in text.splitlines():
|
||||
m = pat.search(line)
|
||||
if m and m.group(1) not in names:
|
||||
names.append(m.group(1))
|
||||
return names
|
||||
|
||||
|
||||
def parse_dns_sd_resolve(text):
|
||||
"""The target host from `dns-sd -L` ("... can be reached at frame.local.:32000")."""
|
||||
m = re.search(r"can be reached at (\S+?)\.?:\d+", text)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def parse_avahi(text):
|
||||
"""Host names, then IPv4 addresses, from `avahi-browse -rpt` resolved ("=") lines."""
|
||||
names, addrs = [], []
|
||||
for line in text.splitlines():
|
||||
f = line.split(";")
|
||||
if len(f) >= 9 and f[0] == "=" and f[2] == "IPv4":
|
||||
names.append(f[6])
|
||||
addrs.append(f[7])
|
||||
return list(dict.fromkeys(names + addrs))
|
||||
|
||||
|
||||
def discover_devkit():
|
||||
if shutil.which("dns-sd"):
|
||||
hosts = []
|
||||
for name in parse_dns_sd_browse(run_for(["dns-sd", "-B", DEVKIT_SERVICE, "local."], 3))[:4]:
|
||||
host = parse_dns_sd_resolve(run_for(["dns-sd", "-L", name, DEVKIT_SERVICE, "local."], 2))
|
||||
if host and host not in hosts:
|
||||
hosts.append(host)
|
||||
return hosts
|
||||
if shutil.which("avahi-browse"):
|
||||
return parse_avahi(run_for(["avahi-browse", "-rpt", DEVKIT_SERVICE], 5))
|
||||
return []
|
||||
|
||||
|
||||
HOST_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9.:%-]*")
|
||||
|
||||
|
||||
def pick_host(arg):
|
||||
if arg and not HOST_RE.fullmatch(arg):
|
||||
say(f" - {arg!r} isn't a host name or IP address")
|
||||
return None
|
||||
for cand in [arg] if arg else [f"{FRAME_ALIAS}.local", FRAME_ALIAS]:
|
||||
host, port = split_port(cand)
|
||||
if reachable(host, port):
|
||||
return host, port
|
||||
say(f" - {cand}: not resolvable, or ports {port} and {DEVKIT_PORT} closed")
|
||||
if arg:
|
||||
return None
|
||||
say(f" - asking mDNS for {DEVKIT_SERVICE}")
|
||||
for host in discover_devkit():
|
||||
if HOST_RE.fullmatch(host) and reachable(host, 22):
|
||||
return host, 22
|
||||
say(f" - {host}: advertised, but not reachable")
|
||||
return None
|
||||
|
||||
|
||||
def make_ssh_dir():
|
||||
# Windows: no mode. Python 3.12.4+ turns 0o700 into an owner-only ACL, which locks
|
||||
# the user out if the folder's owner is Administrators; the profile's ACL suffices.
|
||||
if os.name == "nt":
|
||||
SSH_DIR.mkdir(exist_ok=True)
|
||||
else:
|
||||
SSH_DIR.mkdir(mode=0o700, exist_ok=True)
|
||||
|
||||
|
||||
def make_key(path, kind, comment):
|
||||
if path.exists():
|
||||
say(f" exists: {path}")
|
||||
return
|
||||
bits = ["-b", "3072"] if kind == "rsa" else []
|
||||
subprocess.run(["ssh-keygen", "-q", "-t", kind, *bits, "-N", "", "-C", comment, "-f", str(path)], check=True)
|
||||
say(f" created {path}")
|
||||
|
||||
|
||||
def config_block(host, port=22, user=FRAME_USER):
|
||||
return [BEGIN, f"Host {FRAME_ALIAS}", f" HostName {host}", *([f" Port {port}"] if port != 22 else []),
|
||||
f" User {user}",
|
||||
" IdentityFile ~/.ssh/id_ed25519_frame", " IdentityFile ~/.ssh/id_rsa_frame_devkit",
|
||||
" IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END]
|
||||
|
||||
|
||||
def write_config(host, port=22, user=FRAME_USER):
|
||||
"""Replace our managed block and put it first: ssh uses the first value it sees per
|
||||
option. The trailing "Host *" returns the rest of the file to global scope."""
|
||||
make_ssh_dir()
|
||||
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
|
||||
kept, skip = [], False
|
||||
for line in old.splitlines():
|
||||
if line == BEGIN:
|
||||
skip = True
|
||||
elif line == END:
|
||||
skip = False
|
||||
elif not skip:
|
||||
kept.append(line)
|
||||
block = config_block(host, port, user)
|
||||
tmp = CONFIG.with_name("config.frame-control.tmp")
|
||||
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
||||
if os.name != "nt":
|
||||
tmp.chmod(0o600)
|
||||
# On Windows a running ssh.exe (Frame Control's own, say) keeps the config open
|
||||
# and locked, so the swap can fail for a moment; keep trying for a while.
|
||||
for attempt in range(60):
|
||||
try:
|
||||
os.replace(tmp, CONFIG)
|
||||
return
|
||||
except PermissionError:
|
||||
if attempt == 0:
|
||||
say(" ~/.ssh/config is in use by another ssh; waiting for it...")
|
||||
time.sleep(0.5)
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise SystemExit("~/.ssh/config stayed locked by another program. Quit Frame Control "
|
||||
"and any ssh windows, then run the setup again.")
|
||||
|
||||
|
||||
def key_login_works():
|
||||
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
|
||||
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
def configured_user():
|
||||
"""The User in our managed block, so a re-run keeps one the headset named earlier."""
|
||||
if not CONFIG.exists():
|
||||
return None
|
||||
inside = False
|
||||
for line in CONFIG.read_text(encoding="utf-8").splitlines():
|
||||
if line in (BEGIN, END):
|
||||
inside = line == BEGIN
|
||||
elif inside and line.startswith(" User "):
|
||||
name = line[7:].strip()
|
||||
return name if NAME_RE.fullmatch(name) else None
|
||||
return None
|
||||
|
||||
|
||||
def pair_with_devkit(host, port, user):
|
||||
"""Try devkit pairing and confirm key login. -> (user, None) or (user, reason to fall back)."""
|
||||
say("==> Pairing through the headset's SteamOS devkit service (no password)")
|
||||
chosen = [user]
|
||||
|
||||
def use_login(login):
|
||||
if login == chosen[0]:
|
||||
return
|
||||
if USER_FROM_ENV:
|
||||
say(f" the headset logs in as '{login}'; keeping FRAME_USER={user}")
|
||||
else:
|
||||
chosen[0] = login
|
||||
say(f" the headset logs in as '{login}'")
|
||||
write_config(host, port, login)
|
||||
|
||||
try:
|
||||
pub = DEVKIT_KEY.with_suffix(".pub").read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
return user, f"can't read the pairing key: {e}"
|
||||
reason = devkit_pair(host, pub, key_comment(platform.node()), on_login=use_login)
|
||||
if reason:
|
||||
return chosen[0], reason
|
||||
# The approval is what turns sshd on, so it may take a moment to answer.
|
||||
for _ in range(10):
|
||||
if key_login_works():
|
||||
return chosen[0], None
|
||||
time.sleep(1)
|
||||
return chosen[0], "paired, but key login still fails"
|
||||
|
||||
|
||||
def main(argv):
|
||||
if argv and argv[0] in ("-h", "--help"):
|
||||
sys.exit(__doc__)
|
||||
say("==> Looking for the Steam Frame")
|
||||
found = pick_host(argv[0] if argv else None)
|
||||
while not found:
|
||||
say("Could not reach the Frame over SSH.")
|
||||
say("Check: Developer Mode on and a user password set; same network; no client isolation.")
|
||||
try:
|
||||
typed = input("Type the Frame's IP address (Quick Settings shows it), or press Enter to quit: ").strip()
|
||||
except EOFError:
|
||||
typed = ""
|
||||
if not typed:
|
||||
return 1
|
||||
found = pick_host(typed)
|
||||
host, port = found
|
||||
say(f" found: {host}" + (f" port {port}" if port != 22 else ""))
|
||||
|
||||
say("==> SSH keys")
|
||||
make_ssh_dir()
|
||||
make_key(KEY, "ed25519", f"{platform.node() or 'computer'}->steam-frame")
|
||||
make_key(DEVKIT_KEY, "rsa", key_comment(platform.node()))
|
||||
|
||||
user = FRAME_USER if USER_FROM_ENV else (configured_user() or FRAME_USER)
|
||||
say(f"==> ~/.ssh/config alias '{FRAME_ALIAS}' -> {host}")
|
||||
write_config(host, port, user)
|
||||
|
||||
say("==> Checking key login")
|
||||
if key_login_works():
|
||||
say(" key login already works")
|
||||
else:
|
||||
user, reason = pair_with_devkit(host, port, user)
|
||||
if not reason:
|
||||
say(" paired; key login OK")
|
||||
else:
|
||||
say(f" {reason}; falling back to the password")
|
||||
say(" copying the key: enter the Developer Mode password when asked")
|
||||
pub = KEY.with_suffix(".pub").read_text(encoding="utf-8").strip()
|
||||
r = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "PubkeyAuthentication=no",
|
||||
"-p", str(port), f"{user}@{host}", ADD_KEY_CMD], input=pub + "\n", text=True)
|
||||
if r.returncode != 0 or not key_login_works():
|
||||
say("Key login still isn't working. Check the password and run this again.")
|
||||
return 1
|
||||
say(" key login OK")
|
||||
say(f"\nDone. Frame Control can reach the Frame now. In a terminal: ssh {FRAME_ALIAS}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,289 @@
|
||||
"""What differs between the computers Frame Control runs on: macOS, Linux, Windows.
|
||||
|
||||
Everything here runs on your computer, not the Frame. Python stdlib only.
|
||||
|
||||
CLI (used by the Electron app, so terminal handling lives in one place):
|
||||
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
||||
"""
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MAC = sys.platform == "darwin"
|
||||
WINDOWS = os.name == "nt"
|
||||
LINUX = not MAC and not WINDOWS
|
||||
NAME = "macOS" if MAC else "Windows" if WINDOWS else "Linux"
|
||||
FILE_MANAGER = "Finder" if MAC else "File Explorer" if WINDOWS else "your file manager"
|
||||
|
||||
# Windows' OpenSSH client can't share one connection between commands
|
||||
# (no ControlMaster), so there each command opens its own.
|
||||
MUX = not WINDOWS
|
||||
|
||||
# Popen() keyword arguments that detach a child from our console and signals.
|
||||
DETACHED = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if WINDOWS
|
||||
else {"start_new_session": True})
|
||||
|
||||
|
||||
class HostError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def data_dir(*parts):
|
||||
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME."""
|
||||
if MAC:
|
||||
base = Path.home() / "Library" / "Application Support" / "Frame Control"
|
||||
elif WINDOWS:
|
||||
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "Frame Control"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "frame-control"
|
||||
return base.joinpath(*parts)
|
||||
|
||||
|
||||
def cache_dir(*parts):
|
||||
if MAC:
|
||||
base = Path.home() / "Library" / "Caches" / "Frame Control"
|
||||
elif WINDOWS:
|
||||
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "Frame Control" / "Cache"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "frame-control"
|
||||
return base.joinpath(*parts)
|
||||
|
||||
|
||||
def control_path():
|
||||
"""ssh ControlPath for the shared connection, or None where it isn't supported.
|
||||
|
||||
/tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
||||
"""
|
||||
return f"/tmp/frame-ui-{os.getuid()}-%C" if MUX else None
|
||||
|
||||
|
||||
def which(name, *extra):
|
||||
"""First executable among PATH and the extra candidate paths."""
|
||||
for cand in (shutil.which(name), *extra):
|
||||
if cand and os.path.isfile(cand) and os.access(cand, os.X_OK):
|
||||
return cand
|
||||
return None
|
||||
|
||||
|
||||
def install_hint(tool):
|
||||
"""How to get a missing command-line tool on this computer."""
|
||||
hints = {
|
||||
"adb": {"mac": "brew install android-platform-tools",
|
||||
"win": "winget install Google.PlatformTools",
|
||||
"linux": "install your distribution's adb package (e.g. sudo apt install adb)"},
|
||||
}
|
||||
return hints[tool]["mac" if MAC else "win" if WINDOWS else "linux"]
|
||||
|
||||
|
||||
def android_sdk_dirs():
|
||||
"""Where the Android SDK usually lives, for adb."""
|
||||
dirs = [os.environ.get("ANDROID_HOME"), os.environ.get("ANDROID_SDK_ROOT")]
|
||||
if MAC:
|
||||
dirs += ["~/Library/Android/sdk", "/opt/homebrew/share/android-commandlinetools",
|
||||
"~/.homebrew/share/android-commandlinetools"]
|
||||
elif WINDOWS:
|
||||
dirs += [os.path.join(os.environ.get("LOCALAPPDATA", ""), "Android", "Sdk")]
|
||||
else:
|
||||
dirs += ["~/Android/Sdk", "/usr/lib/android-sdk"]
|
||||
return [os.path.expanduser(d) for d in dirs if d]
|
||||
|
||||
|
||||
def adb():
|
||||
exe = "adb.exe" if WINDOWS else "adb"
|
||||
extra = [os.path.join(d, "platform-tools", exe) for d in android_sdk_dirs()]
|
||||
if MAC:
|
||||
extra += ["/opt/homebrew/bin/adb", str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"]
|
||||
# The app bundles adb as a last resort: an adb you already use goes first, so
|
||||
# two different adb versions don't keep restarting each other's server.
|
||||
tools = os.environ.get("FRAME_CONTROL_TOOLS")
|
||||
if tools:
|
||||
extra.append(os.path.join(tools, exe))
|
||||
env = os.environ.get("ADB")
|
||||
found = (env if env and os.access(env, os.X_OK) else None) or which("adb", *extra)
|
||||
if not found:
|
||||
raise HostError(f"adb isn't installed on this computer: {install_hint('adb')}")
|
||||
return found
|
||||
|
||||
|
||||
def trust_bundled_cas():
|
||||
"""Trust the app's CA bundle for HTTPS as well as the system's certificates.
|
||||
|
||||
Python on Windows only sees the root certificates already in the Windows
|
||||
store, and a fresh install fetches those lazily, so Steam and F-Droid can
|
||||
fail with CERTIFICATE_VERIFY_FAILED. The app bundles curl's copy of Mozilla's
|
||||
CA list (app/build/fetch-deps.js); outside the app this does nothing. Call it
|
||||
before the first urlopen: urllib keeps the HTTPS context it builds then.
|
||||
"""
|
||||
tools = os.environ.get("FRAME_CONTROL_TOOLS")
|
||||
cafile = os.path.join(tools, "cacert.pem") if tools else None
|
||||
if not cafile or not os.path.isfile(cafile):
|
||||
return
|
||||
|
||||
def context(*args, **kwargs):
|
||||
ctx = ssl.create_default_context(*args, **kwargs)
|
||||
ctx.load_verify_locations(cafile)
|
||||
return ctx
|
||||
ssl._create_default_https_context = context # urllib's default for HTTPS
|
||||
|
||||
|
||||
def open_path(path):
|
||||
"""Show a folder or file in the file manager."""
|
||||
path = str(path)
|
||||
if WINDOWS:
|
||||
os.startfile(path) # noqa: pylint only on Windows
|
||||
return
|
||||
opener = "open" if MAC else which("xdg-open")
|
||||
if not opener:
|
||||
raise HostError("xdg-open isn't installed, so the folder can't be opened")
|
||||
subprocess.Popen([opener, path], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, **DETACHED)
|
||||
|
||||
|
||||
open_url = open_path # the same openers hand URLs to the default browser
|
||||
|
||||
|
||||
def _spawn(argv):
|
||||
subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, **DETACHED)
|
||||
|
||||
|
||||
def open_terminal(argv, title="Frame Control"):
|
||||
"""Run argv in a new terminal window, for anything that asks for a password.
|
||||
|
||||
The window stays open after the command ends, so its output can be read.
|
||||
"""
|
||||
argv = [str(a) for a in argv]
|
||||
if MAC:
|
||||
command = shlex.join(argv).replace("\\", "\\\\").replace('"', '\\"')
|
||||
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
|
||||
"-e", f'do script "{command}"', "-e", "activate", "-e", "end tell"],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
# Usually macOS Automation consent for Terminal was denied.
|
||||
raise HostError(f"Couldn't open Terminal: {r.stderr.strip()}")
|
||||
return "Terminal"
|
||||
if WINDOWS:
|
||||
# `start` gives the command its own console window; cmd /k keeps it open.
|
||||
# One hand-built command line: quoting it twice through list2cmdline would
|
||||
# produce backslash-escaped quotes, which cmd doesn't understand.
|
||||
# Every argument is quoted, so cmd treats & | < > ^ in them literally. cmd has
|
||||
# no escape for a quote inside quotes (and expands %VAR% regardless), so refuse those.
|
||||
if any(c in a for a in argv for c in '"%\r\n'):
|
||||
raise HostError("Can't pass quotes or % to a Windows terminal")
|
||||
inner = " ".join(f'"{a}"' for a in argv)
|
||||
subprocess.Popen(f'cmd.exe /c start "{title}" cmd.exe /k "{inner}"', **DETACHED)
|
||||
return "a terminal window"
|
||||
script = f'{shlex.join(argv)}; echo; read -r -p "Press Enter to close. " _'
|
||||
# flags=None: the terminal takes the whole command as one string after -e.
|
||||
for name, flags in (("x-terminal-emulator", ["-e"]), ("gnome-terminal", ["--"]), ("ptyxis", ["--"]),
|
||||
("kgx", ["--"]), ("konsole", ["-e"]), ("xfce4-terminal", ["-x"]),
|
||||
("tilix", None), ("lxterminal", None), ("kitty", []), ("alacritty", ["-e"]),
|
||||
("wezterm", ["start", "--"]), ("foot", []), ("xterm", ["-e"])):
|
||||
exe = which(name)
|
||||
if not exe:
|
||||
continue
|
||||
if flags is None:
|
||||
_spawn([exe, "-e", "bash -c " + shlex.quote(script)])
|
||||
elif name == "x-terminal-emulator" and "lxterminal" in os.path.realpath(exe):
|
||||
_spawn([exe, "-e", "bash -c " + shlex.quote(script)]) # Debian alternative -> lxterminal
|
||||
else:
|
||||
_spawn([exe, *flags, "bash", "-c", script])
|
||||
return name
|
||||
raise HostError("No terminal program found (tried gnome-terminal, konsole, xterm and others)")
|
||||
|
||||
|
||||
def clipboard_text():
|
||||
"""The text on this computer's clipboard."""
|
||||
if MAC:
|
||||
cmds = [["pbpaste"]]
|
||||
elif WINDOWS:
|
||||
cmds = [["powershell.exe", "-NoProfile", "-Command",
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8; Get-Clipboard -Raw"]]
|
||||
else:
|
||||
cmds = [["wl-paste", "--no-newline"], ["xclip", "-selection", "clipboard", "-o"],
|
||||
["xsel", "--clipboard", "--output"]]
|
||||
for cmd in cmds:
|
||||
if not shutil.which(cmd[0]):
|
||||
continue
|
||||
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, timeout=10)
|
||||
if r.returncode == 0:
|
||||
text = r.stdout.decode("utf-8", errors="replace")
|
||||
return text[:-2] if WINDOWS and text.endswith("\r\n") else text
|
||||
if LINUX:
|
||||
raise HostError("Can't read the clipboard: install wl-clipboard (Wayland) or xclip (X11)")
|
||||
raise HostError("Can't read the clipboard")
|
||||
|
||||
|
||||
def ssh_hostname(alias):
|
||||
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
||||
try:
|
||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return alias
|
||||
for line in out.splitlines():
|
||||
if line.startswith("hostname "):
|
||||
return line.split(None, 1)[1].strip()
|
||||
return alias
|
||||
|
||||
|
||||
# Apps the UI can hand off to, per platform: (installed-check, launch argv) pairs,
|
||||
# and where to get the app when none is installed.
|
||||
def open_steam_link():
|
||||
if MAC:
|
||||
if subprocess.run(["open", "-a", "Steam Link"], capture_output=True).returncode == 0:
|
||||
return "Opened Steam Link"
|
||||
elif WINDOWS:
|
||||
for base in (os.environ.get("ProgramFiles(x86)"), os.environ.get("ProgramFiles")):
|
||||
exe = base and os.path.join(base, "Steam Link", "SteamLink.exe")
|
||||
if exe and os.path.isfile(exe):
|
||||
_spawn([exe])
|
||||
return "Opened Steam Link"
|
||||
else:
|
||||
if which("steamlink"):
|
||||
_spawn([which("steamlink")])
|
||||
return "Opened Steam Link"
|
||||
if which("flatpak") and subprocess.run(["flatpak", "info", "com.valvesoftware.SteamLink"],
|
||||
capture_output=True).returncode == 0:
|
||||
_spawn(["flatpak", "run", "com.valvesoftware.SteamLink"])
|
||||
return "Opened Steam Link"
|
||||
open_url("https://store.steampowered.com/remoteplay")
|
||||
return "Steam Link isn't installed; opened its download page"
|
||||
|
||||
|
||||
def open_rdp(alias):
|
||||
"""Remote desktop to the Frame's xrdp (user steamos)."""
|
||||
host = ssh_hostname(alias)
|
||||
if MAC:
|
||||
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
||||
return "Opened Windows App"
|
||||
open_url("https://apps.apple.com/app/windows-app/id1295203466")
|
||||
return "Windows App isn't installed; opened its App Store page"
|
||||
if WINDOWS:
|
||||
_spawn(["mstsc.exe", f"/v:{host}"])
|
||||
return f"Opened Remote Desktop to {host}"
|
||||
if which("remmina"):
|
||||
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
|
||||
return f"Opened Remmina to {host}"
|
||||
for name in ("xfreerdp3", "xfreerdp"):
|
||||
if which(name):
|
||||
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
|
||||
return f"Opened FreeRDP to {host}"
|
||||
raise HostError("No RDP client found: install Remmina or FreeRDP")
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) >= 3 and argv[0] == "terminal" and argv[1] == "--":
|
||||
try:
|
||||
print(f"Opened {open_terminal(argv[2:])}")
|
||||
except HostError as e:
|
||||
sys.exit(str(e))
|
||||
return
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
"""Mac side: search the Steam store and look up each result's Steam Frame rating.
|
||||
"""Computer side: search the Steam store and look up each result's Steam Frame rating.
|
||||
|
||||
Uses the store's public endpoints (no key, no login):
|
||||
api/storesearch name search, price in the IP's currency
|
||||
|
||||
@@ -0,0 +1,819 @@
|
||||
"""Linux and Windows builds on the Frame as Steam "Devkit Games".
|
||||
|
||||
A .zip, a folder or a single executable becomes a title in the Steam library,
|
||||
through the same path as Valve's SteamOS Devkit Client: its devkit-utils
|
||||
(vendored in frame/devkit-utils, synced to ~/devkit-utils on the Frame) make
|
||||
~/devkit-game/<id>/, the files are copied there, and steam-client-create-shortcut
|
||||
asks the running Steam client to register it with a runtime:
|
||||
|
||||
Windows .exe -> Proton Experimental (steam_play=1; x86-64 runs through FEX)
|
||||
aarch64 ELF -> Steam Linux Runtime 4.0 ARM64 (steam_play=0)
|
||||
x86-64 ELF -> Steam Linux Runtime 4.0 (steam_play=0; runs through FEX)
|
||||
|
||||
Everything device-side is inferred from Valve's steamos-devkit source until
|
||||
checked on a headset; see docs/sideloading.md.
|
||||
|
||||
Python stdlib only. CLI:
|
||||
python3 ui/frame_titles.py inspect PATH
|
||||
python3 ui/frame_titles.py install PATH [--name N] [--exe REL] [--runtime R]
|
||||
python3 ui/frame_titles.py list | launch ID | remove ID
|
||||
"""
|
||||
import hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile
|
||||
|
||||
import frame_android
|
||||
import frame_host
|
||||
from frame_android import FrameError
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
UTILS_LOCAL = os.path.join(ROOT, 'frame', 'devkit-utils')
|
||||
UTILS = 'devkit-utils' # on the Frame, relative to $HOME (where Valve's client puts it)
|
||||
GAMES = 'devkit-game' # ditto; steamos-prepare-upload makes <id>/ in here
|
||||
STAMP = '.frame-control-stamp'
|
||||
PY = 'python3 ~/' + UTILS + '/'
|
||||
|
||||
# Valve's reserved sideload names: uploading one of these replaces the Steam client itself.
|
||||
# devkit-steam is the trampoline file that switches SteamOS to a sideloaded client
|
||||
# (select_steam.sh); a folder there breaks Valve's devkit tools.
|
||||
RESERVED_IDS = ('steam', 'steamdeckard', 'steamvr', 'steamvrdeckard', 'devkit-steam')
|
||||
ID_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$')
|
||||
DIR_RE = re.compile(r'^/[A-Za-z0-9_./-]+$')
|
||||
|
||||
# Zip limits: well above any real game, well below a zip bomb.
|
||||
MAX_UNPACKED = 64 * 1024**3
|
||||
MAX_ENTRIES = 200000
|
||||
TMP_PREFIX = 'frame-title-' # then the server's PID, so server.sweep_tmp can clear a killed run's
|
||||
MAX_RATIO = 200 # uncompressed / compressed, once past 1 GB
|
||||
|
||||
# The Steam compat tool aliases Valve's client uses (devkit_client RUNTIME_ALIASES).
|
||||
RUNTIMES = {
|
||||
'proton-experimental': {'label': 'Proton Experimental', 'steam_play': True},
|
||||
'proton-stable': {'label': 'Proton (stable)', 'steam_play': True},
|
||||
'SteamLinuxRuntime_4-arm64': {'label': 'Steam Linux Runtime 4.0 (ARM64)', 'steam_play': False},
|
||||
'SteamLinuxRuntime_4': {'label': 'Steam Linux Runtime 4.0 (x86-64, through FEX)', 'steam_play': False},
|
||||
}
|
||||
# Experimental rather than stable: the Frame's ARM64 Proton + FEX stack is new,
|
||||
# and Proton fixes reach Experimental first. --runtime proton-stable switches.
|
||||
DEFAULT_PROTON = 'proton-experimental'
|
||||
|
||||
ELF_MACHINES = {0xB7: 'arm64', 0x3E: 'x86_64', 0x03: 'x86', 0x28: 'arm'}
|
||||
PE_MACHINES = {0x8664: 'x86_64', 0xAA64: 'arm64', 0x14C: 'x86', 0x1C4: 'arm'}
|
||||
# Executables that are never the game: crash reporters, installers, redistributables.
|
||||
SKIP_RE = re.compile(r'crash|unins|setup|install|redist|dxsetup|dxwebsetup|dotnet|prereq|'
|
||||
r'easyanticheat|eac_|updater|uploader|report|sandbox|helper', re.I)
|
||||
SKIP_DIRS = re.compile(r'^(_*commonredist|redist|redistributables?|directx|vcredist|__installer|'
|
||||
r'installers?|prereqs?|support|engine|__macosx)$', re.I)
|
||||
# Trailing words of an archive name that describe the build, not the game.
|
||||
BUILD_WORDS = re.compile(r'([ ._-]+(win(dows)?(32|64)?|linux(32|64)?|x64|x86(_64)?|amd64|arm64|aarch64|'
|
||||
r'build|release|portable|steamos|v\d+([._]\d+)*|\d+([._]\d+)+))+$', re.I)
|
||||
|
||||
|
||||
def classify(path):
|
||||
"""{'format': 'elf'|'pe'|'script', 'arch', 'exe'} for an executable file, else None."""
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
head = f.read(4096)
|
||||
if head[:4] == b'\x7fELF':
|
||||
return _elf(f, head)
|
||||
if head[:2] == b'MZ':
|
||||
return _pe(f, head)
|
||||
except (OSError, struct.error, ValueError, OverflowError):
|
||||
return None # unreadable, or a header that lies about its sizes
|
||||
if head[:2] == b'#!' or path.lower().endswith('.sh'):
|
||||
return {'format': 'script', 'arch': None, 'exe': True}
|
||||
return None
|
||||
|
||||
|
||||
def _elf(f, head):
|
||||
if len(head) < 64 or head[4] not in (1, 2) or head[5] not in (1, 2):
|
||||
return None
|
||||
wide, end = head[4] == 2, '<' if head[5] == 1 else '>'
|
||||
e_type, machine = struct.unpack_from(end + 'HH', head, 16)
|
||||
arch = ELF_MACHINES.get(machine, f'elf-0x{machine:x}')
|
||||
if e_type == 2: # ET_EXEC
|
||||
return {'format': 'elf', 'arch': arch, 'exe': True}
|
||||
if e_type != 3: # not ET_DYN either: object file, core dump
|
||||
return {'format': 'elf', 'arch': arch, 'exe': False}
|
||||
# ET_DYN is a PIE executable or a shared library; only executables ask for an interpreter.
|
||||
if wide:
|
||||
phoff, = struct.unpack_from(end + 'Q', head, 32)
|
||||
phentsize, phnum = struct.unpack_from(end + 'HH', head, 54)
|
||||
else:
|
||||
phoff, = struct.unpack_from(end + 'I', head, 28)
|
||||
phentsize, phnum = struct.unpack_from(end + 'HH', head, 42)
|
||||
if phentsize < (56 if wide else 32) or phnum > 256:
|
||||
return {'format': 'elf', 'arch': arch, 'exe': False}
|
||||
f.seek(phoff)
|
||||
table = f.read(phentsize * phnum)
|
||||
interp = any(struct.unpack_from(end + 'I', table, i * phentsize)[0] == 3 # PT_INTERP
|
||||
for i in range(len(table) // phentsize))
|
||||
return {'format': 'elf', 'arch': arch, 'exe': interp}
|
||||
|
||||
|
||||
def _pe(f, head):
|
||||
if len(head) < 0x40:
|
||||
return None
|
||||
lfanew, = struct.unpack_from('<I', head, 0x3C)
|
||||
f.seek(lfanew)
|
||||
coff = f.read(24)
|
||||
if len(coff) < 24 or coff[:4] != b'PE\0\0':
|
||||
return None # a DOS program, or not an executable at all
|
||||
machine, = struct.unpack_from('<H', coff, 4)
|
||||
characteristics, = struct.unpack_from('<H', coff, 22)
|
||||
return {'format': 'pe', 'arch': PE_MACHINES.get(machine, f'pe-0x{machine:x}'),
|
||||
'exe': not characteristics & 0x2000} # IMAGE_FILE_DLL
|
||||
|
||||
|
||||
def title_id(name):
|
||||
"""The Devkit Game id Steam shows as the title's name: [A-Za-z0-9_-], at most 64."""
|
||||
s = re.sub(r'[^A-Za-z0-9_-]+', '_', str(name or '').strip())
|
||||
s = re.sub(r'_+', '_', s).strip('_-')[:64].strip('_-')
|
||||
if not s:
|
||||
raise FrameError('the title needs a name with some letters or digits')
|
||||
if s.lower() in RESERVED_IDS:
|
||||
s += '-game'
|
||||
return s
|
||||
|
||||
|
||||
def display_name(filename):
|
||||
"""A title name from a zip, folder or exe name: no extension or build/platform words."""
|
||||
base = os.path.basename(str(filename).rstrip('/\\'))
|
||||
stem, ext = os.path.splitext(base)
|
||||
if ext.lower() in ('.zip', '.exe', '.sh', '.x86_64', '.arm64', '.aarch64', '.bin'):
|
||||
base = stem
|
||||
return BUILD_WORDS.sub('', base) or base
|
||||
|
||||
|
||||
def _norm(s):
|
||||
return re.sub(r'[^a-z0-9]', '', s.lower())
|
||||
|
||||
|
||||
# ---- payload: zip, folder or single file -> a local tree to upload ----------
|
||||
|
||||
def extract_zip(zpath, dest):
|
||||
"""Unpack a zip into dest, refusing paths that escape it and absurd sizes."""
|
||||
try:
|
||||
z = zipfile.ZipFile(zpath)
|
||||
except (zipfile.BadZipFile, OSError) as e:
|
||||
raise FrameError(f'{os.path.basename(zpath)} is not a readable zip: {e}')
|
||||
with z:
|
||||
infos = z.infolist()
|
||||
if len(infos) > MAX_ENTRIES:
|
||||
raise FrameError(f'the zip has {len(infos)} entries; the limit is {MAX_ENTRIES}')
|
||||
total = sum(i.file_size for i in infos)
|
||||
packed = max(1, os.path.getsize(zpath))
|
||||
if total > MAX_UNPACKED or (total > 1024**3 and total > packed * MAX_RATIO):
|
||||
raise FrameError(f'the zip would unpack to {total / 1024**3:.1f} GB, which looks wrong')
|
||||
free = shutil.disk_usage(dest).free
|
||||
if total + 256 * 1024**2 > free:
|
||||
raise FrameError(f'not enough space on this computer to unpack the zip '
|
||||
f'({total / 1024**3:.1f} GB needed, {free / 1024**3:.1f} GB free)')
|
||||
try:
|
||||
_extract_members(z, infos, os.path.realpath(dest))
|
||||
except FrameError:
|
||||
raise # a RuntimeError too, but already worded
|
||||
except (zipfile.BadZipFile, RuntimeError, NotImplementedError, EOFError, OSError) as e:
|
||||
# Encrypted or corrupt members, unsupported compression, clashing names, a full disk.
|
||||
raise FrameError(f'could not unpack {os.path.basename(zpath)}: {e}')
|
||||
|
||||
|
||||
def _extract_members(z, infos, root):
|
||||
# No symlink is ever created here, so no write can be redirected through one
|
||||
# (chained links, Windows without the privilege). Links inside the zip are
|
||||
# resolved on paper and materialised as copies once the real files are out.
|
||||
links = {}
|
||||
for info in infos:
|
||||
rel = _safe_member(info.filename)
|
||||
if rel is None:
|
||||
continue
|
||||
target = _inside(root, rel, info.filename)
|
||||
mode = info.external_attr >> 16
|
||||
if info.is_dir():
|
||||
os.makedirs(target, exist_ok=True)
|
||||
continue
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
if stat.S_ISLNK(mode):
|
||||
if info.file_size > 4096: # a link's content is a path, never this big
|
||||
raise FrameError(f'the zip has an oversized link: {info.filename}')
|
||||
link = z.read(info).decode('utf-8', 'replace').replace('\\', '/')
|
||||
dest = posixpath.normpath(posixpath.join(posixpath.dirname(rel), link))
|
||||
if link.startswith('/') or ':' in link or dest == '..' or dest.startswith('../'):
|
||||
raise FrameError(f'the zip has a link that points outside it: {info.filename}')
|
||||
links[rel] = link # as written: resolved later, one component at a time
|
||||
continue
|
||||
with z.open(info) as src, open(target, 'wb') as out:
|
||||
shutil.copyfileobj(src, out, 1 << 20)
|
||||
if mode & 0o111:
|
||||
os.chmod(target, 0o755)
|
||||
_materialise_links(root, links)
|
||||
|
||||
|
||||
def _inside(root, rel, name):
|
||||
"""rel's path under root, refusing anything that resolves outside it."""
|
||||
target = os.path.join(root, *rel.split('/'))
|
||||
if not (os.path.realpath(target) + os.sep).startswith(root + os.sep):
|
||||
raise FrameError(f'the zip has a path that climbs out of it: {name}')
|
||||
return target
|
||||
|
||||
|
||||
def _resolve_link(path, links):
|
||||
"""path with every link in it followed, on paper; None if it loops or leaves the zip.
|
||||
|
||||
Like the kernel: each component in turn, so 'dirlink/..' is the parent of
|
||||
where dirlink points, not the folder dirlink sits in.
|
||||
"""
|
||||
todo, done, hops = path.split('/'), [], 0
|
||||
while todo:
|
||||
part = todo.pop(0)
|
||||
if part in ('', '.'):
|
||||
continue
|
||||
if part == '..':
|
||||
if not done:
|
||||
return None
|
||||
done.pop()
|
||||
continue
|
||||
done.append(part)
|
||||
text = links.get('/'.join(done))
|
||||
if text is not None:
|
||||
hops += 1
|
||||
if hops > 40:
|
||||
return None
|
||||
done.pop() # link text is relative to the link's folder
|
||||
todo = text.split('/') + todo
|
||||
return '/'.join(done)
|
||||
|
||||
|
||||
def _materialise_links(root, links):
|
||||
"""Copy the file each link names into its place (lib.so.1 -> lib.so.1.2.3 and the like).
|
||||
|
||||
Only links to files: a folder link could hold itself, and game builds link
|
||||
libraries, not folders. Folder, looping and dangling links are dropped.
|
||||
"""
|
||||
copies = []
|
||||
for rel in sorted(links):
|
||||
dest = _resolve_link(rel, links)
|
||||
if not dest:
|
||||
continue # loops, escapes, or the zip's own top folder
|
||||
src, target = _inside(root, dest, rel), _inside(root, rel, rel)
|
||||
if os.path.isfile(src) and not os.path.lexists(target): # a real entry may have the name
|
||||
copies.append((src, target))
|
||||
# Many links to one big file could fill the disk: the same limits as the zip itself.
|
||||
need = sum(os.path.getsize(src) for src, _ in copies)
|
||||
if need + _tree_size(root) > MAX_UNPACKED:
|
||||
raise FrameError('the zip\'s links would copy more than it holds, which looks wrong')
|
||||
if need + 256 * 1024**2 > shutil.disk_usage(root).free:
|
||||
raise FrameError(f'not enough space on this computer for the zip\'s linked files ({need / 1024**3:.1f} GB)')
|
||||
for src, target in copies:
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
shutil.copy2(src, target)
|
||||
|
||||
|
||||
def _safe_member(name):
|
||||
"""The member's relative path with / separators, None to skip it; raises if it escapes."""
|
||||
rel = name.replace('\\', '/')
|
||||
if rel.startswith('/') or re.match(r'^[A-Za-z]:', rel):
|
||||
raise FrameError(f'the zip has an absolute path: {name}')
|
||||
parts = [p for p in rel.split('/') if p not in ('', '.')]
|
||||
if any(p == '..' for p in parts):
|
||||
raise FrameError(f'the zip has a path that climbs out of it: {name}')
|
||||
if any(':' in p for p in parts):
|
||||
# Drive-qualified parts ('C:..') climb out on Windows; ':' is an NTFS stream elsewhere.
|
||||
raise FrameError(f'the zip has a path with a drive or stream name: {name}')
|
||||
if not parts or parts[0] == '__MACOSX' or parts[-1] in ('.DS_Store', 'Thumbs.db'):
|
||||
return None
|
||||
return '/'.join(parts)
|
||||
|
||||
|
||||
def _redirected(path, expected):
|
||||
"""True if path is a symlink, or resolves somewhere else (a Windows junction isn't islink)."""
|
||||
return os.path.islink(path) or os.path.normcase(os.path.realpath(path)) != os.path.normcase(expected)
|
||||
|
||||
|
||||
def _has_links(root):
|
||||
real = os.path.realpath(root)
|
||||
for dirpath, dirnames, filenames in os.walk(real):
|
||||
for n in dirnames + filenames:
|
||||
if _redirected(os.path.join(dirpath, n), os.path.join(dirpath, n)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _stage_folder(src, dest):
|
||||
"""Copy src to dest; links to files inside src become copies, all other links are left out."""
|
||||
real = os.path.realpath(src)
|
||||
inside = lambda p: os.path.normcase(p).startswith(os.path.normcase(real) + os.sep) # noqa: E731
|
||||
folders, copies = [], [] # decide everything first, so the space check sees the same files
|
||||
for dirpath, dirnames, filenames in os.walk(real):
|
||||
out = os.path.join(dest, os.path.relpath(dirpath, real))
|
||||
folders.append(out)
|
||||
# Only descend into real folders: not symlinked ones, not junctions (os.walk follows those).
|
||||
linked = [d for d in dirnames if _redirected(os.path.join(dirpath, d), os.path.join(dirpath, d))]
|
||||
dirnames[:] = [d for d in dirnames if d not in linked]
|
||||
for fn in filenames + linked:
|
||||
target = os.path.realpath(os.path.join(dirpath, fn))
|
||||
if inside(target) and os.path.isfile(target):
|
||||
copies.append((target, os.path.join(out, fn)))
|
||||
if shutil.disk_usage(os.path.dirname(dest)).free < sum(os.path.getsize(t) for t, _ in copies) + 256 * 1024**2:
|
||||
raise FrameError('not enough space on this computer to stage the folder')
|
||||
for out in folders:
|
||||
os.makedirs(out, exist_ok=True)
|
||||
for target, out in copies:
|
||||
shutil.copy2(target, out)
|
||||
return dest
|
||||
|
||||
|
||||
def _below(top, root):
|
||||
"""The folders _unwrap stepped through from top to root, as 'a/b', or ''. Taken
|
||||
before staging moves root elsewhere (possibly another drive on Windows)."""
|
||||
rel = os.path.relpath(root, os.path.realpath(top)).replace(os.sep, '/')
|
||||
return '' if rel == '.' else rel
|
||||
|
||||
|
||||
def _unwrap(root):
|
||||
"""Step into a single top-level folder, the usual shape of a zipped build.
|
||||
|
||||
Never through a link or junction: the folder you chose (or unpacked) stays the boundary.
|
||||
"""
|
||||
root = os.path.realpath(root)
|
||||
for _ in range(4):
|
||||
entries = [e for e in os.listdir(root) if e not in ('__MACOSX', '.DS_Store', 'Thumbs.db')]
|
||||
if len(entries) != 1:
|
||||
break
|
||||
only = os.path.join(root, entries[0])
|
||||
if not os.path.isdir(only) or _redirected(only, only):
|
||||
break
|
||||
root = only
|
||||
return root
|
||||
|
||||
|
||||
def candidates(root, title=''):
|
||||
"""Executables under root, best launch target first."""
|
||||
found = []
|
||||
want = _norm(title)
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = sorted(d for d in dirnames if not os.path.islink(os.path.join(dirpath, d)))
|
||||
rel_dir = os.path.relpath(dirpath, root)
|
||||
parts = [] if rel_dir == '.' else rel_dir.split(os.sep)
|
||||
for fn in sorted(filenames):
|
||||
full = os.path.join(dirpath, fn)
|
||||
if os.path.islink(full) or not os.path.isfile(full):
|
||||
continue
|
||||
c = classify(full)
|
||||
if not c or not c['exe']:
|
||||
continue
|
||||
stem = _norm(os.path.splitext(fn)[0])
|
||||
skip = bool(SKIP_RE.search(fn)) or any(SKIP_DIRS.match(p) for p in parts)
|
||||
match = 2 if want and stem == want else 1 if want and stem and (want in stem or stem in want) else 0
|
||||
found.append({'path': '/'.join(parts + [fn]), 'format': c['format'], 'arch': c['arch'],
|
||||
'size': os.path.getsize(full), 'depth': len(parts), 'skip': skip, 'match': match})
|
||||
found.sort(key=_rank)
|
||||
_prefer_launcher_script(found)
|
||||
return found
|
||||
|
||||
|
||||
def _platform_rank(c):
|
||||
# Native ARM64 first, then Proton, then x86-64 Linux through FEX; scripts are placed separately.
|
||||
order = {('elf', 'arm64'): 0, ('pe', 'x86_64'): 1, ('elf', 'x86_64'): 2, ('pe', 'x86'): 3, ('pe', 'arm64'): 3}
|
||||
return order.get((c['format'], c['arch']), 5 if c['format'] == 'script' else 6)
|
||||
|
||||
|
||||
def _rank(c):
|
||||
return (c['skip'], _platform_rank(c), -c['match'], c['depth'], -c['size'], c['path'])
|
||||
|
||||
|
||||
def _prefer_launcher_script(found):
|
||||
"""A top-level shell script beats a Linux binary one folder down (run.sh + bin/game)."""
|
||||
if not found or found[0]['format'] != 'elf' or found[0]['depth'] == 0:
|
||||
return
|
||||
for i, c in enumerate(found):
|
||||
if c['format'] == 'script' and c['depth'] == 0 and not c['skip']:
|
||||
found.insert(0, found.pop(i))
|
||||
return
|
||||
|
||||
|
||||
def runtime_for(target, found=()):
|
||||
"""(compat tool alias, note) for a launch target, or raise FrameError if it can't run."""
|
||||
fmt, arch = target['format'], target['arch']
|
||||
if fmt == 'script':
|
||||
# A script runs in the runtime of the binaries next to it; alone, natively.
|
||||
elf = next((c for c in found if c['format'] == 'elf' and not c['skip']), None)
|
||||
if elf:
|
||||
return runtime_for(elf)[0], 'A shell script; runtime chosen from the Linux binary next to it.'
|
||||
return 'SteamLinuxRuntime_4-arm64', 'A shell script with no Linux binary beside it; run natively.'
|
||||
if fmt == 'pe':
|
||||
if arch not in ('x86_64', 'x86', 'arm64'):
|
||||
raise FrameError(f"{target['path']} is a Windows program for {arch}, which Proton can't run")
|
||||
note = 'Windows x86-64 build: Proton runs it through FEX.' if arch == 'x86_64' else \
|
||||
f'Windows {arch} build under Proton.'
|
||||
return DEFAULT_PROTON, note
|
||||
if fmt == 'elf' and arch == 'arm64':
|
||||
return 'SteamLinuxRuntime_4-arm64', 'Native ARM64 Linux build.'
|
||||
if fmt == 'elf' and arch == 'x86_64':
|
||||
return 'SteamLinuxRuntime_4', ("x86-64 Linux build: probably won't start. It needs the x86-64 Steam "
|
||||
"Linux Runtime 4.0, which the Frame didn't install for a sideloaded title "
|
||||
"(2026-09-26). Use an ARM64 or Windows build if there is one.")
|
||||
raise FrameError(f"{target['path']} is a {arch} Linux program; the Frame runs ARM64 and x86-64 (through FEX) only")
|
||||
|
||||
|
||||
def allowed_runtimes(target, found=()):
|
||||
alias, _ = runtime_for(target, found)
|
||||
return ['proton-experimental', 'proton-stable'] if RUNTIMES[alias]['steam_play'] else [alias]
|
||||
|
||||
|
||||
def inspect(path, name=None):
|
||||
"""Read a .zip, folder or executable into an install plan (a JSON-safe dict).
|
||||
|
||||
A zip is unpacked into a temporary folder, plan['work']; pass the plan to
|
||||
discard() when done with it. Raises FrameError if nothing in it can run.
|
||||
"""
|
||||
path = os.path.abspath(path)
|
||||
if not os.path.exists(path):
|
||||
raise FrameError(f'{path} does not exist')
|
||||
work = None
|
||||
try:
|
||||
if os.path.isdir(path):
|
||||
root = _unwrap(path)
|
||||
unwrapped = _below(path, root)
|
||||
if _has_links(root):
|
||||
# scp -r follows links, so a link out of the folder could upload
|
||||
# anything; copy the folder with its links made safe first.
|
||||
work = tempfile.mkdtemp(prefix=f'{TMP_PREFIX}{os.getpid()}-')
|
||||
root = _stage_folder(root, os.path.join(work, os.path.basename(root)))
|
||||
elif path.lower().endswith('.zip'):
|
||||
work = tempfile.mkdtemp(prefix=f'{TMP_PREFIX}{os.getpid()}-')
|
||||
extract_zip(path, work)
|
||||
root = _unwrap(work)
|
||||
unwrapped = _below(work, root)
|
||||
elif classify(path):
|
||||
# A single executable is uploaded on its own; don't copy a whole Downloads folder.
|
||||
work = tempfile.mkdtemp(prefix=f'{TMP_PREFIX}{os.getpid()}-')
|
||||
shutil.copy2(path, os.path.join(work, os.path.basename(path)))
|
||||
root, unwrapped = work, ''
|
||||
else:
|
||||
raise FrameError(f'{os.path.basename(path)} is not a .zip, a folder or a program')
|
||||
title = name or display_name(os.path.basename(path.rstrip('/\\')))
|
||||
found = candidates(root, title)
|
||||
if not found:
|
||||
raise FrameError(f'no Linux or Windows program found in {os.path.basename(path)}')
|
||||
plan = {'source': os.path.basename(path.rstrip('/\\')), 'name': title, 'id': title_id(title),
|
||||
'root': root, 'work': work, 'candidates': found,
|
||||
'unwrapped': unwrapped,
|
||||
'size': _tree_size(root), 'warnings': []}
|
||||
_choose(plan, found[0]['path'])
|
||||
return plan
|
||||
except BaseException:
|
||||
if work:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def _tree_size(root):
|
||||
total = 0
|
||||
for dirpath, _, filenames in os.walk(root):
|
||||
for fn in filenames:
|
||||
full = os.path.join(dirpath, fn)
|
||||
if os.path.isfile(full) and not os.path.islink(full):
|
||||
total += os.path.getsize(full)
|
||||
return total
|
||||
|
||||
|
||||
def _choose(plan, rel, runtime=None):
|
||||
"""Set plan's launch target (a path relative to root) and its runtime."""
|
||||
rel = rel.replace('\\', '/')
|
||||
# A manifest may name the program as it is in the archive, above the folder
|
||||
# _unwrap stepped into. A path that works as it is always wins.
|
||||
prefix = plan.get('unwrapped') and plan['unwrapped'] + '/'
|
||||
if (prefix and rel.startswith(prefix) and not any(c['path'] == rel for c in plan['candidates'])
|
||||
and not os.path.isfile(os.path.join(plan['root'], *rel.split('/')))):
|
||||
rel = rel[len(prefix):]
|
||||
target = next((c for c in plan['candidates'] if c['path'] == rel), None)
|
||||
if target is None:
|
||||
full = os.path.realpath(os.path.join(plan['root'], *rel.replace('\\', '/').split('/')))
|
||||
root = os.path.realpath(plan['root'])
|
||||
if not (full + os.sep).startswith(root + os.sep) or not os.path.isfile(full):
|
||||
raise FrameError(f'{rel} is not a file in the title')
|
||||
c = classify(full)
|
||||
if not c or not c['exe']:
|
||||
raise FrameError(f"{rel} isn't a program the Frame can start")
|
||||
target = {'path': os.path.relpath(full, root).replace(os.sep, '/'), 'format': c['format'],
|
||||
'arch': c['arch'], 'size': os.path.getsize(full), 'depth': rel.count('/'),
|
||||
'skip': False, 'match': 0}
|
||||
alias, note = runtime_for(target, plan['candidates'])
|
||||
allowed = allowed_runtimes(target, plan['candidates'])
|
||||
if runtime:
|
||||
if runtime not in allowed:
|
||||
raise FrameError(f"{target['path']} can't use {runtime}; choose one of {', '.join(allowed)}")
|
||||
alias = runtime
|
||||
plan.update(target=target['path'], format=target['format'], arch=target['arch'], runtime=alias,
|
||||
runtime_label=RUNTIMES[alias]['label'], runtimes=allowed, note=note)
|
||||
plan['warnings'] = (['This looks like an installer or helper, not the game itself.'] if target['skip'] else [])
|
||||
return plan
|
||||
|
||||
|
||||
def discard(plan):
|
||||
if plan and plan.get('work'):
|
||||
shutil.rmtree(plan['work'], ignore_errors=True)
|
||||
|
||||
|
||||
def argv_for(rel):
|
||||
# Valve's client sends the start command as one string (it may carry arguments),
|
||||
# so a path with spaces is quoted. How Steam splits it is inferred.
|
||||
return ['"' + rel + '"' if re.search(r'\s', rel) else rel]
|
||||
|
||||
|
||||
def shortcut_parms(gameid, directory, rel, runtime):
|
||||
"""The JSON steam-client-create-shortcut takes, as devkit_client.new_or_ensure_game builds it."""
|
||||
settings = {'steam_play': '1' if RUNTIMES[runtime]['steam_play'] else '0'}
|
||||
if RUNTIMES[runtime]['steam_play']:
|
||||
# gui2._update_game sends these with every Proton title; debugging stays off.
|
||||
settings.update(steam_play_debug='0', steam_play_debug_version='2019')
|
||||
settings['compat_tool'] = runtime
|
||||
return {'gameid': gameid, 'directory': directory, 'argv': argv_for(rel), 'env': {},
|
||||
'settings': settings, 'clear_settings': True, 'force_appid': '', 'lepton_args': ''}
|
||||
|
||||
|
||||
# ---- the Frame side ----------------------------------------------------------
|
||||
|
||||
def ssh(cmd, input=None, timeout=120):
|
||||
return frame_android.ssh(cmd, input=input, timeout=timeout)
|
||||
|
||||
|
||||
def _json_out(out, what):
|
||||
"""The JSON object a devkit-utils script prints last (its logging goes to stderr)."""
|
||||
for line in reversed(out.strip().splitlines()):
|
||||
line = line.strip()
|
||||
if line.startswith('{') or line.startswith('['):
|
||||
try:
|
||||
return json.loads(line)
|
||||
except ValueError:
|
||||
break
|
||||
raise FrameError(f'{what} gave no usable answer: {out.strip()[-300:]!r}')
|
||||
|
||||
|
||||
def utils_stamp():
|
||||
"""Hash of the vendored devkit-utils, compared with the copy on the Frame."""
|
||||
h = hashlib.sha256()
|
||||
for dirpath, dirnames, filenames in os.walk(UTILS_LOCAL):
|
||||
dirnames[:] = sorted(d for d in dirnames if d != '__pycache__')
|
||||
for fn in sorted(filenames):
|
||||
if fn.endswith('.pyc'):
|
||||
continue
|
||||
full = os.path.join(dirpath, fn)
|
||||
h.update(os.path.relpath(full, UTILS_LOCAL).replace(os.sep, '/').encode() + b'\0')
|
||||
with open(full, 'rb') as f:
|
||||
h.update(f.read())
|
||||
return h.hexdigest()[:20]
|
||||
|
||||
|
||||
def _json_files(gid):
|
||||
# Exact names: a glob like Game-*.json would also match another title called Game-Deluxe.
|
||||
return ' '.join(f'{GAMES}/{gid}-{k}.json' for k in ('argv', 'env', 'settings', 'framecontrol'))
|
||||
|
||||
|
||||
_utils_lock = threading.Lock()
|
||||
|
||||
|
||||
def ensure_utils():
|
||||
"""Copy frame/devkit-utils to ~/devkit-utils on the Frame unless it's already this version."""
|
||||
with _utils_lock: # one sync at a time: they share a staging folder
|
||||
return _ensure_utils()
|
||||
|
||||
|
||||
def _ensure_utils():
|
||||
stamp = utils_stamp()
|
||||
have = ssh(f'cat {UTILS}/{STAMP} 2>/dev/null || true', timeout=30).strip()
|
||||
if have == stamp:
|
||||
return False
|
||||
# Merge rather than replace: Valve's own client may have put newer files there.
|
||||
tmp = f'.{UTILS}.frame-control'
|
||||
ssh(f'rm -rf {tmp}', timeout=30)
|
||||
_copy_tree(UTILS_LOCAL, tmp, timeout=300)
|
||||
ssh(f'mkdir -p {UTILS} && cp -R {tmp}/. {UTILS}/ && rm -rf {tmp} {UTILS}/__pycache__ '
|
||||
f'&& echo {stamp} > {UTILS}/{STAMP}', timeout=60)
|
||||
return True
|
||||
|
||||
|
||||
def _copy_tree(src, dest, timeout=3 * 3600, delete=False):
|
||||
"""Copy a local folder's contents to dest on the Frame (dest ends up a copy of src).
|
||||
|
||||
rsync where installed (not on Windows; see server.push_file), else scp -r
|
||||
into a fresh dest, which is what Windows has. dest must be a plain path.
|
||||
"""
|
||||
name = os.path.basename(src.rstrip('/\\'))
|
||||
opts = frame_android.SSH_OPTS # read now: the server swaps in its multiplexed options
|
||||
if _rsync():
|
||||
cmd = ['rsync', '-a', *(['--delete'] if delete else []), '-e', shlex.join(['ssh', *opts]),
|
||||
src.rstrip('/') + '/', f'{frame_android.FRAME}:{dest.rstrip("/")}/']
|
||||
else:
|
||||
# scp -r copies src *into* dest when dest exists, so dest must not.
|
||||
ssh(f'rm -rf {shlex.quote(dest)}', timeout=60)
|
||||
cmd = ['scp', *opts, '-r', src, f'{frame_android.FRAME}:{dest}']
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
errors='replace', 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:]}')
|
||||
|
||||
|
||||
def _rsync():
|
||||
# Not on Windows: a Windows rsync (cwRsync, MSYS2) wouldn't take the POSIX -e quoting.
|
||||
return not frame_host.WINDOWS and bool(shutil.which('rsync'))
|
||||
|
||||
|
||||
_install_lock = threading.Lock()
|
||||
|
||||
|
||||
def install(path, name=None, exe=None, runtime=None, progress=None):
|
||||
"""Sideload a .zip, folder or executable as a Devkit Game; returns the title dict.
|
||||
|
||||
name: the Steam name (sanitised to the title id), default from the file name.
|
||||
exe: launch target relative to the title's root, default the best candidate.
|
||||
runtime: a compat tool alias from RUNTIMES that suits the target (e.g.
|
||||
'proton-stable' instead of the default Proton Experimental).
|
||||
progress: optional callable(stage_text, fraction 0..1).
|
||||
"""
|
||||
plan = inspect(path, name)
|
||||
try:
|
||||
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress)
|
||||
finally:
|
||||
discard(plan)
|
||||
|
||||
|
||||
def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
|
||||
"""Install an inspect() plan, optionally with another name, target or runtime."""
|
||||
if name:
|
||||
plan['name'], plan['id'] = name, title_id(name)
|
||||
if exe or runtime:
|
||||
_choose(plan, exe or plan['target'], runtime)
|
||||
step = progress or (lambda *a: None)
|
||||
with _install_lock:
|
||||
return _install(plan, step)
|
||||
|
||||
|
||||
def _install(plan, step):
|
||||
gid = plan['id']
|
||||
if not ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
||||
raise FrameError(f'bad title id {gid!r}')
|
||||
step("Syncing Valve's devkit tools to the Frame", 0.02)
|
||||
ensure_utils()
|
||||
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
|
||||
step('Preparing the title folder', 0.05)
|
||||
ready = _json_out(ssh(f'{PY}steamos-prepare-upload --gameid {gid}', timeout=60), 'steamos-prepare-upload')
|
||||
directory = str(ready.get('directory') or '')
|
||||
if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'):
|
||||
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
|
||||
registered = False
|
||||
try:
|
||||
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
|
||||
if _rsync():
|
||||
_copy_tree(plan['root'], directory, delete=True)
|
||||
else:
|
||||
part = f"{directory.rsplit('/', 1)[0]}/.{gid}.upload"
|
||||
_copy_tree(plan['root'], part)
|
||||
ssh(f'rm -rf {directory} && mv {part} {directory}', timeout=120)
|
||||
# Same modes Valve's client gives an upload (rsync --chmod=Du=rwx,Dgo=rx,Fu=rwx,Fog=rx).
|
||||
ssh(f'chmod -R 755 {directory}', timeout=300)
|
||||
step('Registering with Steam', 0.9)
|
||||
parms = shortcut_parms(gid, directory, plan['target'], plan['runtime'])
|
||||
reply = _json_out(ssh(f'{PY}steam-client-create-shortcut --parms {shlex.quote(json.dumps(parms))}',
|
||||
timeout=90), 'steam-client-create-shortcut')
|
||||
meta = {'id': gid, 'name': plan['name'], 'target': plan['target'], 'runtime': plan['runtime'],
|
||||
'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S')}
|
||||
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||
registered = True # the files stay: Steam registers them once it's running
|
||||
if 'error' in reply:
|
||||
raise FrameError(f"Uploaded, but Steam didn't register it: {reply['error']}. "
|
||||
"With Steam running on the Frame, install it again.")
|
||||
step('Done', 1.0)
|
||||
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
|
||||
return meta
|
||||
finally:
|
||||
if not registered and not existed:
|
||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||
try:
|
||||
ssh(f'rm -rf {GAMES}/{gid} {GAMES}/.{gid}.upload {_json_files(gid)}', timeout=60)
|
||||
except FrameError:
|
||||
pass
|
||||
|
||||
|
||||
LIST_SCRIPT = r'''
|
||||
import json, os
|
||||
root = os.path.expanduser('~/devkit-game')
|
||||
reserved = %r
|
||||
out = []
|
||||
for d in sorted(os.listdir(root)) if os.path.isdir(root) else []:
|
||||
if d.startswith('.') or d.lower() in reserved or not os.path.isdir(os.path.join(root, d)):
|
||||
continue
|
||||
t = {'id': d}
|
||||
for key, suffix in (('settings', '-settings.json'), ('argv', '-argv.json'), ('meta', '-framecontrol.json')):
|
||||
try:
|
||||
with open(os.path.join(root, d + suffix)) as f:
|
||||
t[key] = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
t[key] = None
|
||||
out.append(t)
|
||||
print(json.dumps(out))
|
||||
''' % (RESERVED_IDS,)
|
||||
|
||||
|
||||
def list_titles():
|
||||
"""The Devkit Games on the Frame (any uploaded by Valve's client too)."""
|
||||
raw = _json_out(ssh('python3 -', input=LIST_SCRIPT, timeout=30), 'the title list')
|
||||
titles = []
|
||||
for t in raw if isinstance(raw, list) else []:
|
||||
if not ID_RE.match(str(t.get('id', ''))):
|
||||
continue
|
||||
settings, meta = t.get('settings') or {}, t.get('meta') or {}
|
||||
argv = t.get('argv') if isinstance(t.get('argv'), list) else []
|
||||
alias = str(settings.get('compat_tool') or '')
|
||||
titles.append({'id': t['id'], 'name': str(meta.get('name') or t['id']),
|
||||
'target': str(meta.get('target') or (argv[0] if argv else '')),
|
||||
'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'),
|
||||
'source': str(meta.get('source') or ''), 'size': meta.get('size'),
|
||||
'installed': meta.get('installed'), 'registered': t.get('settings') is not None,
|
||||
'frame_control': bool(meta)})
|
||||
return titles
|
||||
|
||||
|
||||
def _check_id(gid):
|
||||
gid = str(gid or '')
|
||||
if not ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
||||
raise FrameError(f'bad title id {gid!r}')
|
||||
if ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() != 'yes':
|
||||
raise FrameError(f'{gid} is not installed')
|
||||
return gid
|
||||
|
||||
|
||||
def launch(gid):
|
||||
gid = _check_id(gid)
|
||||
ensure_utils()
|
||||
# steam-devkit-rpc logs 'success' when Steam answers, but exits 0 after a
|
||||
# 5 s timeout too, so read its log (stderr) rather than trust the exit code.
|
||||
out = ssh(f'{PY}steam-devkit-rpc run-game gameid={gid} 2>&1', timeout=60)
|
||||
if 'success' not in [line.strip() for line in out.splitlines()]:
|
||||
raise FrameError(f"Steam didn't confirm the launch: {out.strip()[-300:] or 'no answer'}")
|
||||
return {'id': gid}
|
||||
|
||||
|
||||
def remove(gid):
|
||||
# Not while an install runs: it could be this title, half copied or about to register.
|
||||
if not _install_lock.acquire(blocking=False):
|
||||
raise FrameError('an install is running; remove the title when it has finished')
|
||||
try:
|
||||
gid = _check_id(gid)
|
||||
ensure_utils()
|
||||
# steamos-delete removes the folder and syncs Steam's shortcuts; its json files stay, so clear them too.
|
||||
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
|
||||
ssh(f'rm -f {_json_files(gid)}', timeout=30)
|
||||
return {'id': gid}
|
||||
finally:
|
||||
_install_lock.release()
|
||||
|
||||
|
||||
def public(plan):
|
||||
"""A plan without its local paths, for the UI, with the runtimes each candidate may use."""
|
||||
out = {k: v for k, v in plan.items() if k not in ('root', 'work', 'candidates')}
|
||||
out['candidates'] = []
|
||||
for c in plan['candidates']:
|
||||
c = dict(c)
|
||||
try:
|
||||
c['runtimes'] = allowed_runtimes(c, plan['candidates'])
|
||||
except FrameError as e:
|
||||
c['runtimes'], c['blocked'] = [], str(e)
|
||||
out['candidates'].append(c)
|
||||
out['runtime_labels'] = {k: v['label'] for k, v in RUNTIMES.items()}
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['help']
|
||||
|
||||
def opt(flag):
|
||||
return args[args.index(flag) + 1] if flag in args and args.index(flag) + 1 < len(args) else None
|
||||
|
||||
try:
|
||||
if cmd == 'inspect' and args:
|
||||
plan = inspect(args[0], opt('--name'))
|
||||
try:
|
||||
if opt('--exe') or opt('--runtime'):
|
||||
_choose(plan, opt('--exe') or plan['target'], opt('--runtime'))
|
||||
r = public(plan)
|
||||
finally:
|
||||
discard(plan)
|
||||
elif cmd == 'install' and args:
|
||||
r = install(args[0], name=opt('--name'), exe=opt('--exe'), runtime=opt('--runtime'),
|
||||
progress=lambda text, _: print(text + '…', file=sys.stderr))
|
||||
elif cmd == 'list':
|
||||
r = list_titles()
|
||||
elif cmd in ('launch', 'remove') and args:
|
||||
r = globals()[cmd](args[0])
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
except FrameError as e:
|
||||
sys.exit(f'error: {e}')
|
||||
print(json.dumps(r, indent=1))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,468 @@
|
||||
"""Install links from websites: frame-control://install?manifest=URL or ?url=URL.
|
||||
|
||||
The app hands the link to the page, the page shows what it will install and
|
||||
asks the user first, and only then does this module download the file and pass
|
||||
it to the installer for its type (dispatch()). See docs/web-install.md.
|
||||
|
||||
A manifest is the same JSON FrameDrop uses, so one works for both tools:
|
||||
{"schema": "framedrop.install/v1", "name": "My Game",
|
||||
"files": [{"url": "https://cdn.example.com/mygame-arm64.apk", "sha256": "..."}]}
|
||||
"frame-control.install/v1" is accepted with the same shape.
|
||||
|
||||
Rules: HTTPS only, except http(s)://localhost or 127.0.0.1 for testing, and then
|
||||
only with FRAME_CONTROL_LOCAL_LINKS=1 set and when the link itself points there.
|
||||
No credentials in URLs, no private, loopback, link-local or CGNAT addresses
|
||||
(checked on every redirect, and the connection goes to the address that was
|
||||
checked, so DNS can't change it in between). The file URL must end in a file name.
|
||||
|
||||
Python stdlib only, 3.9 compatible.
|
||||
"""
|
||||
import hashlib
|
||||
import http.client
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import errno
|
||||
import re
|
||||
import select
|
||||
import socket
|
||||
import ssl
|
||||
import tempfile
|
||||
import time
|
||||
from urllib.parse import unquote, urljoin, urlsplit
|
||||
|
||||
SCHEMAS = ("framedrop.install/v1", "frame-control.install/v1")
|
||||
MAX_FILE = 4 * 1024**3 # largest download accepted
|
||||
MAX_MANIFEST = 256 * 1024 # largest manifest accepted
|
||||
MAX_URL = 2048
|
||||
MAX_REDIRECTS = 5
|
||||
TIMEOUT = 30 # seconds per socket operation
|
||||
CHUNK = 1 << 20
|
||||
LOCAL_HOSTS = ("localhost", "127.0.0.1")
|
||||
# Off by default: otherwise any website could make the app fetch from local services.
|
||||
LOCAL_LINKS_ENV = "FRAME_CONTROL_LOCAL_LINKS"
|
||||
USER_AGENT = "FrameControl (+https://github.com/saphid/steam-frame)"
|
||||
# What dispatch() can install, by file extension.
|
||||
KINDS = {".apk": "apk", ".zip": "title", ".exe": "title"}
|
||||
KIND_LABEL = {"apk": "Android app (APK)", "title": "Linux/Windows title"}
|
||||
SHA256 = re.compile(r"[0-9a-fA-F]{64}")
|
||||
CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
||||
# connect_ex() results meaning "still connecting" (the last is Windows' WSAEWOULDBLOCK).
|
||||
_CONNECTING = {errno.EINPROGRESS, errno.EWOULDBLOCK, errno.EALREADY, getattr(errno, "WSAEWOULDBLOCK", 10035)}
|
||||
|
||||
# Swapped out by the tests, which have no network.
|
||||
_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
|
||||
class WebInstallError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Cancelled(WebInstallError):
|
||||
pass
|
||||
|
||||
|
||||
# ---- URLs -------------------------------------------------------------------
|
||||
|
||||
def is_public(ip):
|
||||
"""True for addresses on the public internet, and nothing a LAN or this computer uses."""
|
||||
ip = ipaddress.ip_address(ip)
|
||||
if ip.version == 6:
|
||||
if ip.ipv4_mapped:
|
||||
ip = ip.ipv4_mapped
|
||||
elif ip.is_site_local: # fec0::/10: deprecated, but is_global doesn't catch it
|
||||
return False
|
||||
elif ip.sixtofour and not is_public(ip.sixtofour):
|
||||
return False
|
||||
if ip.version == 4 and ip in CGNAT:
|
||||
return False
|
||||
return ip.is_global and not ip.is_multicast
|
||||
|
||||
|
||||
def check_url(url, allow_local=False):
|
||||
"""Validate a URL against the rules above; returns (scheme, host, port, is_local).
|
||||
|
||||
Resolving the name is left to connect time (see _resolve), so this needs no network.
|
||||
"""
|
||||
if not isinstance(url, str) or not url or len(url) > MAX_URL:
|
||||
raise WebInstallError("the link must be a URL of at most %d characters" % MAX_URL)
|
||||
if any(c.isspace() or ord(c) < 32 for c in url):
|
||||
raise WebInstallError("the URL has spaces or control characters in it")
|
||||
try:
|
||||
u = urlsplit(url)
|
||||
port = u.port
|
||||
except ValueError as e:
|
||||
raise WebInstallError(f"not a valid URL: {e}")
|
||||
scheme = u.scheme.lower()
|
||||
if scheme not in ("https", "http"):
|
||||
raise WebInstallError(f"only https:// links are allowed, not {scheme or 'a relative URL'}")
|
||||
if u.username is not None or u.password is not None or "@" in u.netloc:
|
||||
raise WebInstallError("URLs with a user name or password in them aren't allowed")
|
||||
host = (u.hostname or "").lower().rstrip(".")
|
||||
if not host:
|
||||
raise WebInstallError("the URL has no host")
|
||||
local = host in LOCAL_HOSTS
|
||||
if local and not allow_local:
|
||||
raise WebInstallError(f"localhost links are for testing: set {LOCAL_LINKS_ENV}=1, and the link itself must point there")
|
||||
if scheme == "http" and not local:
|
||||
raise WebInstallError("only https:// is allowed (http:// only for localhost while testing)")
|
||||
if not local:
|
||||
try:
|
||||
literal = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
literal = None
|
||||
if literal is not None and not is_public(literal):
|
||||
raise WebInstallError(f"{host} is a private or local address")
|
||||
return scheme, host, port or (443 if scheme == "https" else 80), local
|
||||
|
||||
|
||||
def file_name(url):
|
||||
"""The file name the URL ends in, e.g. mygame-arm64.apk."""
|
||||
path = urlsplit(url).path
|
||||
name = unquote(path.rsplit("/", 1)[-1])
|
||||
if not name or name in (".", "..") or "/" in name or "\\" in name or name.startswith(".") \
|
||||
or any(ord(c) < 32 for c in name) or len(name) > 200:
|
||||
raise WebInstallError("the file URL must end in a file name, e.g. https://example.com/mygame.apk")
|
||||
return name
|
||||
|
||||
|
||||
def file_kind(name):
|
||||
ext = os.path.splitext(name.lower())[1]
|
||||
kind = KINDS.get(ext)
|
||||
if not kind:
|
||||
raise WebInstallError(f"{name}: Frame Control installs .apk, .zip and .exe files, not {ext or 'this type'}")
|
||||
return kind
|
||||
|
||||
|
||||
def _resolve(host, port, local):
|
||||
"""One address to connect to; every address the name has must be public."""
|
||||
if local:
|
||||
return "127.0.0.1"
|
||||
try:
|
||||
infos = _getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
except (OSError, UnicodeError) as e:
|
||||
raise WebInstallError(f"couldn't look up {host}: {e}")
|
||||
ips = [info[4][0].split("%", 1)[0] for info in infos]
|
||||
if not ips:
|
||||
raise WebInstallError(f"couldn't look up {host}")
|
||||
for ip in ips:
|
||||
if not is_public(ip):
|
||||
raise WebInstallError(f"{host} points to a private or local address ({ip})")
|
||||
return ips[0]
|
||||
|
||||
|
||||
# ---- HTTP -------------------------------------------------------------------
|
||||
|
||||
class _Abortable:
|
||||
"""Connects to an address checked beforehand, whatever DNS says by then.
|
||||
|
||||
raw_sock is the socket to shut down to stop the connection from another
|
||||
thread (abort()): http.client drops conn.sock once a response will close
|
||||
the connection, yet keeps reading the body from it.
|
||||
"""
|
||||
raw_sock = None
|
||||
aborted = False
|
||||
|
||||
def _tcp(self):
|
||||
"""Connect without blocking, so abort() can stop a connect that hangs."""
|
||||
sock = socket.socket(socket.AF_INET6 if ":" in self._ip else socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.setblocking(False)
|
||||
err = sock.connect_ex((self._ip, self.port))
|
||||
deadline = time.monotonic() + self.timeout
|
||||
while err in _CONNECTING:
|
||||
if self.aborted:
|
||||
raise OSError("aborted")
|
||||
if time.monotonic() > deadline:
|
||||
raise socket.timeout(f"timed out connecting to {self.host}")
|
||||
_, writable, failed = select.select([], [sock], [sock], 0.2)
|
||||
if writable or failed:
|
||||
err = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
|
||||
if err:
|
||||
raise OSError(err, os.strerror(err))
|
||||
sock.settimeout(self.timeout)
|
||||
self.raw_sock = sock
|
||||
if self.aborted: # abort() ran just now and found nothing to shut down
|
||||
raise OSError("aborted")
|
||||
except BaseException:
|
||||
sock.close()
|
||||
raise
|
||||
return sock
|
||||
|
||||
|
||||
class _HTTPConnection(_Abortable, http.client.HTTPConnection):
|
||||
def __init__(self, host, ip, port, timeout):
|
||||
super().__init__(host, port, timeout=timeout)
|
||||
self._ip = ip
|
||||
|
||||
def connect(self):
|
||||
self.sock = self._tcp()
|
||||
|
||||
|
||||
class _HTTPSConnection(_Abortable, http.client.HTTPSConnection):
|
||||
"""As above, still verifying the certificate for the host name."""
|
||||
|
||||
def __init__(self, host, ip, port, timeout):
|
||||
# urllib's default context, so the app's bundled CA list (frame_host.trust_bundled_cas) applies too.
|
||||
super().__init__(host, port, timeout=timeout, context=ssl._create_default_https_context())
|
||||
self._ip = ip
|
||||
|
||||
def connect(self):
|
||||
# Wrapping detaches the plain socket, so publish the TLS one before the handshake.
|
||||
sock = self._context.wrap_socket(self._tcp(), server_hostname=self.host, do_handshake_on_connect=False)
|
||||
self.raw_sock = sock
|
||||
try:
|
||||
if self.aborted:
|
||||
raise OSError("aborted")
|
||||
sock.do_handshake()
|
||||
except (AttributeError, ValueError) as e:
|
||||
# abort()'s shutdown() can tear down the TLS state mid-way.
|
||||
sock.close()
|
||||
if self.aborted:
|
||||
raise OSError("aborted")
|
||||
raise OSError(str(e))
|
||||
except BaseException:
|
||||
sock.close()
|
||||
raise
|
||||
self.sock = sock
|
||||
|
||||
|
||||
def _open(url, allow_local, method="GET", connected=None):
|
||||
"""(connection, response) for url after redirects, each hop checked. Caller closes the connection.
|
||||
|
||||
connected(conn) gets each connection before it's used, for abort().
|
||||
"""
|
||||
for _ in range(MAX_REDIRECTS + 1):
|
||||
scheme, host, port, local = check_url(url, allow_local)
|
||||
ip = _resolve(host, port, local)
|
||||
cls = _HTTPSConnection if scheme == "https" else _HTTPConnection
|
||||
conn = cls(host, ip, port, TIMEOUT)
|
||||
if connected:
|
||||
connected(conn)
|
||||
u = urlsplit(url)
|
||||
target = (u.path or "/") + ("?" + u.query if u.query else "")
|
||||
try:
|
||||
conn.request(method, target, headers={"User-Agent": USER_AGENT, "Accept-Encoding": "identity"})
|
||||
r = conn.getresponse()
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
conn.close()
|
||||
raise WebInstallError(f"couldn't reach {host}: {e}")
|
||||
if r.status in (301, 302, 303, 307, 308) and r.getheader("Location"):
|
||||
url = urljoin(url, r.getheader("Location").strip())
|
||||
conn.close()
|
||||
continue
|
||||
if r.status != 200:
|
||||
conn.close()
|
||||
raise WebInstallError(f"{host} answered HTTP {r.status} {r.reason}".strip())
|
||||
return conn, r
|
||||
raise WebInstallError(f"more than {MAX_REDIRECTS} redirects")
|
||||
|
||||
|
||||
def _length(r):
|
||||
try:
|
||||
n = int(r.getheader("Content-Length") or "")
|
||||
except ValueError:
|
||||
return None
|
||||
return n if n >= 0 else None
|
||||
|
||||
|
||||
# ---- manifests --------------------------------------------------------------
|
||||
|
||||
def parse_manifest(obj):
|
||||
"""{"name": ..., "file": {"url", "sha256", "size", "exe"}} from a manifest object."""
|
||||
if not isinstance(obj, dict):
|
||||
raise WebInstallError("the manifest must be a JSON object")
|
||||
schema = obj.get("schema")
|
||||
if schema not in SCHEMAS:
|
||||
raise WebInstallError(f"unsupported manifest schema {schema!r} (expected {' or '.join(SCHEMAS)})")
|
||||
files = obj.get("files")
|
||||
if not isinstance(files, list) or not files:
|
||||
raise WebInstallError("the manifest has no files")
|
||||
if len(files) > 1:
|
||||
raise WebInstallError(f"the manifest lists {len(files)} files; Frame Control installs one file per link for now")
|
||||
entry = files[0]
|
||||
if not isinstance(entry, dict) or not isinstance(entry.get("url"), str) or not entry["url"]:
|
||||
raise WebInstallError("the manifest's file has no url")
|
||||
sha = entry.get("sha256")
|
||||
if sha is not None and (not isinstance(sha, str) or not SHA256.fullmatch(sha)):
|
||||
raise WebInstallError("sha256 must be 64 hex digits")
|
||||
size = entry.get("size")
|
||||
if size is not None and (type(size) is not int or size <= 0):
|
||||
raise WebInstallError("size must be a positive integer")
|
||||
exe = entry.get("exe")
|
||||
if exe is not None and (not isinstance(exe, str) or not exe or len(exe) > 300):
|
||||
raise WebInstallError("exe must be a path inside the archive")
|
||||
name = obj.get("name")
|
||||
if name is not None and not isinstance(name, str):
|
||||
raise WebInstallError("name must be a string")
|
||||
return {"name": clean_name(name), "file": {"url": entry["url"], "sha256": sha.lower() if sha else None,
|
||||
"size": size, "exe": exe}}
|
||||
|
||||
|
||||
def clean_name(name):
|
||||
name = re.sub(r"[\x00-\x1f\x7f]", "", name or "").strip()
|
||||
return name[:120] or None
|
||||
|
||||
|
||||
def fetch_manifest(url, allow_local):
|
||||
conn, r = _open(url, allow_local)
|
||||
try:
|
||||
n = _length(r)
|
||||
if n is not None and n > MAX_MANIFEST:
|
||||
raise WebInstallError(f"the manifest is over {MAX_MANIFEST // 1024} KB")
|
||||
data = r.read(MAX_MANIFEST + 1)
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
raise WebInstallError(f"couldn't read the manifest: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
if len(data) > MAX_MANIFEST:
|
||||
raise WebInstallError(f"the manifest is over {MAX_MANIFEST // 1024} KB")
|
||||
try:
|
||||
obj = json.loads(data.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise WebInstallError("the manifest isn't valid JSON")
|
||||
return parse_manifest(obj)
|
||||
|
||||
|
||||
def _head_size(url, allow_local):
|
||||
"""Content-Length from a HEAD request, or None; only for showing the size up front."""
|
||||
try:
|
||||
conn, r = _open(url, allow_local, method="HEAD")
|
||||
except WebInstallError:
|
||||
return None
|
||||
try:
|
||||
return _length(r)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def plan(manifest=None, url=None):
|
||||
"""Everything the confirm dialog shows, fetched and checked; nothing is downloaded yet.
|
||||
|
||||
Exactly one of manifest (a manifest URL) or url (a direct file URL).
|
||||
"""
|
||||
if (manifest is None) == (url is None):
|
||||
raise WebInstallError("give either manifest or url")
|
||||
link = manifest if manifest is not None else url
|
||||
# localhost is for testing a link on your own computer: only with the developer
|
||||
# switch on, and only for a link that starts there (a public manifest can't
|
||||
# point at localhost).
|
||||
allow_local = check_url(link, allow_local=os.environ.get(LOCAL_LINKS_ENV) == "1")[3]
|
||||
if manifest is not None:
|
||||
m = fetch_manifest(manifest, allow_local)
|
||||
name, f = m["name"], m["file"]
|
||||
else:
|
||||
name, f = None, {"url": url, "sha256": None, "size": None, "exe": None}
|
||||
_, host, _, _ = check_url(f["url"], allow_local)
|
||||
fname = file_name(f["url"])
|
||||
kind = file_kind(fname)
|
||||
size = f["size"] or _head_size(f["url"], allow_local)
|
||||
if size is not None and size > MAX_FILE:
|
||||
raise WebInstallError(f"{fname} is {size / 1024**3:.1f} GB; the limit is {MAX_FILE / 1024**3:.0f} GB")
|
||||
return {"name": name or fname, "url": f["url"], "file": fname, "kind": kind, "kindLabel": KIND_LABEL[kind],
|
||||
"host": host, "linkHost": urlsplit(link).hostname, "size": size, "sha256": f["sha256"],
|
||||
"exe": f["exe"], "source": link, "allowLocal": allow_local, "sizeFromManifest": bool(f["size"])}
|
||||
|
||||
|
||||
def abort(conn):
|
||||
"""Stop conn from another thread (cancel, shutdown): unblocks a read, or makes the connect fail."""
|
||||
conn.aborted = True
|
||||
sock = conn.raw_sock
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def download(p, dest_dir, progress=None, cancelled=None, connected=None):
|
||||
"""Download plan p's file into dest_dir; returns its path. Checks the size cap and sha256.
|
||||
|
||||
progress(done, total_or_None) is called as bytes arrive; cancelled() may return True to stop;
|
||||
connected(conn) gets each connection before it's used, for abort().
|
||||
"""
|
||||
dest = os.path.join(dest_dir, p["file"])
|
||||
try:
|
||||
conn, r = _open(p["url"], p["allowLocal"], connected=connected)
|
||||
except WebInstallError:
|
||||
if cancelled and cancelled():
|
||||
raise Cancelled("download cancelled")
|
||||
raise
|
||||
fd, part = tempfile.mkstemp(prefix=".part-", dir=dest_dir)
|
||||
out = os.fdopen(fd, "wb")
|
||||
ok = False
|
||||
try:
|
||||
total = _length(r)
|
||||
expected = p["size"] if p.get("sizeFromManifest") else None
|
||||
if total is not None and total > MAX_FILE:
|
||||
raise WebInstallError(f"the file is over the {MAX_FILE / 1024**3:.0f} GB limit")
|
||||
if expected is not None and total is not None and total != expected:
|
||||
raise WebInstallError(f"the server says {total} bytes; the manifest says {expected}")
|
||||
digest = hashlib.sha256()
|
||||
done = 0
|
||||
while True:
|
||||
if cancelled and cancelled():
|
||||
raise Cancelled("download cancelled")
|
||||
try:
|
||||
chunk = r.read(CHUNK)
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
if cancelled and cancelled():
|
||||
raise Cancelled("download cancelled")
|
||||
raise WebInstallError(f"download failed: {e}")
|
||||
if not chunk:
|
||||
if cancelled and cancelled(): # abort() makes the read end early
|
||||
raise Cancelled("download cancelled")
|
||||
break
|
||||
done += len(chunk)
|
||||
if done > MAX_FILE:
|
||||
raise WebInstallError(f"the file is over the {MAX_FILE / 1024**3:.0f} GB limit")
|
||||
digest.update(chunk)
|
||||
out.write(chunk)
|
||||
if progress:
|
||||
progress(done, total or expected)
|
||||
out.close()
|
||||
if total is not None and done != total:
|
||||
raise WebInstallError(f"download cut off at {done} of {total} bytes")
|
||||
if expected is not None and done != expected:
|
||||
raise WebInstallError(f"downloaded {done} bytes; the manifest says {expected}")
|
||||
if p["sha256"] and digest.hexdigest() != p["sha256"]:
|
||||
raise WebInstallError(f"{p['file']} doesn't match the manifest's sha256; not installing it")
|
||||
os.replace(part, dest)
|
||||
ok = True
|
||||
return dest
|
||||
finally:
|
||||
out.close()
|
||||
conn.close()
|
||||
if not ok:
|
||||
try:
|
||||
os.remove(part)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---- installing -------------------------------------------------------------
|
||||
|
||||
def dispatch(path, name=None, exe=None, progress=None, source=None):
|
||||
"""Install a downloaded file with the installer for its type; returns {"message", "kind", "result"}.
|
||||
|
||||
.apk goes to frame_android (its own Lepton instance and Steam shortcut, named by
|
||||
the APK's label); .zip and .exe to frame_titles. The caller has the SSH
|
||||
connection ready.
|
||||
"""
|
||||
kind = file_kind(os.path.basename(path))
|
||||
if kind == "apk":
|
||||
import frame_android
|
||||
try:
|
||||
m = frame_android.install(path, source=source or os.path.basename(path))
|
||||
except frame_android.FrameError as e:
|
||||
raise WebInstallError(str(e))
|
||||
return {"message": f"Installed {m['label']} as its own app in the Steam library", "kind": kind, "result": m}
|
||||
try:
|
||||
import frame_titles
|
||||
except ImportError as e:
|
||||
if e.name != "frame_titles":
|
||||
raise
|
||||
raise WebInstallError("Linux/Windows titles need a newer Frame Control")
|
||||
result = frame_titles.install(path, name=name, exe=exe, progress=progress)
|
||||
msg = result.get("message") if isinstance(result, dict) else None
|
||||
return {"message": msg or f"Installed {name or os.path.basename(path)}", "kind": kind, "result": result}
|
||||
+627
-125
File diff suppressed because it is too large.
Load diff
+604
-101
@@ -1,18 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame Control: a small local web UI for managing the Steam Frame from the Mac.
|
||||
"""Frame Control: a small local web UI for managing the Steam Frame from a computer.
|
||||
|
||||
Stdlib only. Listens on 127.0.0.1 and talks to the headset through the `frame`
|
||||
SSH alias set up by scripts/connect.sh, reusing the scripts in ../scripts.
|
||||
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
|
||||
Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
|
||||
up by scripts/connect.sh or ui/frame_connect.py.
|
||||
|
||||
Usage: ui/server.py [--port 47810] (normally started by scripts/frame-ui.sh)
|
||||
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
|
||||
Env: FRAME_ALIAS (default frame)
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import secrets
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
@@ -26,19 +29,29 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
import frame_android
|
||||
import frame_catalog
|
||||
import frame_store
|
||||
# The app runs Python with -I, which leaves the script's own folder off
|
||||
# sys.path, so add it for the sibling modules below.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import frame_android # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
import frame_titles # noqa: E402
|
||||
import frame_webinstall # noqa: E402
|
||||
|
||||
frame_host.trust_bundled_cas()
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
SCRIPTS = HERE.parent / "scripts"
|
||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||
# Reuse one SSH connection for the frequent status/screenshot calls. /tmp, not
|
||||
# $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
||||
CONTROL = f"/tmp/frame-ui-{os.getuid()}-%C"
|
||||
MUX = ["ssh", "-o", "BatchMode=yes", "-o", f"ControlPath={CONTROL}"]
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
|
||||
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
|
||||
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||
# supports it (not on Windows: there every command connects on its own).
|
||||
CONTROL = frame_host.control_path()
|
||||
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
|
||||
# Commands use the master when it's up and connect directly when it isn't.
|
||||
SSH = [*MUX, "-o", "ControlMaster=no", "-o", "ConnectTimeout=5"]
|
||||
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
|
||||
|
||||
# Android helpers share the multiplexed connection when it's up.
|
||||
frame_android.SSH_OPTS = SSH[1:]
|
||||
@@ -72,6 +85,89 @@ class Failure(Exception):
|
||||
self.status = status
|
||||
|
||||
|
||||
# What ssh prints when it never reached the Frame, and what to tell the user
|
||||
# instead. Only ssh's own wording is matched, so a command that ran on the Frame
|
||||
# and failed keeps its real error.
|
||||
UNREACHABLE = [
|
||||
(re.compile(r"Could not resolve hostname"),
|
||||
"Can't find the Frame on the network. Check it's on and connected, or run Set Up Connection."),
|
||||
(re.compile(r"port \d+: (Operation timed out|Connection timed out|Host is down|No route to host|"
|
||||
r"Network is unreachable)"),
|
||||
"The Frame isn't answering. It may be asleep, switched off, or on another network."),
|
||||
(re.compile(r"[Tt]imed out talking to "),
|
||||
"The Frame took too long to answer. It may be asleep or busy; try again."),
|
||||
(re.compile(r"port \d+: Connection refused"),
|
||||
"The Frame refused the connection. Check Developer Mode is still on."),
|
||||
(re.compile(r"Permission denied \(publickey"),
|
||||
"The Frame didn't accept this computer's SSH key. Run Set Up Connection again."),
|
||||
(re.compile(r"Host key verification failed"),
|
||||
"The Frame's SSH identity changed (after a reinstall, or a different device). Run Set Up Connection again."),
|
||||
(re.compile(r"kex_exchange_identification|Connection closed by .* port \d+|Connection reset by .* port \d+"),
|
||||
"The connection to the Frame dropped. Try again."),
|
||||
]
|
||||
|
||||
|
||||
def unreachable(message):
|
||||
"""The plain-language reason the Frame couldn't be reached, or None if it was."""
|
||||
for pattern, friendly in UNREACHABLE:
|
||||
if pattern.search(message):
|
||||
return friendly
|
||||
return None
|
||||
|
||||
|
||||
def error_body(message):
|
||||
"""A JSON error body and status; SSH connection failures become one clear offline message."""
|
||||
friendly = unreachable(message)
|
||||
if friendly:
|
||||
return {"error": friendly, "offline": True, "detail": message}, 503
|
||||
return {"error": message}, None
|
||||
|
||||
|
||||
# ---- Background jobs: installs that can outlast a request ------------------
|
||||
#
|
||||
# Flatpak and Android installs can take many minutes. The request starts the
|
||||
# work and returns a job id at once; the page polls /api/job for the outcome.
|
||||
|
||||
JOB_TTL = 3600
|
||||
_jobs_lock = threading.Lock()
|
||||
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"}
|
||||
|
||||
|
||||
def start_job(label, work):
|
||||
"""Run work() in the background. It returns a dict with a "message"."""
|
||||
now = time.time()
|
||||
with _jobs_lock:
|
||||
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
|
||||
del _jobs[j]
|
||||
job = secrets.token_hex(8)
|
||||
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now}
|
||||
|
||||
def run():
|
||||
fields = {}
|
||||
try:
|
||||
result = work()
|
||||
fields = {"message": result.get("message") or f"{label}: done", "result": result}
|
||||
except (Failure, frame_android.FrameError) as e:
|
||||
fields = {"error": unreachable(str(e)) or str(e)}
|
||||
except Exception as e:
|
||||
fields = {"error": f"{type(e).__name__}: {e}"}
|
||||
finally:
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(fields, done=True, time=time.time())
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
return {"message": f"{label}…", "job": job}
|
||||
|
||||
|
||||
def job_status(query):
|
||||
with _jobs_lock:
|
||||
job = _jobs.get((parse_qs(query).get("id") or [""])[0])
|
||||
snapshot = job and {k: v for k, v in job.items() if k != "time"}
|
||||
if not snapshot:
|
||||
raise Failure("no such job (the app may have restarted)", 404)
|
||||
return snapshot
|
||||
|
||||
|
||||
_master_lock = threading.Lock()
|
||||
_master = None
|
||||
|
||||
@@ -82,9 +178,12 @@ def ensure_master():
|
||||
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
||||
"""
|
||||
global _master
|
||||
if not CONTROL:
|
||||
return
|
||||
|
||||
def up():
|
||||
try:
|
||||
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True,
|
||||
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL,
|
||||
timeout=5).returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
@@ -97,7 +196,7 @@ def ensure_master():
|
||||
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
||||
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
stderr=subprocess.DEVNULL, **frame_host.DETACHED)
|
||||
for _ in range(60):
|
||||
if up() or _master.poll() is not None:
|
||||
return
|
||||
@@ -107,7 +206,10 @@ def ensure_master():
|
||||
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||
try:
|
||||
ensure_master()
|
||||
r = subprocess.run([*SSH, FRAME, remote], input=stdin, capture_output=True,
|
||||
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
||||
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
|
||||
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
||||
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||
text=text, errors="replace" if text else None, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"Timed out talking to {FRAME}")
|
||||
@@ -119,40 +221,16 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||
return r.stdout
|
||||
|
||||
|
||||
def script(name, *args, stdin=None, timeout=900):
|
||||
"""Run one of ../scripts and return its combined output."""
|
||||
try:
|
||||
r = subprocess.run([str(SCRIPTS / name), *args], input=stdin, text=True, timeout=timeout,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
env={**os.environ, "FRAME_ALIAS": FRAME})
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"{name} timed out")
|
||||
out = strip_ansi(r.stdout).strip()
|
||||
if r.returncode != 0:
|
||||
raise Failure(out or f"{name} exited {r.returncode}")
|
||||
return out
|
||||
|
||||
|
||||
def strip_ansi(s):
|
||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
||||
|
||||
|
||||
def terminal(command):
|
||||
"""Open Terminal.app running `command` (for anything needing a password)."""
|
||||
as_str = command.replace("\\", "\\\\").replace('"', '\\"')
|
||||
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
|
||||
"-e", f'do script "{as_str}"', "-e", "activate", "-e", "end tell"],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
# Usually macOS Automation consent for Terminal was denied.
|
||||
raise Failure(f"Couldn't open Terminal: {r.stderr.strip()}", 500)
|
||||
|
||||
|
||||
def open_app(name, fallback_url):
|
||||
if subprocess.run(["open", "-a", name], capture_output=True).returncode == 0:
|
||||
return f"Opened {name}"
|
||||
subprocess.run(["open", fallback_url])
|
||||
return f"{name} isn't installed; opened its download page"
|
||||
def terminal(argv):
|
||||
"""Open a terminal window running argv (for anything needing a password)."""
|
||||
try:
|
||||
return frame_host.open_terminal(argv)
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
|
||||
|
||||
# ---- actions ---------------------------------------------------------------
|
||||
@@ -259,7 +337,7 @@ def save_shots(body):
|
||||
try:
|
||||
try:
|
||||
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
||||
capture_output=True, text=True, timeout=300)
|
||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure("Copying screenshots timed out")
|
||||
if r.returncode != 0:
|
||||
@@ -368,13 +446,44 @@ def set_volume(body):
|
||||
return {"message": "Volume updated"}
|
||||
|
||||
|
||||
# Runs on the Frame, clipboard text 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
|
||||
# borrow plasmashell's bus address. Same as scripts/paste-to-frame.sh.
|
||||
PASTE = r"""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
|
||||
"""
|
||||
# base64 keeps the script intact through every local shell's quoting rules.
|
||||
PASTE_CMD = 'bash -c "$(echo %s | base64 -d)"' % base64.b64encode(PASTE.encode()).decode()
|
||||
|
||||
|
||||
def clipboard(body):
|
||||
if body.get("fromMac"):
|
||||
return {"message": script("paste-to-frame.sh", timeout=30)}
|
||||
text = body.get("text")
|
||||
if not isinstance(text, str) or not text:
|
||||
raise Failure("nothing to send", 400)
|
||||
return {"message": script("paste-to-frame.sh", "-", stdin=text, timeout=30)}
|
||||
if body.get("fromMac") or body.get("fromComputer"):
|
||||
try:
|
||||
text = frame_host.clipboard_text()
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
if not text:
|
||||
raise Failure("The clipboard is empty (or holds something other than text)", 400)
|
||||
else:
|
||||
text = body.get("text")
|
||||
if not isinstance(text, str) or not text:
|
||||
raise Failure("nothing to send", 400)
|
||||
return {"message": ssh(PASTE_CMD, stdin=text, timeout=30).strip()}
|
||||
|
||||
|
||||
def flatpak(body):
|
||||
@@ -382,7 +491,13 @@ def flatpak(body):
|
||||
if not FLATPAK_ID.match(app):
|
||||
raise Failure("bad Flatpak app ID", 400)
|
||||
if action == "install":
|
||||
return {"message": script("install-apps.sh", app)}
|
||||
def work():
|
||||
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
|
||||
ssh("flatpak remote-add --user --if-not-exists flathub "
|
||||
"https://dl.flathub.org/repo/flathub.flatpakrepo && "
|
||||
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=1800)
|
||||
return {"message": f"Installed {app}"}
|
||||
return start_job(f"Install {app}", work)
|
||||
if action == "uninstall":
|
||||
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
|
||||
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
|
||||
@@ -391,25 +506,25 @@ def flatpak(body):
|
||||
|
||||
def open_thing(body):
|
||||
what = body.get("what")
|
||||
alias = shlex.quote(FRAME)
|
||||
if what == "terminal":
|
||||
terminal(f"ssh {alias}")
|
||||
return {"message": "Opened an SSH session in Terminal"}
|
||||
if what in ("reboot", "poweroff", "suspend"):
|
||||
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
||||
terminal(f"ssh -t {alias} sudo systemctl {what}")
|
||||
return {"message": f"Confirm with the Developer Mode password in Terminal to {what}"}
|
||||
if what == "steamlink":
|
||||
return {"message": open_app("Steam Link", "https://store.steampowered.com/remoteplay")}
|
||||
if what == "rdp":
|
||||
return {"message": open_app("Windows App", "https://apps.apple.com/app/windows-app/id1295203466")}
|
||||
if what == "sftp":
|
||||
terminal(f"sftp {alias}")
|
||||
return {"message": "Opened an SFTP session in Terminal"}
|
||||
if what == "shots":
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(["open", str(SHOTS_DIR)])
|
||||
return {"message": "Opened ~/Pictures/SteamFrame in Finder"}
|
||||
try:
|
||||
if what == "terminal":
|
||||
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
|
||||
if what in ("reboot", "poweroff", "suspend"):
|
||||
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
||||
where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
|
||||
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
|
||||
if what == "steamlink":
|
||||
return {"message": frame_host.open_steam_link()}
|
||||
if what == "rdp":
|
||||
return {"message": frame_host.open_rdp(FRAME)}
|
||||
if what == "sftp":
|
||||
return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
|
||||
if what == "shots":
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
frame_host.open_path(SHOTS_DIR)
|
||||
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
raise Failure("unknown target", 400)
|
||||
|
||||
|
||||
@@ -419,8 +534,13 @@ def android(body):
|
||||
ensure_master()
|
||||
try:
|
||||
if action == "install":
|
||||
m = frame_catalog.install(pkg)
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.", "app": m}
|
||||
frame_catalog.app(pkg) # an unknown package fails now, not in the background
|
||||
|
||||
def work():
|
||||
m = frame_catalog.install(pkg)
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
||||
"app": m}
|
||||
return start_job(f"Install {pkg}", work)
|
||||
if action in ("launch", "stop"):
|
||||
m = getattr(frame_android, action)(pkg)
|
||||
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
||||
@@ -438,13 +558,127 @@ def android(body):
|
||||
runtime=body.get("runtime") or "instance",
|
||||
label=body.get("label"), source=body.get("source"))
|
||||
name = r.get("label") or pkg
|
||||
where = "" if frame_catalog.compat_db.shared() else " on this Mac"
|
||||
where = "" if frame_catalog.compat_db.shared() else " on this computer"
|
||||
return {"message": f"Saved your report for {name}{where}", "report": r}
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e))
|
||||
raise Failure("unknown action", 400)
|
||||
|
||||
|
||||
# ---- Sideloaded titles (Linux/Windows builds as Steam Devkit Games) --------
|
||||
#
|
||||
# Installing is two steps: inspect (a dropped file is uploaded and a zip
|
||||
# unpacked here, once) returns a token and the detected target and runtime for
|
||||
# the page to confirm; install then runs in the background with progress the
|
||||
# page polls. Unconfirmed uploads are dropped after STAGE_TTL.
|
||||
|
||||
STAGE_TTL = 3600
|
||||
_titles_lock = threading.Lock()
|
||||
_staged = {} # token -> {"plan", "dir" (an upload's temp dir or None), "time"}
|
||||
_title_jobs = {} # token -> {"stage", "fraction", "done", "error", "title", "time"}
|
||||
|
||||
|
||||
def _drop_staged(entry):
|
||||
frame_titles.discard(entry["plan"])
|
||||
if entry.get("dir"):
|
||||
shutil.rmtree(entry["dir"], ignore_errors=True)
|
||||
|
||||
|
||||
def _purge_titles(now=None):
|
||||
now = now or time.time()
|
||||
with _titles_lock:
|
||||
stale = [_staged.pop(t) for t in [t for t, e in _staged.items() if now - e["time"] > STAGE_TTL]]
|
||||
for t in [t for t, j in _title_jobs.items() if j["done"] and now - j["time"] > STAGE_TTL]:
|
||||
del _title_jobs[t]
|
||||
for e in stale:
|
||||
_drop_staged(e)
|
||||
|
||||
|
||||
def stage_title(path, temp_dir=None, name=None):
|
||||
"""Inspect a .zip, folder or program and keep it for install; returns the plan and a token."""
|
||||
_purge_titles()
|
||||
try:
|
||||
plan = frame_titles.inspect(path, name)
|
||||
except BaseException as e:
|
||||
# Whatever went wrong, nothing will ever claim this upload.
|
||||
if temp_dir:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
if isinstance(e, frame_android.FrameError):
|
||||
raise Failure(str(e), 400)
|
||||
raise
|
||||
token = secrets.token_hex(12)
|
||||
with _titles_lock:
|
||||
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time()}
|
||||
return {"message": f"Read {plan['source']}: {plan['target']} with {plan['runtime_label']}",
|
||||
"token": token, "plan": frame_titles.public(plan)}
|
||||
|
||||
|
||||
def _run_title_install(token, entry, name, exe, runtime):
|
||||
def update(**fields): # the page reads jobs from other threads; change them under the lock
|
||||
with _titles_lock:
|
||||
_title_jobs[token].update(fields)
|
||||
|
||||
try:
|
||||
m = frame_titles.install_plan(entry["plan"], name=name, exe=exe, runtime=runtime,
|
||||
progress=lambda stage, fraction: update(stage=stage, fraction=fraction))
|
||||
update(title=m, message=f"Installed {m['id']} in the Steam library ({m['runtime_label']})")
|
||||
except frame_android.FrameError as e:
|
||||
update(error=str(e))
|
||||
except Exception as e:
|
||||
update(error=f"{type(e).__name__}: {e}")
|
||||
finally:
|
||||
_drop_staged(entry)
|
||||
update(done=True, time=time.time())
|
||||
|
||||
|
||||
def titles(body):
|
||||
"""Sideloaded titles (frame_titles.py): inspect a local path, install, discard, launch, remove."""
|
||||
action = body.get("action")
|
||||
if action == "inspect":
|
||||
# The app's page passes a dropped folder's path (Electron knows it); browsers upload instead.
|
||||
path = str(body.get("path") or "")
|
||||
if not os.path.isabs(path) or not os.path.exists(path):
|
||||
raise Failure("inspect needs the absolute path of a .zip, folder or program", 400)
|
||||
return stage_title(path, name=body.get("name") or None)
|
||||
if action in ("install", "discard"):
|
||||
token = str(body.get("token") or "")
|
||||
with _titles_lock:
|
||||
entry = _staged.pop(token, None)
|
||||
if not entry:
|
||||
raise Failure("that upload has expired; drop the file again", 400)
|
||||
if action == "discard":
|
||||
_drop_staged(entry)
|
||||
return {"message": "Discarded"}
|
||||
with _titles_lock:
|
||||
_title_jobs[token] = {"stage": "Starting", "fraction": 0, "done": False, "error": None,
|
||||
"message": None, "title": None, "time": time.time()}
|
||||
ensure_master()
|
||||
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
|
||||
threading.Thread(target=_run_title_install, daemon=True,
|
||||
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
|
||||
return {"message": f"Installing {entry['plan']['source']}", "job": token}
|
||||
if action not in ("launch", "remove"):
|
||||
raise Failure("unknown action", 400)
|
||||
gid = str(body.get("id", ""))
|
||||
if not frame_titles.ID_RE.match(gid):
|
||||
raise Failure("bad title id", 400)
|
||||
ensure_master()
|
||||
try:
|
||||
m = getattr(frame_titles, action)(gid)
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e))
|
||||
return {"message": f"{'Launching' if action == 'launch' else 'Removed'} {m['id']}"}
|
||||
|
||||
|
||||
def title_job(query):
|
||||
with _titles_lock:
|
||||
job = _title_jobs.get((parse_qs(query).get("token") or [""])[0])
|
||||
snapshot = job and {k: v for k, v in job.items() if k != "time"}
|
||||
if not snapshot:
|
||||
raise Failure("no such install", 404)
|
||||
return snapshot
|
||||
|
||||
|
||||
# ---- Android display (wm size / wm density / font_scale over ADB) -----------
|
||||
#
|
||||
# Each running Lepton instance listens for ADB on the Frame (5555 is Lepton
|
||||
@@ -464,16 +698,15 @@ _live_tunnels = set() # ssh processes (ADB forwards, live video) to kill if the
|
||||
|
||||
|
||||
def adb_path():
|
||||
for cand in (os.environ.get("ADB"), shutil.which("adb"), "/opt/homebrew/bin/adb",
|
||||
str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"):
|
||||
if cand and os.access(cand, os.X_OK):
|
||||
return cand
|
||||
raise Failure("adb missing on the Mac: brew install android-platform-tools", 500)
|
||||
try:
|
||||
return frame_host.adb()
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
|
||||
|
||||
def adb(adb_bin, *args, timeout=20):
|
||||
try:
|
||||
r = subprocess.run([adb_bin, *args], capture_output=True, text=True,
|
||||
r = subprocess.run([adb_bin, *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
errors="replace", timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"adb {' '.join(args[-2:])} timed out")
|
||||
@@ -490,7 +723,7 @@ def free_local_port():
|
||||
|
||||
|
||||
class AdbTunnel:
|
||||
"""SSH forwards from Mac loopback to Frame ADB ports, plus adb connections.
|
||||
"""SSH forwards from local loopback to Frame ADB ports, plus adb connections.
|
||||
|
||||
`with AdbTunnel([5555, 5557]) as t: t.shell(5555, "wm size")`. On exit it
|
||||
disconnects adb and kills the ssh process, whatever happened inside.
|
||||
@@ -588,7 +821,7 @@ class AdbTunnel:
|
||||
self._stop_ssh()
|
||||
for p in self.local:
|
||||
try:
|
||||
subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, timeout=10)
|
||||
subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, stdin=subprocess.DEVNULL, timeout=10)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
finally:
|
||||
@@ -736,12 +969,245 @@ def android_display(body):
|
||||
return {"message": f"Port {port}: " + "; ".join(c.split(";")[0] for c in cmds), "display": now}
|
||||
|
||||
|
||||
POST = {"/api/android/display": android_display, "/api/android": android,"/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots}
|
||||
# ---- install links from websites (frame-control://install, docs/web-install.md) ----
|
||||
# The app hands the link to the page, which asks /check (fetches the manifest,
|
||||
# downloads nothing), shows what it found and waits for the user's click before
|
||||
# /start. A website can't call these itself: like all of /api/* they need the
|
||||
# Host and X-Frame-UI checks in Handler.local_request.
|
||||
_web_lock = threading.Lock()
|
||||
_web_plans = {} # id -> checked plan waiting for the user to confirm
|
||||
_web_jobs = {} # id -> progress of the confirmed install (only the latest is kept)
|
||||
_web_workers = set() # threads running an install, joined on shutdown
|
||||
_web_closing = False # set on shutdown; no new installs after that
|
||||
MAX_WEB_PLANS = 8
|
||||
WEB_TMP_PREFIX = "frame-webinstall-" # then the server's PID, for sweep_tmp
|
||||
|
||||
|
||||
def webinstall_check(body):
|
||||
manifest, url = body.get("manifest"), body.get("url")
|
||||
for v in (manifest, url):
|
||||
if v is not None and not isinstance(v, str):
|
||||
raise Failure("manifest and url must be strings", 400)
|
||||
try:
|
||||
plan = frame_webinstall.plan(manifest=manifest, url=url)
|
||||
except frame_webinstall.WebInstallError as e:
|
||||
raise Failure(str(e), 400)
|
||||
pid = secrets.token_urlsafe(16)
|
||||
with _web_lock:
|
||||
while len(_web_plans) >= MAX_WEB_PLANS:
|
||||
_web_plans.pop(next(iter(_web_plans)))
|
||||
_web_plans[pid] = plan
|
||||
shown = ("name", "file", "kind", "kindLabel", "host", "linkHost", "size", "source")
|
||||
return {"id": pid, **{k: plan[k] for k in shown}, "sha256": bool(plan["sha256"])}
|
||||
|
||||
|
||||
def webinstall_start(body):
|
||||
pid = body.get("id")
|
||||
with _web_lock:
|
||||
if any(j["phase"] in ("download", "install") for j in _web_jobs.values()):
|
||||
raise Failure("another install from a link is still running", 409)
|
||||
# One use per check: the page can only install what it showed.
|
||||
plan = _web_plans.pop(pid, None) if isinstance(pid, str) else None
|
||||
if not plan:
|
||||
raise Failure("unknown or already used install id; open the link again", 400)
|
||||
job = {"phase": "download", "done": 0, "total": plan["size"], "detail": "", "message": None,
|
||||
"error": None, "cancel": False}
|
||||
if _web_closing:
|
||||
raise Failure("Frame Control is quitting", 503)
|
||||
_web_jobs.clear()
|
||||
_web_jobs[pid] = job
|
||||
# Started under the lock, so shutdown never sees a thread it can't join.
|
||||
worker = threading.Thread(target=_webinstall_run, args=(plan, job), daemon=True)
|
||||
_web_workers.add(worker)
|
||||
worker.start()
|
||||
return {"job": pid}
|
||||
|
||||
|
||||
def _webinstall_run(plan, job):
|
||||
tmp = None
|
||||
try:
|
||||
tmp = tempfile.mkdtemp(prefix=f"{WEB_TMP_PREFIX}{os.getpid()}-")
|
||||
|
||||
def progress(done, total):
|
||||
job["done"], job["total"] = done, total
|
||||
|
||||
def detail(*args, **_kw): # frame_titles may report its steps as text
|
||||
texts = [a for a in args if isinstance(a, str)]
|
||||
if texts:
|
||||
job["detail"] = texts[0][:200]
|
||||
|
||||
def connected(conn):
|
||||
with _web_lock:
|
||||
job["_conn"] = conn
|
||||
stop = job["cancel"] # cancelled before this connection existed
|
||||
if stop:
|
||||
frame_webinstall.abort(conn)
|
||||
|
||||
path = frame_webinstall.download(plan, tmp, progress=progress, cancelled=lambda: job["cancel"],
|
||||
connected=connected)
|
||||
# Under the lock cancel uses, so a cancel it acknowledged is never followed by an install.
|
||||
with _web_lock:
|
||||
if job["cancel"]:
|
||||
raise frame_webinstall.Cancelled("download cancelled")
|
||||
job["phase"] = "install"
|
||||
job.pop("_conn", None)
|
||||
ensure_master()
|
||||
res = frame_webinstall.dispatch(path, name=plan["name"], exe=plan["exe"], progress=detail, source=plan["url"])
|
||||
job["message"], job["phase"] = res["message"], "done"
|
||||
except Exception as e:
|
||||
known = (frame_webinstall.WebInstallError, Failure, frame_android.FrameError)
|
||||
job["error"] = str(e) if isinstance(e, known) else f"{type(e).__name__}: {e}"
|
||||
job["phase"] = "error"
|
||||
finally:
|
||||
with _web_lock:
|
||||
job.pop("_conn", None)
|
||||
if tmp:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
with _web_lock:
|
||||
_web_workers.discard(threading.current_thread())
|
||||
|
||||
|
||||
def webinstall_job(query):
|
||||
job = _web_jobs.get((parse_qs(query).get("id") or [""])[0])
|
||||
if not job:
|
||||
raise Failure("unknown install job", 404)
|
||||
with _web_lock: # the worker adds and drops _conn meanwhile
|
||||
return {k: v for k, v in job.items() if k != "cancel" and not k.startswith("_")}
|
||||
|
||||
|
||||
def webinstall_cancel(body):
|
||||
jid = body.get("job")
|
||||
job = _web_jobs.get(jid) if isinstance(jid, str) else None
|
||||
if not job:
|
||||
raise Failure("unknown install job", 404)
|
||||
with _web_lock:
|
||||
if job["phase"] != "download":
|
||||
raise Failure("only the download can be cancelled", 409)
|
||||
job["cancel"] = True
|
||||
conn = job.get("_conn")
|
||||
if conn:
|
||||
frame_webinstall.abort(conn)
|
||||
return {"message": "Cancelling the download"}
|
||||
|
||||
|
||||
def webinstall_shutdown():
|
||||
"""Stop downloads and give workers a moment to delete their temporary files.
|
||||
|
||||
An install already copying to the Frame may outlive this; sweep_tmp
|
||||
removes what it leaves on a later start.
|
||||
"""
|
||||
global _web_closing
|
||||
with _web_lock:
|
||||
_web_closing = True
|
||||
conns = []
|
||||
for job in _web_jobs.values():
|
||||
job["cancel"] = True
|
||||
conns.append(job.get("_conn")) # once: the worker may drop it any time
|
||||
workers = list(_web_workers)
|
||||
for conn in conns:
|
||||
if conn:
|
||||
frame_webinstall.abort(conn)
|
||||
deadline = time.time() + 4 # the app kills the server 5 s after asking it to stop
|
||||
for worker in workers:
|
||||
worker.join(max(0, deadline - time.time()))
|
||||
|
||||
|
||||
def _pid_alive(pid):
|
||||
if frame_host.WINDOWS:
|
||||
# os.kill(pid, 0) would terminate the process there; ask the kernel instead.
|
||||
import ctypes
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
handle = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
||||
if not handle:
|
||||
return ctypes.get_last_error() == 5 # access denied: it exists
|
||||
try:
|
||||
code = ctypes.c_ulong()
|
||||
return not k32.GetExitCodeProcess(handle, ctypes.byref(code)) or code.value == 259 # STILL_ACTIVE
|
||||
finally:
|
||||
k32.CloseHandle(handle)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except OSError:
|
||||
return True # exists, owned by someone else
|
||||
return True
|
||||
|
||||
|
||||
def sweep_tmp():
|
||||
"""Delete download and title staging folders left by a server killed mid-install.
|
||||
|
||||
Folders carry the server's PID, so only a dead server's are taken.
|
||||
"""
|
||||
for prefix in (WEB_TMP_PREFIX, frame_titles.TMP_PREFIX):
|
||||
for d in Path(tempfile.gettempdir()).glob(f"{prefix}*"):
|
||||
_sweep_one(prefix, d)
|
||||
|
||||
|
||||
def _sweep_one(prefix, d):
|
||||
m = re.fullmatch(re.escape(prefix) + r"(\d+)-.*", d.name)
|
||||
if not m:
|
||||
return
|
||||
pid = int(m[1])
|
||||
try:
|
||||
if pid != os.getpid() and not _pid_alive(pid) and d.is_dir():
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel}
|
||||
|
||||
|
||||
# ---- HTTP ------------------------------------------------------------------
|
||||
|
||||
def _pipe_reader(pipe):
|
||||
"""Chunks from a pipe via a thread; select() can't wait on pipes on Windows."""
|
||||
chunks = queue.Queue() # unbounded: the pump never blocks, so it ends at EOF
|
||||
|
||||
def pump():
|
||||
try:
|
||||
while True:
|
||||
chunk = pipe.read1(1 << 16) if hasattr(pipe, "read1") else os.read(pipe.fileno(), 1 << 16)
|
||||
chunks.put(chunk)
|
||||
if not chunk:
|
||||
return
|
||||
except (OSError, ValueError):
|
||||
chunks.put(b"")
|
||||
|
||||
threading.Thread(target=pump, daemon=True).start()
|
||||
return chunks
|
||||
|
||||
|
||||
def _next_chunk(chunks, timeout):
|
||||
"""The next chunk, or b"" at end of stream or after `timeout` seconds of silence."""
|
||||
try:
|
||||
return chunks.get(timeout=timeout)
|
||||
except queue.Empty:
|
||||
return b""
|
||||
|
||||
|
||||
def push_file(path, dest="Downloads/"):
|
||||
"""Copy a file to the Frame (as scripts/push.sh): rsync where both ends have it, else scp."""
|
||||
name = Path(path).name
|
||||
try:
|
||||
# Not on Windows: a Windows rsync (cwRsync, MSYS2) wouldn't take our POSIX -e quoting.
|
||||
if not frame_host.WINDOWS and shutil.which("rsync") and ssh("command -v rsync >/dev/null && echo yes || true").strip() == "yes":
|
||||
cmd = ["rsync", "-a", "-e", shlex.join(SSH), str(path), f"{FRAME}:{shlex.quote(dest)}"]
|
||||
else:
|
||||
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
||||
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
||||
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"Copying {name} timed out")
|
||||
if r.returncode != 0:
|
||||
raise Failure(strip_ansi(r.stderr or r.stdout).strip() or f"copy exited {r.returncode}")
|
||||
return f"Sent {name} to ~/{dest}"
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "FrameControl/1"
|
||||
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
||||
@@ -780,6 +1246,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def send_json(self, obj, status=200):
|
||||
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
||||
|
||||
def send_error_json(self, message, status):
|
||||
body, offline_status = error_body(message)
|
||||
self.send_json(body, offline_status or status)
|
||||
|
||||
def do_GET(self):
|
||||
if not self.local_request():
|
||||
return
|
||||
@@ -788,9 +1258,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/api/host":
|
||||
self.send_json({"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||
elif path == "/api/android":
|
||||
ensure_master()
|
||||
self.send_json({"apps": frame_android.list_apps()})
|
||||
elif path == "/api/titles":
|
||||
ensure_master()
|
||||
self.send_json({"titles": frame_titles.list_titles()})
|
||||
elif path == "/api/titles/job":
|
||||
self.send_json(title_job(url.query))
|
||||
elif path == "/api/job":
|
||||
self.send_json(job_status(url.query))
|
||||
elif path == "/api/android/displays":
|
||||
self.send_json(android_displays())
|
||||
elif path == "/api/android/reports":
|
||||
@@ -804,6 +1284,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(steam_frame("owned"))
|
||||
elif path == "/api/steam/search":
|
||||
self.send_json(steam_search(url.query))
|
||||
elif path == "/api/webinstall/job":
|
||||
self.send_json(webinstall_job(url.query))
|
||||
elif path == "/api/shots":
|
||||
self.send_json(list_shots())
|
||||
elif path == "/api/shots/image":
|
||||
@@ -818,9 +1300,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
except Failure as e:
|
||||
self.send_json({"error": str(e)}, e.status)
|
||||
self.send_error_json(str(e), e.status)
|
||||
except frame_android.FrameError as e:
|
||||
self.send_json({"error": str(e)}, 502)
|
||||
self.send_error_json(str(e), 502)
|
||||
except Exception as e:
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
@@ -844,11 +1326,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
self.send_json(handler(body))
|
||||
except Failure as e:
|
||||
self.send_json({"error": str(e)}, e.status)
|
||||
self.send_error_json(str(e), e.status)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.send_json({"error": f"bad request: {e}"}, 400)
|
||||
except frame_android.FrameError as e:
|
||||
self.send_json({"error": str(e)}, 502)
|
||||
self.send_error_json(str(e), 502)
|
||||
except Exception as e:
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
@@ -869,11 +1351,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
old, _stream_proc = _stream_proc, proc
|
||||
if old and old.poll() is None:
|
||||
old.terminate()
|
||||
fd = proc.stdout.fileno()
|
||||
chunks = _pipe_reader(proc.stdout)
|
||||
# Nothing is sent until the first bytes arrive, so a failure to
|
||||
# start still comes back as a JSON error.
|
||||
ready, _, _ = select.select([fd], [], [], 20)
|
||||
first = os.read(fd, 1 << 16) if ready else b""
|
||||
first = _next_chunk(chunks, 20)
|
||||
if not first:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
@@ -894,8 +1375,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.flush()
|
||||
# A stalled headset view ends the stream rather than
|
||||
# holding this thread (and the page) forever.
|
||||
ready, _, _ = select.select([fd], [], [], STREAM_STALL)
|
||||
chunk = os.read(fd, 1 << 16) if ready else b""
|
||||
chunk = _next_chunk(chunks, STREAM_STALL)
|
||||
except OSError:
|
||||
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
|
||||
finally:
|
||||
@@ -911,7 +1391,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
_live_tunnels.discard(proc)
|
||||
|
||||
def upload(self):
|
||||
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only)."""
|
||||
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install), 'apkinfo' (read only)
|
||||
or 'title' (a .zip or program to sideload: inspected and kept for /api/titles install)."""
|
||||
name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
|
||||
mode = self.headers.get("X-Mode", "push")
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
@@ -922,6 +1403,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
|
||||
raise Failure("APK install needs a .apk file", 400)
|
||||
tmp = Path(tempfile.mkdtemp(prefix="frame-ui-"))
|
||||
keep = False
|
||||
try:
|
||||
dest = tmp / name
|
||||
with open(dest, "wb") as f:
|
||||
@@ -945,6 +1427,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except frame_android.FrameError as e:
|
||||
info["blocker"] = str(e)
|
||||
return {"message": f"Read {info['label']} {info['version']}", "apk": info}
|
||||
if mode == "title":
|
||||
keep = True # stage_title owns tmp now, and removes it on failure
|
||||
return stage_title(str(dest), temp_dir=str(tmp))
|
||||
if mode == "apk":
|
||||
ensure_master()
|
||||
try:
|
||||
@@ -952,30 +1437,48 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e), 400)
|
||||
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
|
||||
return {"message": script("push.sh", str(dest))}
|
||||
return {"message": push_file(dest)}
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
if not keep:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
||||
ap.add_argument("--exit-on-eof", action="store_true",
|
||||
help="stop cleanly when stdin closes (the app closes it on quit; "
|
||||
"Windows has no SIGTERM to catch)")
|
||||
args = ap.parse_args()
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
sweep_tmp()
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
if args.exit_on_eof:
|
||||
def watch_stdin():
|
||||
sys.stdin.buffer.read()
|
||||
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||
threading.Thread(target=watch_stdin, daemon=True).start()
|
||||
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
# The app both closes stdin and sends SIGTERM on quit; a second signal
|
||||
# mid-cleanup would abort it and leave the SSH master running.
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
webinstall_shutdown()
|
||||
# The master was started with -N, so it stays up until told to exit.
|
||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
|
||||
if CONTROL:
|
||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)
|
||||
if _master and _master.poll() is None:
|
||||
_master.terminate()
|
||||
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
_purge_titles(now=float("inf")) # unconfirmed title uploads
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user