mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 13:00:33 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f629af35 | ||
|
|
7efce19f4c | ||
|
|
623ce683d4 | ||
|
|
e5f3c96275 | ||
|
|
0cb289d571 | ||
|
|
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 | ||
|
|
6d73912f8c | ||
|
|
60571dbfac | ||
|
|
f324aac690 | ||
|
|
8b7c46a63a | ||
|
|
f3ae71ab05 | ||
|
|
eabf4cd1f9 |
No files matched your search
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: steam-frame
|
name: steam-frame
|
||||||
description: Operate the user's Valve Steam Frame headset from the Mac through the ~/projects/steam-frame helpers and field notes. Use for Steam Frame SSH, screen streaming, clipboard, file push, APK or Flatpak installs, launching apps on the headset, arranging floating windows or panels in VR space, or debugging SteamOS/gamescope/SteamVR on the Frame.
|
description: Operate the user's Valve Steam Frame headset from the Mac through this repo's helpers and field notes. Use for Steam Frame SSH, screen streaming, clipboard, file push, APK or Flatpak installs, launching apps on the headset, arranging floating windows or panels in VR space, or debugging SteamOS/gamescope/SteamVR on the Frame.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Steam Frame
|
# Steam Frame
|
||||||
|
|
||||||
The repo is `~/projects/steam-frame`. SSH works through the `frame` alias
|
SSH works through the `frame` alias
|
||||||
(user `steamos`). The headset has to be awake for anything that touches its
|
(user `steamos`). The headset has to be awake for anything that touches its
|
||||||
desktop or panels.
|
desktop or panels.
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ desktop or panels.
|
|||||||
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
|
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
|
||||||
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
|
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
|
||||||
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
|
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
|
||||||
|
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
|
||||||
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
||||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||||
| Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` |
|
| Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` |
|
||||||
|
|||||||
@@ -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
|
esac
|
||||||
done
|
done
|
||||||
- name: Python compiles
|
- 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
|
- name: Server tests
|
||||||
run: python -m unittest discover -s tests -v
|
run: python -m unittest discover -s tests -v
|
||||||
- name: App syntax
|
- 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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 saphid
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,211 +1,199 @@
|
|||||||
# Steam Frame ↔ Mac
|
<div align="center">
|
||||||
|
|
||||||
This repo holds notes and Mac-side helpers for controlling a Valve Steam Frame
|
<img src="docs/img/icon.png" width="112" alt="Frame Control icon">
|
||||||
(standalone VR headset: SteamOS 3, Arch-based, arm64, Snapdragon 8 Gen 3) from
|
|
||||||
this Mac, with as little typing on the headset's virtual keyboard as possible.
|
|
||||||
|
|
||||||
Status: written 2026-09-25 and checked against a real Frame the same day
|
# Frame Control
|
||||||
(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.
|
|
||||||
|
|
||||||
**Quick start:** set up SSH once (next section), then install
|
**Manage your Valve Steam Frame from your computer.**<br>
|
||||||
[Frame Control](#frame-control-mac-app) from the DMG.
|
See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
|
||||||
|
|
||||||
## Minimum typing on the headset
|
[](https://github.com/saphid/steam-frame/releases/latest)
|
||||||
|
[](#install)
|
||||||
|
[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
|
[**Download**](#install) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
|
||||||
**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:
|
<br>
|
||||||
|
|
||||||
1. **Steam Settings → System → Enable Developer Mode** (a toggle, no typing).
|
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900">
|
||||||
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:
|
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||||
|
|
||||||
```sh
|
</div>
|
||||||
cd ~/projects/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)
|
## Features
|
||||||
- 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
|
|
||||||
|
|
||||||
Run `./scripts/connect.sh --harden` later if you want to turn off SSH password
|
<table>
|
||||||
logins.
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
Sources: [Valve: Setting up your Steam Frame for development](https://partner.steamgames.com/doc/steamhardware/steamframe/setup),
|
**👓 Headset view**<br>
|
||||||
[Valve: Steam Frame Debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)
|
Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
|
||||||
(both **confirmed on Steam Frame**, Valve official).
|
|
||||||
|
|
||||||
**Fallback, only if the Developer Mode toggle doesn't give you SSH.** From the
|
</td>
|
||||||
Mac, run `./scripts/serve-bootstrap.sh`. It prints a one-liner of about 30
|
<td width="50%" valign="top">
|
||||||
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).
|
|
||||||
|
|
||||||
## Recommended options
|
**🔋 Battery and status**<br>
|
||||||
|
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
|
||||||
|
|
||||||
| Goal | Recommended | Confidence |
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**🎮 Steam games**<br>
|
||||||
|
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**🤖 Android apps**<br>
|
||||||
|
About 4,500 F-Droid apps rated for the Frame. One click installs each as its own app in your Steam library.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**📁 Files, games and clipboard**<br>
|
||||||
|
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**📸 Screenshots**<br>
|
||||||
|
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**🧩 Flatpaks and display**<br>
|
||||||
|
Install desktop apps like Moonlight or VLC, and set each Android app's resolution and text size.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**⚡ One-click tools**<br>
|
||||||
|
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Nothing is installed on the Frame for any of this: the app uses what SteamOS
|
||||||
|
already ships (sideloading a game copies Valve's own devkit scripts to
|
||||||
|
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
| | Download | Needs |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Shell on the Frame | `ssh frame` (user `steamos`) | Confirmed (Valve docs) |
|
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
|
||||||
| **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 |
|
| **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 |
|
||||||
| **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 |
|
| **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) |
|
||||||
| File transfer | `scp` / `rsync` over the `frame` alias (`scripts/push.sh`) | **Verified** (rsync is on the image) |
|
| **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`) |
|
||||||
| 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](docs/ssh.md), [docs/streaming.md](docs/streaming.md),
|
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
|
||||||
[docs/file-transfer.md](docs/file-transfer.md),
|
Google doesn't publish `adb` for arm64 Linux, so that build uses your
|
||||||
[docs/open-questions.md](docs/open-questions.md). For how the Frame's software
|
distribution's. If you already have `adb`, the app uses yours.
|
||||||
fits together, see [docs/how-the-frame-works.md](docs/how-the-frame-works.md).
|
|
||||||
|
|
||||||
## 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
|
There's no paid Apple developer account behind it, so macOS says the app is
|
||||||
leave it. Each Steam app, though, gets its own SteamVR panel. That also works
|
damaged or can't be checked. Drag it to Applications, then clear the download
|
||||||
for any Linux app tagged with an app id of its own:
|
quarantine once:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/panel-on-frame.sh konsole
|
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||||
./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**
|
The first time, macOS also asks to allow local network access (for SSH) and
|
||||||
controls to place each panel. See [docs/panels.md](docs/panels.md).
|
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.
|
The installer isn't code-signed, so Windows SmartScreen may say it protected
|
||||||
[Stream Frame](https://streamframe.app/) (macOS 14+, free) records and screenshots
|
your PC. Choose **More info → Run anyway**. The portable `.zip` avoids the
|
||||||
the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is
|
installer: unzip it anywhere and run `Frame Control.exe`.
|
||||||
Windows-only. Steam Link views the headset. **Frame Control** is a Mac app over
|
</details>
|
||||||
the scripts below. Install it from the DMG (see [Mac app](#mac-app)), or run
|
|
||||||
the same UI in a browser without packaging:
|
<details>
|
||||||
|
<summary><b>Linux: running the AppImage</b></summary>
|
||||||
|
|
||||||
```sh
|
```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,
|
## Set up the headset
|
||||||
floating panels, dashboard and controllers). Shows the left eye, like pointing
|
|
||||||
a camera into one lens, or both eyes; single shot or about 2 fps live; saves
|
|
||||||
as PNG. The viewer fits the whole frame; zoom with − / + (or scroll, or
|
|
||||||
double-click), drag to pan, `0` to fit, `F` for full screen. It uses OpenVR's `IVRScreenshots` API through Python `ctypes`
|
|
||||||
(`ui/frame_vrshot.py`), so nothing is installed on the Frame. **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). The
|
|
||||||
ratings go into our private compatibility database (a Lakebed capsule only the
|
|
||||||
app can use, backed up daily to Google Drive; 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.
|
|
||||||
|
|
||||||
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
You type one password on the headset, once. Everything else happens on your
|
||||||
with a non-local `Host` header, and any `/api/` request without a custom
|
computer.
|
||||||
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.
|
|
||||||
|
|
||||||
### 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
|
Before asking for the password it tries Valve's SteamOS devkit pairing: in
|
||||||
`ui/`, `scripts/`, `frame/android/` and the rated catalogue from `apk-catalog/`.
|
the headset, open Steam Settings → Developer → **Pair new host** and approve
|
||||||
It starts `ui/server.py` on a free loopback port and shows it in its own window.
|
the request, and no password is needed. (The service and the pairing-mode
|
||||||
The server stops when you quit the app. A prebuilt DMG for Apple Silicon is
|
step are verified on a Frame; the approval itself isn't yet. See
|
||||||
attached to each [GitHub release](https://github.com/saphid/steam-frame/releases).
|
[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
|
**What it changes:** only what you click. Installs go to your user account on
|
||||||
cd app
|
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
|
||||||
npm install
|
games in `~/devkit-game`), and nothing
|
||||||
npm run dist # → app/dist/Frame Control-<version>-arm64.dmg (and a .zip)
|
needs `sudo` except the power buttons. On your computer it adds a `Host frame`
|
||||||
npm start # run from the checkout without packaging
|
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
|
## Feedback
|
||||||
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 Android ratings database needs
|
|
||||||
its key in the Keychain (see `compat-db/README.md`); without it, the app uses
|
|
||||||
its offline copy.
|
|
||||||
|
|
||||||
The build is ad-hoc signed and not notarized. A copy you build yourself opens
|
This is a first public test, so reports are really useful, especially from
|
||||||
normally. A copy downloaded from GitHub Releases is quarantined; clear it with
|
Windows and Linux. Please [open an issue](https://github.com/saphid/steam-frame/issues/new)
|
||||||
`xattr -dr com.apple.quarantine "/Applications/Frame Control.app"`. The first
|
with:
|
||||||
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.
|
|
||||||
|
|
||||||
## 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 |
|
## Going further
|
||||||
|---|---|---|
|
|
||||||
| `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 | Back up the compatibility database locally and to Google Drive (daily LaunchAgent) (**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` |
|
|
||||||
|
|
||||||
## 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.
|
- 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,
|
Each running Lepton (Android) instance opens its own ADB port in 5555–5599,
|
||||||
@@ -214,23 +202,28 @@ showed live status and the library.
|
|||||||
networks only, and turn Developer Mode off when you don't need it.
|
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
|
- Frame Control reaches ADB and the Steam client's DevTools port (Frame
|
||||||
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
|
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
|
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
|
||||||
you've switched to key auth, a short password still protects `sudo` and
|
you've switched to key auth, a short password still protects `sudo` and
|
||||||
RDP, so pick one that isn't trivially guessable.
|
RDP, so pick one that isn't trivially guessable.
|
||||||
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access,
|
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access,
|
||||||
use Tailscale (Flatpak/package availability for the Frame hasn't been
|
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
|
||||||
checked).
|
**every** Frame port is reachable from your tailnet, including Steam's DevTools
|
||||||
|
on loopback 8080; see [docs/tailscale.md](docs/tailscale.md).
|
||||||
|
</details>
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m unittest discover -s tests # server guards, validation, Steam helpers; no headset needed
|
python3 -m unittest discover -s tests # server tests; no headset needed
|
||||||
cd app && npm install && npm run dist # build the DMG
|
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
|
The server is Python stdlib only; the app is Electron. GitHub Actions runs the
|
||||||
may find (Xcode Command Line Tools), plus syntax checks for every script and the
|
tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
|
||||||
Electron main process (`.github/workflows/checks.yml`). Anything that touches the
|
into the release. See [building](docs/frame-control.md#building).
|
||||||
headset is verified by hand against a real Frame, and the docs label it
|
|
||||||
**verified** or **inferred**.
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE). Steam, Steam Frame and SteamVR are trademarks of Valve
|
||||||
|
Corporation. This project isn't affiliated with or endorsed by Valve.
|
||||||
@@ -27,8 +27,8 @@ rules and the evidence behind them are in [docs/apks.md](../docs/apks.md).
|
|||||||
|
|
||||||
## Compatibility reports
|
## Compatibility reports
|
||||||
|
|
||||||
Reports live in Frame Control's private database, a Lakebed capsule at
|
Reports are saved on your Mac. The maintainer's copy of Frame Control also
|
||||||
`https://frame-compat.lakebed.app` that only the app can read or write (see
|
syncs them to a private Lakebed database (see
|
||||||
[compat-db/README.md](../compat-db/README.md), including backups). **Test**
|
[compat-db/README.md](../compat-db/README.md), including backups). **Test**
|
||||||
records whether an app stays up in its own instance (`result`); **Report**
|
records whether an app stays up in its own instance (`result`); **Report**
|
||||||
(on any installed app, catalogue card, or **+ Report an APK** for anything else, e.g. an
|
(on any installed app, catalogue card, or **+ Report an APK** for anything else, e.g. an
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
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 };
|
||||||
+163
-48
@@ -1,7 +1,7 @@
|
|||||||
// Frame Control as a Mac app: starts ui/server.py on a free loopback port and
|
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||||
// shows it in a native window. The server does all the work over the `frame`
|
// a free loopback port and shows it in a native window. The server does all the
|
||||||
// SSH alias; this file only hosts it.
|
// work over the `frame` SSH alias; this file only hosts it.
|
||||||
const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
|
const { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, shell } = require("electron");
|
||||||
const { execFile, spawn } = require("child_process");
|
const { execFile, spawn } = require("child_process");
|
||||||
const { promisify } = require("util");
|
const { promisify } = require("util");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -9,14 +9,20 @@ const http = require("http");
|
|||||||
const net = require("net");
|
const net = require("net");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||||
|
|
||||||
const run = promisify(execFile);
|
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 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 SERVER = path.join(ROOT, "ui", "server.py");
|
||||||
const SCRIPTS = path.join(ROOT, "scripts");
|
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 LOG = path.join(LOG_DIR, "server.log");
|
||||||
const BG = "#0d1117";
|
const BG = "#0d1117";
|
||||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||||
@@ -25,15 +31,18 @@ let server = null;
|
|||||||
let url = null;
|
let url = null;
|
||||||
let win = null;
|
let win = null;
|
||||||
let quitting = false;
|
let quitting = false;
|
||||||
|
let python = null;
|
||||||
|
|
||||||
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses
|
// 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.
|
// Runs asynchronously so a slow shell profile can't freeze the window.
|
||||||
let cachedPath = null;
|
let cachedPath = null;
|
||||||
async function loginPath() {
|
async function loginPath() {
|
||||||
|
if (IS_WIN) return process.env.PATH || "";
|
||||||
if (cachedPath) return cachedPath;
|
if (cachedPath) return cachedPath;
|
||||||
const shellPath = os.userInfo().shell || process.env.SHELL || "/bin/zsh";
|
const shellPath = os.userInfo().shell || process.env.SHELL || (IS_MAC ? "/bin/zsh" : "/bin/sh");
|
||||||
const extra = ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")];
|
const extra = IS_MAC ? ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")] : [];
|
||||||
let fromShell = "";
|
let fromShell = "";
|
||||||
try {
|
try {
|
||||||
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
|
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
|
||||||
@@ -46,19 +55,44 @@ async function loginPath() {
|
|||||||
return joined;
|
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) {
|
async function findPython(env) {
|
||||||
for (const dir of env.PATH.split(":")) {
|
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"];
|
||||||
const p = path.join(dir, "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 {
|
try {
|
||||||
fs.accessSync(p, fs.constants.X_OK);
|
fs.accessSync(p, fs.constants.X_OK);
|
||||||
// /usr/bin/python3 is a stub until the Command Line Tools are installed.
|
// /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed.
|
||||||
await run(p, ["-c", "import http.server"], { timeout: 10000, env });
|
await run(p, [...PY_FLAGS, "-c", "import http.server, sys; assert sys.version_info >= (3, 8)"],
|
||||||
|
{ timeout: 10000, env, windowsHide: true });
|
||||||
return p;
|
return p;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
return null;
|
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() {
|
function freePort() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const s = net.createServer();
|
const s = net.createServer();
|
||||||
@@ -80,17 +114,19 @@ function ping(target) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" };
|
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||||
const python = await findPython(env);
|
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||||
if (!python) {
|
python = await findPython(env);
|
||||||
throw new Error("Frame Control needs python3. Install the Xcode Command Line Tools "
|
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
|
||||||
+ "(xcode-select --install) or Homebrew's python, then reopen the app.");
|
if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`);
|
||||||
}
|
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
const log = fs.openSync(LOG, "a");
|
const log = fs.openSync(LOG, "a");
|
||||||
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
|
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);
|
fs.closeSync(log);
|
||||||
server = child;
|
server = child;
|
||||||
let exited = null;
|
let exited = null;
|
||||||
@@ -112,13 +148,20 @@ async function startServer() {
|
|||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
}
|
}
|
||||||
if (server === child) server = null;
|
if (server === child) server = null;
|
||||||
child.kill("SIGTERM");
|
endServer(child);
|
||||||
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
|
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() {
|
function stopServer() {
|
||||||
// server.py handles SIGTERM by closing its shared SSH connection.
|
if (server) endServer(server);
|
||||||
if (server) server.kill("SIGTERM");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorPage(message) {
|
function errorPage(message) {
|
||||||
@@ -139,12 +182,12 @@ async function restartServer() {
|
|||||||
const old = server;
|
const old = server;
|
||||||
server = null;
|
server = null;
|
||||||
url = null;
|
url = null;
|
||||||
if (old) old.kill("SIGTERM");
|
if (old) endServer(old);
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The page's sticky header becomes the title bar, clear of the traffic lights.
|
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
||||||
const CHROME_CSS = `
|
const CHROME_CSS = IS_MAC && `
|
||||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
||||||
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
||||||
`;
|
`;
|
||||||
@@ -183,23 +226,76 @@ async function firstRunCheck() {
|
|||||||
type: "info",
|
type: "info",
|
||||||
message: "Connect to your Steam Frame",
|
message: "Connect to your Steam Frame",
|
||||||
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → `
|
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 "
|
+ "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 Terminal.",
|
+ "headset, creates a key, and asks for that password once in a terminal window.",
|
||||||
buttons: ["Set Up Connection…", "Later"],
|
buttons: ["Set Up Connection…", "Later"],
|
||||||
defaultId: 0, cancelId: 1,
|
defaultId: 0, cancelId: 1,
|
||||||
});
|
});
|
||||||
if (response === 0) setUpConnection();
|
if (response === 0) setUpConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IPC only from our own page in our own window.
|
||||||
|
function fromUi(e) {
|
||||||
|
if (!win || e.sender !== win.webContents || !url || !e.senderFrame) return false;
|
||||||
|
try {
|
||||||
|
return new URL(e.senderFrame.url).origin === new URL(url).origin;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||||
|
|
||||||
|
// frame-control://install links from websites (docs/web-install.md). They can
|
||||||
|
// arrive before the window or server exists (macOS open-url on a cold launch),
|
||||||
|
// so they wait here until the page asks for them. The page checks the link with
|
||||||
|
// the server and installs nothing until the user confirms in its dialog.
|
||||||
|
const pendingLinks = [];
|
||||||
|
let linkPage = null; // the webContents whose current page is listening
|
||||||
|
|
||||||
|
function openInstallLink(raw) {
|
||||||
|
const req = parseInstallLink(raw);
|
||||||
|
if (!req) {
|
||||||
|
app.whenReady().then(() => dialog.showErrorBox("Frame Control can't use this link",
|
||||||
|
"Install links look like frame-control://install?manifest=https://… or frame-control://install?url=https://…"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingLinks.push(req);
|
||||||
|
if (pendingLinks.length > 5) pendingLinks.shift(); // a page opening links in a loop
|
||||||
|
deliverLinks();
|
||||||
|
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliverLinks() {
|
||||||
|
if (!win || !linkPage || linkPage !== win.webContents) return;
|
||||||
|
while (pendingLinks.length) win.webContents.send("install-link", pendingLinks.shift());
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.on("install-link:ready", (e) => {
|
||||||
|
if (!fromUi(e)) return;
|
||||||
|
linkPage = e.sender;
|
||||||
|
deliverLinks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function registerScheme() {
|
||||||
|
// A checkout runs as `electron .`, so the OS must be told the script too.
|
||||||
|
// (macOS takes the scheme from Info.plist, which only the built app has.)
|
||||||
|
if (process.defaultApp) {
|
||||||
|
if (process.argv.length >= 2) app.setAsDefaultProtocolClient(SCHEME, process.execPath, [path.resolve(process.argv[1])]);
|
||||||
|
} else {
|
||||||
|
app.setAsDefaultProtocolClient(SCHEME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||||
title: "Frame Control", backgroundColor: BG, show: false,
|
title: "Frame Control", backgroundColor: BG, show: false,
|
||||||
titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 },
|
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
|
||||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
: { 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.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.
|
// External links open in the default browser; the app never navigates away.
|
||||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||||
if (/^https?:\/\//.test(target)) shell.openExternal(target);
|
if (/^https?:\/\//.test(target)) shell.openExternal(target);
|
||||||
@@ -208,40 +304,51 @@ function createWindow() {
|
|||||||
win.webContents.on("will-navigate", (e, target) => {
|
win.webContents.on("will-navigate", (e, target) => {
|
||||||
if (!url || new URL(target).origin !== new URL(url).origin) e.preventDefault();
|
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();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs in Terminal because ssh-copy-id asks for the Developer Mode password.
|
// Opens a terminal window (Terminal, a Linux terminal emulator or a console) via
|
||||||
function runInTerminal(command) {
|
// ui/frame_host.py, which the server uses too: setup and power actions ask for the
|
||||||
const quoted = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
// Developer Mode password there.
|
||||||
execFile("osascript", ["-e", 'tell application "Terminal"', "-e", `do script "${quoted}"`,
|
async function runInTerminal(argv) {
|
||||||
"-e", "activate", "-e", "end tell"], (err) => {
|
try {
|
||||||
if (err) dialog.showErrorBox("Couldn't open Terminal", String(err.message || err));
|
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, "'\\''")}'`;
|
async function setUpConnection() {
|
||||||
|
const alias = `FRAME_ALIAS=${FRAME}`;
|
||||||
function setUpConnection() {
|
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
||||||
runInTerminal(`env ${sh(`FRAME_ALIAS=${FRAME}`)} zsh ${sh(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() {
|
function buildMenu() {
|
||||||
const template = [
|
const template = [
|
||||||
{ role: "appMenu" },
|
...(IS_MAC ? [{ role: "appMenu" }] : []),
|
||||||
{ role: "fileMenu" },
|
{ role: "fileMenu" },
|
||||||
{ role: "editMenu" },
|
{ role: "editMenu" },
|
||||||
{
|
{
|
||||||
label: "Frame",
|
label: "Frame",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: "Set Up Connection…", click: setUpConnection },
|
{ 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" },
|
{ type: "separator" },
|
||||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||||
{ label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) },
|
{ 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 +360,7 @@ function buildMenu() {
|
|||||||
{ type: "separator" }, { role: "togglefullscreen" },
|
{ type: "separator" }, { role: "togglefullscreen" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ role: "windowMenu" },
|
...(IS_MAC ? [{ role: "windowMenu" }] : []),
|
||||||
{
|
{
|
||||||
role: "help",
|
role: "help",
|
||||||
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
|
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
|
||||||
@@ -265,10 +372,18 @@ function buildMenu() {
|
|||||||
if (!app.requestSingleInstanceLock()) {
|
if (!app.requestSingleInstanceLock()) {
|
||||||
app.quit();
|
app.quit();
|
||||||
} else {
|
} 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(); }
|
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(() => {
|
app.whenReady().then(() => {
|
||||||
|
registerScheme();
|
||||||
buildMenu();
|
buildMenu();
|
||||||
createWindow();
|
createWindow();
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.1.0",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.1.0",
|
"version": "0.3.1",
|
||||||
"license": "UNLICENSED",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
"electron-builder": "^26.15.3"
|
"electron-builder": "^26.15.3"
|
||||||
|
|||||||
+83
-7
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"productName": "Frame Control",
|
"productName": "Frame Control",
|
||||||
"version": "0.1.0",
|
"version": "0.3.1",
|
||||||
"description": "Mac app for managing a Valve Steam Frame over SSH",
|
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"license": "UNLICENSED",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||||
"dist": "electron-builder --mac --arm64 --publish never",
|
"dist": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||||
"dist:dir": "electron-builder --mac --arm64 --dir"
|
"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": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
@@ -19,13 +21,24 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"appId": "com.saphid.frame-control",
|
"appId": "com.saphid.frame-control",
|
||||||
"productName": "Frame Control",
|
"productName": "Frame Control",
|
||||||
|
"protocols": [
|
||||||
|
{
|
||||||
|
"name": "Frame Control install link",
|
||||||
|
"schemes": [
|
||||||
|
"frame-control"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist",
|
"output": "dist",
|
||||||
"buildResources": "build"
|
"buildResources": "build"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"main.js",
|
"main.js",
|
||||||
"package.json"
|
"preload.js",
|
||||||
|
"install-link.js",
|
||||||
|
"package.json",
|
||||||
|
"build/icon.png"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
@@ -51,6 +64,14 @@
|
|||||||
"*.py"
|
"*.py"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "../frame/devkit-utils",
|
||||||
|
"to": "frame/devkit-utils",
|
||||||
|
"filter": [
|
||||||
|
"**/*",
|
||||||
|
"!**/__pycache__/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "../apk-catalog",
|
"from": "../apk-catalog",
|
||||||
"to": "apk-catalog",
|
"to": "apk-catalog",
|
||||||
@@ -59,6 +80,20 @@
|
|||||||
"pins.json",
|
"pins.json",
|
||||||
"site/apps.js"
|
"site/apps.js"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "build/deps/${os}-${arch}/python",
|
||||||
|
"to": "python",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "build/deps/${os}-${arch}/tools",
|
||||||
|
"to": "tools",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
@@ -73,7 +108,8 @@
|
|||||||
"extendInfo": {
|
"extendInfo": {
|
||||||
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
|
"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."
|
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
|
||||||
}
|
},
|
||||||
|
"artifactName": "Frame-Control-mac-${arch}.${ext}"
|
||||||
},
|
},
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"title": "Frame Control ${version}"
|
"title": "Frame Control ${version}"
|
||||||
@@ -82,6 +118,46 @@
|
|||||||
"runAsNode": false,
|
"runAsNode": false,
|
||||||
"enableNodeOptionsEnvironmentVariable": false,
|
"enableNodeOptionsEnvironmentVariable": false,
|
||||||
"enableNodeCliInspectArguments": 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,17 @@
|
|||||||
|
// Lets the page read this computer's clipboard through Electron, so sending it
|
||||||
|
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||||
|
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||||
|
// as a title without zipping it (the local server reads it from there).
|
||||||
|
// It also receives frame-control://install links (docs/web-install.md): only
|
||||||
|
// what the link asked for, never an install; the page asks the user first.
|
||||||
|
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("frameApp", {
|
||||||
|
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||||
|
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||||
|
onInstallLink: (cb) => {
|
||||||
|
ipcRenderer.removeAllListeners("install-link");
|
||||||
|
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
|
||||||
|
ipcRenderer.send("install-link:ready");
|
||||||
|
},
|
||||||
|
});
|
||||||
+7
-7
@@ -1,11 +1,12 @@
|
|||||||
# compat-db: Frame Control's compatibility database
|
# compat-db: Frame Control's compatibility database
|
||||||
|
|
||||||
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
|
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
|
||||||
reports for Android apps on the Steam Frame. Only Frame Control can read or
|
reports for Android apps on the Steam Frame. For now only the maintainer's
|
||||||
write it.
|
copy of Frame Control has the key to read or write it. Everyone else's reports
|
||||||
|
stay on their own Mac (see `shared()` in `ui/frame_compat_db.py`).
|
||||||
|
|
||||||
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
|
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
|
||||||
owned by `saphid`, doesn't expire). The browser page only says it's private.
|
claimed, so it doesn't expire). The browser page only says it's private.
|
||||||
- Access: `GET /v1/reports?since=<createdAt>` and `POST /v1/reports` with
|
- Access: `GET /v1/reports?since=<createdAt>` and `POST /v1/reports` with
|
||||||
`{"reports": [...]}`. Both need the `x-frame-control-key` header. There are
|
`{"reports": [...]}`. Both need the `x-frame-control-key` header. There are
|
||||||
no Lakebed queries or mutations, so nothing else can reach the rows.
|
no Lakebed queries or mutations, so nothing else can reach the rows.
|
||||||
@@ -22,10 +23,9 @@ write it.
|
|||||||
`scripts/compat-db-backup.sh` exports every report through the app key and
|
`scripts/compat-db-backup.sh` exports every report through the app key and
|
||||||
keeps dated copies in
|
keeps dated copies in
|
||||||
`~/Library/Application Support/Frame Control/compat-db/backups` (newest 60).
|
`~/Library/Application Support/Frame Control/compat-db/backups` (newest 60).
|
||||||
When the data has changed, it also uploads them to Google Drive
|
When the data has changed, it also uploads them with `gog` to the Google
|
||||||
(**the backup folder**, folder
|
Drive folder named by `DRIVE_FOLDER_ID` (set it in the LaunchAgent's
|
||||||
`<drive-folder-id>`) with `gog`. The LaunchAgent
|
`EnvironmentVariables`). A LaunchAgent runs it daily at 03:40 and logs to
|
||||||
`frame-compat-backup` runs it daily at 03:40; the log is
|
|
||||||
`~/Library/Logs/frame-compat-backup.log`. If an export has fewer reports than
|
`~/Library/Logs/frame-compat-backup.log`. If an export has fewer reports than
|
||||||
the last good backup (`backups/.last-good`), it's kept as `refused-*.json`,
|
the last good backup (`backups/.last-good`), it's kept as `refused-*.json`,
|
||||||
nothing is uploaded, and every later run refuses too until you rerun with
|
nothing is uploaded, and every later run refuses too until you rerun with
|
||||||
|
|||||||
+9
-9
@@ -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
|
Each APK becomes its own app, the way T3 Code is set up (see the instance
|
||||||
section below), instead of going into Lepton Development:
|
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.
|
API > 30 or have no `arm64-v8a` build are refused.
|
||||||
2. The APK, `frame/android/lepton-app.sh` (as `launch.sh`), `instance.id`,
|
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
|
`meta.json`, the icon and the `lepton-show-flatscreen` marker go to
|
||||||
@@ -135,11 +136,11 @@ collects community reports for Steam games only and has no public API, and
|
|||||||
Valve's "Great on Frame" badges and each Steam app's `recommended_runtime`
|
Valve's "Great on Frame" badges and each Steam app's `recommended_runtime`
|
||||||
(for example `lepton-stable`) also cover Steam games only
|
(for example `lepton-stable`) also cover Steam games only
|
||||||
([VR.org](https://vr.org/articles/steam-frame-lepton-android-runtime-52-of-130-certified-2026)).
|
([VR.org](https://vr.org/articles/steam-frame-lepton-android-runtime-52-of-130-certified-2026)).
|
||||||
So we keep our own, in a private Lakebed database
|
So Frame Control keeps its own. Your reports are saved on your Mac; the
|
||||||
(`https://frame-compat.lakebed.app`) that only Frame Control can read or write.
|
maintainer's copy also syncs them to a private Lakebed database.
|
||||||
**Test** records whether the app stays up in its own instance, and **Report**
|
**Test** records whether the app stays up in its own instance, and **Report**
|
||||||
(for any APK, F-Droid or not) records whether it worked, how it was run, where it came from, and notes, each with the SteamOS and Lepton build ids.
|
(for any APK, F-Droid or not) records whether it worked, how it was run, where it came from, and notes, each with the SteamOS and Lepton build ids.
|
||||||
A daily job backs it up locally and to Google Drive. See
|
See
|
||||||
[compat-db/README.md](../compat-db/README.md) and
|
[compat-db/README.md](../compat-db/README.md) and
|
||||||
[apk-catalog/README.md](../apk-catalog/README.md).
|
[apk-catalog/README.md](../apk-catalog/README.md).
|
||||||
|
|
||||||
@@ -188,13 +189,12 @@ But pasta runs with `--map-gw`, so the **gateway address inside Lepton
|
|||||||
|
|
||||||
T3 Code v2 on the Mac listens only on `127.0.0.1:3873`. To reach it:
|
T3 Code v2 on the Mac listens only on `127.0.0.1:3873`. To reach it:
|
||||||
|
|
||||||
1. The LaunchAgent `~/Library/LaunchAgents/frame-t3-tunnel.plist`
|
1. Keep `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running on the Mac,
|
||||||
keeps `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running. launchd
|
for example from a LaunchAgent with `KeepAlive`, so launchd restarts it if
|
||||||
restarts it if it drops. Log: `~/Library/Logs/frame-t3-tunnel.log`.
|
it drops.
|
||||||
2. In the app on the Frame, the environment host is `192.168.1.1:3873`.
|
2. In the app on the Frame, the environment host is `192.168.1.1:3873`.
|
||||||
|
|
||||||
The app on the Frame was built from the v2 nightly source (fork commit
|
The app on the Frame was built from the T3 Code v2 nightly source with `expo prebuild` and `gradlew assembleRelease
|
||||||
`d0c468e3`) with `expo prebuild` and `gradlew assembleRelease
|
|
||||||
-PreactNativeArchitectures=arm64-v8a`, using Homebrew `openjdk@17` and the
|
-PreactNativeArchitectures=arm64-v8a`, using Homebrew `openjdk@17` and the
|
||||||
`android-commandlinetools` SDK. It's signed with the debug key.
|
`android-commandlinetools` SDK. It's signed with the debug key.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Frame Control in detail
|
||||||
|
|
||||||
|
What each part of the app does, how it works, and what has been checked on a
|
||||||
|
real Frame. For installing it, see the [README](../README.md#install).
|
||||||
|
|
||||||
|
As of 2026-09-25 no other desktop app manages the Frame end to end.
|
||||||
|
[Stream Frame](https://streamframe.app/) (macOS 14+, free) records and screenshots
|
||||||
|
the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is
|
||||||
|
Windows-only. Steam Link views the headset.
|
||||||
|
|
||||||
|
You can also run the same UI in a browser without the app, from a checkout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/frame-ui.sh # macOS: opens http://127.0.0.1:47810 in its own window
|
||||||
|
python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||||
|
floating panels, dashboard and controllers). Shows the left eye, like pointing
|
||||||
|
a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
|
||||||
|
is 720p video at about 30 fps: `ffmpeg` on the Frame encodes SteamVR's
|
||||||
|
headset-view device (`/dev/video99`) to H.264 over SSH, and the page decodes
|
||||||
|
it with WebCodecs. Live video is one eye; Capture still gets both. The viewer
|
||||||
|
fits the whole frame; zoom with − / + (or scroll, or double-click), drag to
|
||||||
|
pan, `0` to fit, `F` for full screen. Capture uses OpenVR's `IVRScreenshots`
|
||||||
|
API through Python `ctypes` (`ui/frame_vrshot.py`). Nothing extra is
|
||||||
|
installed on the Frame (SteamOS ships `ffmpeg`). **Desktop panel** captures
|
||||||
|
gamescope's flat layer instead.
|
||||||
|
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
|
||||||
|
save them to `~/Pictures/SteamFrame`.
|
||||||
|
- **Battery** with charging state: charge rate in watts, time to full or empty,
|
||||||
|
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
|
||||||
|
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
|
||||||
|
the desktop, Lepton and xrdp are running.
|
||||||
|
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
|
||||||
|
- **Get games**: every game you own with its Steam Frame rating (Verified,
|
||||||
|
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
|
||||||
|
with live progress. Search the Steam store with prices and Frame ratings; Buy
|
||||||
|
opens the store page in your browser, or Store on Frame opens it in the
|
||||||
|
headset. It drives the Frame's own Steam client through its DevTools port;
|
||||||
|
see [steam-games.md](steam-games.md).
|
||||||
|
- **Volume** and mute (`wpctl`).
|
||||||
|
- **Android apps**: search about 4,500 F-Droid apps rated for the Frame, install
|
||||||
|
one with a click as its own Lepton instance (it keeps its data and shows in the
|
||||||
|
Steam library), then launch, stop, test or remove it. **Report an APK** records
|
||||||
|
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
|
||||||
|
installed app). Your reports are saved on your computer and change the verdicts
|
||||||
|
you see. They aren't uploaded anywhere: the shared database is maintainer-only
|
||||||
|
for now (see [compat-db/README.md](../compat-db/README.md)). Uses the app's bundled
|
||||||
|
`adb`, or yours if you have one.
|
||||||
|
- **Android display**: pick a running Lepton instance (by the app in it) and set
|
||||||
|
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
|
||||||
|
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
|
||||||
|
(0.85–1.3×) over ADB (`wm size`, `wm density`, `font_scale`). Reset puts all
|
||||||
|
three back. Whether the settings survive the app relaunching is untested.
|
||||||
|
- **Transfer**: drag and drop files to `~/Downloads`; `.apk` files install as
|
||||||
|
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
|
||||||
|
the Steam library (Valve's Devkit Game path, with Proton or the Steam Linux
|
||||||
|
Runtime picked from the program's header), listed under **Sideloaded titles**
|
||||||
|
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
|
||||||
|
Frame clipboard.
|
||||||
|
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
||||||
|
Remmina).
|
||||||
|
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
||||||
|
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
||||||
|
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
||||||
|
asks for the sudo password over SSH.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
|
||||||
|
and shows it in its own window; the server stops when you quit the app. The
|
||||||
|
app bundles `ui/`, `scripts/`, `frame/android/`, Valve's `frame/devkit-utils/` and the rated catalogue from
|
||||||
|
`apk-catalog/`, plus a standalone Python
|
||||||
|
([python-build-standalone](https://github.com/astral-sh/python-build-standalone))
|
||||||
|
and `adb` from Google's platform-tools, so there's nothing else to install. It
|
||||||
|
also bundles curl's copy of Mozilla's CA list, because Python on Windows only
|
||||||
|
trusts root certificates already in the Windows store.
|
||||||
|
`app/build/fetch-deps.js` downloads both, pinned by SHA-256.
|
||||||
|
|
||||||
|
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
||||||
|
with a non-local `Host` header, and any `/api/` request without a custom
|
||||||
|
header, so other websites can't drive it or read captures. Everything reaches
|
||||||
|
the headset through the `frame` SSH alias. On macOS and Linux it keeps one
|
||||||
|
multiplexed SSH connection open, so status and each capture take about 0.3 s.
|
||||||
|
Windows' OpenSSH can't share a connection, so there each request connects on
|
||||||
|
its own and the app is a little slower. What differs between the three
|
||||||
|
systems lives in `ui/frame_host.py`.
|
||||||
|
|
||||||
|
Headset captures are deleted from the Frame as soon as they're copied, because
|
||||||
|
they show everything on screen, including anything private. The look follows
|
||||||
|
the Steam client: its palette, Motiva Sans (loaded from Valve's CDN), portrait
|
||||||
|
library capsules and green Play buttons.
|
||||||
|
|
||||||
|
**Verified on the Frame 2026-09-25 (macOS app):** status and charging details,
|
||||||
|
both capture modes (headset view while in use, and a blank frame in standby,
|
||||||
|
which the UI labels), clipboard, volume, file push, and input validation.
|
||||||
|
**Not yet exercised from the UI:** Launch, Flatpak install/remove, APK drop,
|
||||||
|
title sideloading (not yet run on a headset at all), and the power buttons. Each of these calls a command that was verified
|
||||||
|
separately.
|
||||||
|
|
||||||
|
## Per-platform notes
|
||||||
|
|
||||||
|
**macOS.** The app reads `PATH` from your login shell, so Homebrew's `rsync`
|
||||||
|
and `adb` are used when you launch it from Finder. Set Up Connection runs
|
||||||
|
`scripts/connect.sh` in Terminal. The log is at
|
||||||
|
`~/Library/Logs/Frame Control/server.log`. The build is ad-hoc signed and not
|
||||||
|
notarized: a downloaded copy is quarantined until you run
|
||||||
|
`xattr -dr com.apple.quarantine "/Applications/Frame Control.app"`. The first
|
||||||
|
time you use them, macOS asks to allow local network access (for SSH) and
|
||||||
|
control of Terminal (for SSH and power actions).
|
||||||
|
|
||||||
|
**Windows.** `ssh` is Windows' built-in OpenSSH client
|
||||||
|
(Settings → System → Optional features, if it's been removed). Set Up
|
||||||
|
Connection runs `ui/frame_connect.py` in a console window. Copies use `scp`
|
||||||
|
because Windows has no `rsync`. The installer isn't code-signed, so SmartScreen
|
||||||
|
warns on first run: choose **More info → Run anyway**. The log is at
|
||||||
|
`%APPDATA%\Frame Control\logs\server.log`.
|
||||||
|
|
||||||
|
**Linux.** Needs `ssh`, which most desktops have; the `.deb` pulls it in.
|
||||||
|
The arm64 build also needs your distribution's `adb` for Android apps, because
|
||||||
|
Google publishes no arm64 Linux platform-tools. Set Up Connection runs
|
||||||
|
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
|
||||||
|
and others). The log is at
|
||||||
|
`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
|
||||||
|
instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
|
||||||
|
`xclip` (X11).
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd app
|
||||||
|
npm install
|
||||||
|
npm start # run from the checkout without packaging
|
||||||
|
npm run dist # macOS: dist/*.dmg and .zip (Apple Silicon)
|
||||||
|
npm run dist:win # Windows: installer and .zip
|
||||||
|
npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to the
|
||||||
|
release (`.github/workflows/release.yml`).
|
||||||
@@ -30,12 +30,14 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
|||||||
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
|
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
|
||||||
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
|
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
|
||||||
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
|
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
|
||||||
|
| SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) |
|
||||||
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
|
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
|
||||||
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
|
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
|
||||||
|
| **Testing VR apps without wearing the headset.** In standby SteamVR keeps OpenXR sessions hidden, so they render one frame and stop. `vrcmd` (in `/opt/steamvr/bin/linuxarm64`) settings use `section.key`: `vrcmd --set-settings-bool power.pauseCompositorOnStandby 0` and `vrcmd --set-settings-float power.turnOffScreensTimeout 3600`, then `vrcmd --handlewakeup`, keep the compositor running, and the scene app becomes visible. If it stays `visible-blurred`, the Steam dashboard is open: `SteamClient.OpenVR.VROverlay.HideDashboard()` in Steam's `SharedJSContext` (CDP on 8080) closes it. The headset view then captures with `ui/frame_vrshot.py`. Restore afterwards with `--set-settings-bool power.pauseCompositorOnStandby 1` and `--set-settings-float power.turnOffScreensTimeout 5`. The bool setter reads `true` as false, so use 1/0. A Steam launch that stalls in standby at `ShowInterstitials` or `CreatingProcess` (see `console_log.txt`) continues with `SteamClient.Apps.ContinueGameAction(<action id>, "<appid>", "<task>")`. **Verified 2026-09-27.** | Proving VR output remotely, [webxr-chromium.md](webxr-chromium.md) |
|
||||||
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
|
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
|
||||||
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
|
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
|
||||||
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
|
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
|
||||||
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb`, `wayvnc`. | Script design |
|
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale` (installable in `~`, see below), `krfb`, `wayvnc`. | Script design |
|
||||||
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` |
|
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` |
|
||||||
| `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things |
|
| `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things |
|
||||||
| Clipboard: Klipper over the nested D-Bus bus (`qdbus6 org.kde.klipper …`). | `paste-to-frame.sh` |
|
| Clipboard: Klipper over the nested D-Bus bus (`qdbus6 org.kde.klipper …`). | `paste-to-frame.sh` |
|
||||||
@@ -43,9 +45,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) |
|
| 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) |
|
| 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` |
|
| 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. 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) |
|
| **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 |
|
| 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
|
## Debug recipes
|
||||||
|
|
||||||
@@ -70,5 +77,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
|
|||||||
- Files and clipboard: [file-transfer.md](file-transfer.md)
|
- Files and clipboard: [file-transfer.md](file-transfer.md)
|
||||||
- Android apps: [apks.md](apks.md)
|
- Android apps: [apks.md](apks.md)
|
||||||
- Installing and buying Steam games: [steam-games.md](steam-games.md)
|
- Installing and buying Steam games: [steam-games.md](steam-games.md)
|
||||||
|
- Remote access from anywhere: [tailscale.md](tailscale.md)
|
||||||
- Floating windows in space: [panels.md](panels.md)
|
- Floating windows in space: [panels.md](panels.md)
|
||||||
- What's still unverified: [open-questions.md](open-questions.md)
|
- What's still unverified: [open-questions.md](open-questions.md)
|
||||||
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>
|
||||||
@@ -25,7 +25,7 @@ build 20260922.6101926, kernel 6.18, aarch64):
|
|||||||
(`vrserver`, `vrcompositor`) and `xrdp` are running.
|
(`vrserver`, `vrcompositor`) and `xrdp` are running.
|
||||||
- **9.** `rsync`, `flatpak`, `python3`, `git`, `qdbus6` and `xrdp` are present.
|
- **9.** `rsync`, `flatpak`, `python3`, `git`, `qdbus6` and `xrdp` are present.
|
||||||
`wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb` and `wayvnc`
|
`wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb` and `wayvnc`
|
||||||
are **not**. `paste-to-frame.sh` now uses Klipper over D-Bus and round-trips
|
are **not** (Tailscale can be added in `~`; see [tailscale.md](tailscale.md)). `paste-to-frame.sh` now uses Klipper over D-Bus and round-trips
|
||||||
text correctly.
|
text correctly.
|
||||||
- Flathub is already configured as a **system** remote; Chromium is the only
|
- Flathub is already configured as a **system** remote; Chromium is the only
|
||||||
installed Flatpak. `/` is 10 GB (42% used); `/home` is 929 GB.
|
installed Flatpak. `/` is 10 GB (42% used); `/home` is 929 GB.
|
||||||
@@ -40,7 +40,7 @@ build 20260922.6101926, kernel 6.18, aarch64):
|
|||||||
gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were
|
gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were
|
||||||
created side by side with `panel-on-frame.sh`. See [panels.md](panels.md).
|
created side by side with `panel-on-frame.sh`. See [panels.md](panels.md).
|
||||||
|
|
||||||
Still open: 4, 6, 7, 11 (in-headset connect), 12–21.
|
Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a reboot), 17–21.
|
||||||
|
|
||||||
## Check on the headset (in order)
|
## Check on the headset (in order)
|
||||||
|
|
||||||
@@ -82,8 +82,10 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–21.
|
|||||||
to type locally.
|
to type locally.
|
||||||
15. **ADB**: does `adb shell` over USB-C from a Mac (not just a Windows PC)
|
15. **ADB**: does `adb shell` over USB-C from a Mac (not just a Windows PC)
|
||||||
reach the Linux side? Does USB power from the Mac cope?
|
reach the Linux side? Does USB power from the Mac cope?
|
||||||
16. **Tailscale**: can it be installed persistently (Flatpak? a
|
16. ~~**Tailscale**~~: answered 2026-09-25. A userspace `tailscaled` in `~`
|
||||||
userspace `tailscaled` in `~`?) for access off the home LAN?
|
runs as a lingering user service with no sudo; see [tailscale.md](tailscale.md).
|
||||||
|
Still open: reaching the Frame from outside the home network, and the service
|
||||||
|
starting after a reboot.
|
||||||
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the
|
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the
|
||||||
panels from `panel-on-frame.sh` show up, take input, and offer **Float in
|
panels from `panel-on-frame.sh` show up, take input, and offer **Float in
|
||||||
World** / **Move** / **Size**? Do floating positions survive closing and
|
World** / **Move** / **Size**? Do floating positions survive closing and
|
||||||
|
|||||||
+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
|
- **Verified on device (2026-09-25):** `avahi-daemon` is running on the Frame
|
||||||
and `frame.local` resolves from the Mac over mDNS.
|
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
|
Once you have a working address, the `Host frame` alias means you just type
|
||||||
`ssh frame`.
|
`ssh frame`.
|
||||||
- To check discovery yourself: `dns-sd -G v4 frame.local` (Ctrl-C to stop), or
|
- To check discovery yourself: `dns-sd -G v4 frame.local` (Ctrl-C to stop), or
|
||||||
@@ -55,10 +56,49 @@ Host frame
|
|||||||
HostName frame.local
|
HostName frame.local
|
||||||
User steamos
|
User steamos
|
||||||
IdentityFile ~/.ssh/id_ed25519_frame
|
IdentityFile ~/.ssh/id_ed25519_frame
|
||||||
|
IdentityFile ~/.ssh/id_rsa_frame_devkit
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
ServerAliveInterval 30
|
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
|
`~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS
|
||||||
updates (inferred from Deck; the Frame uses the same A/B image scheme).
|
updates (inferred from Deck; the Frame uses the same A/B image scheme).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Tailscale: use the Frame from anywhere
|
||||||
|
|
||||||
|
With Tailscale on the Frame, the `frame` SSH alias works off the home LAN, and
|
||||||
|
so does everything built on it: Frame Control, the scripts and the Mac app.
|
||||||
|
When the Mac and the Frame are on the same network, Tailscale connects them
|
||||||
|
directly, so there's no relay in the way (`tailscale ping frame` → `via
|
||||||
|
192.168.1.50:41641`, 20 ms).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/tailscale-on-frame.sh # install or update, then approve the login URL
|
||||||
|
scripts/connect.sh frame.<tailnet>.ts.net # point the alias at Tailscale (the script prints this)
|
||||||
|
scripts/tailscale-on-frame.sh --uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it's installed
|
||||||
|
|
||||||
|
There's no Tailscale Flatpak, and the rootfs is read-only. So the script
|
||||||
|
installs Tailscale's static arm64 build in the `steamos` user's home and runs
|
||||||
|
`tailscaled --tun=userspace-networking` as a systemd **user** service. It
|
||||||
|
doesn't need sudo, and SteamOS updates don't touch it.
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
|---|---|
|
||||||
|
| `~/.local/share/tailscale/<version>/` | `tailscale`, `tailscaled` (SHA-256 checked against pkgs.tailscale.com) |
|
||||||
|
| `~/.local/share/tailscale/current` | Symlink to the active version |
|
||||||
|
| `~/.local/share/tailscale/state/` | Node key and state |
|
||||||
|
| `~/.local/bin/tailscale` | CLI wrapper that points at the daemon's socket (`$XDG_RUNTIME_DIR/tailscale/tailscaled.sock`) |
|
||||||
|
| `~/.config/systemd/user/tailscaled.service` | The service |
|
||||||
|
|
||||||
|
Lingering (`loginctl enable-linger`) is on, so the service starts at boot
|
||||||
|
without anyone logging in. polkit allowed that without sudo. Re-running the
|
||||||
|
script is safe. It restarts `tailscaled` only if the version or unit changed,
|
||||||
|
and then does so detached after 3 s, because the SSH session may itself run
|
||||||
|
over Tailscale.
|
||||||
|
|
||||||
|
To update, run the script again; it installs the latest stable version. To
|
||||||
|
manage the node, use `ssh frame '~/.local/bin/tailscale status'` (or `set`,
|
||||||
|
`down`, `up`).
|
||||||
|
|
||||||
|
**Verified 2026-09-25 (SteamOS 0.3.0, build 20260922.6101926, Tailscale
|
||||||
|
1.102.4):**
|
||||||
|
|
||||||
|
- First install and login approval. This ran an earlier revision of the script,
|
||||||
|
which restarted the daemon unconditionally. The node is `frame`,
|
||||||
|
with a 100.x.y.z tailnet address.
|
||||||
|
- SSH works over Tailscale: the Frame serves the same ED25519 host key as it
|
||||||
|
does on `frame.local`.
|
||||||
|
- Frame Control's status and Get games work through the alias.
|
||||||
|
- The current script: a re-run with nothing changed doesn't restart anything,
|
||||||
|
and a re-run with a changed unit restarts `tailscaled` 3 s after the SSH
|
||||||
|
session ends and then reads `Running`. The timer needs
|
||||||
|
`AccuracySec=100ms`; the default of 1 min made it fire up to a minute late.
|
||||||
|
The first-install guard was checked on its own.
|
||||||
|
|
||||||
|
**Not verified:**
|
||||||
|
|
||||||
|
- A clean first install and login with the current script end to end. It would
|
||||||
|
mean removing the node from the tailnet.
|
||||||
|
- Reaching the Frame from outside the home network. Only the direct LAN path
|
||||||
|
was tested.
|
||||||
|
- The service coming up after a reboot. That's **inferred** from linger plus
|
||||||
|
`WantedBy=default.target`; the Frame hasn't been rebooted since.
|
||||||
|
|
||||||
|
## Exposure: every port is on the tailnet
|
||||||
|
|
||||||
|
In userspace mode, `tailscaled` passes inbound tailnet connections to the
|
||||||
|
Frame's **loopback**. Any device on the tailnet can therefore reach **every**
|
||||||
|
listening port, including ones meant to be local-only. Checked from the Mac on
|
||||||
|
2026-09-25:
|
||||||
|
|
||||||
|
| Port | Service | Normally |
|
||||||
|
|---|---|---|
|
||||||
|
| 22 | sshd | LAN |
|
||||||
|
| 8080 | Steam client DevTools (full control of the Steam client and account session) | loopback only |
|
||||||
|
| 27062 | SteamVR `vrserver` | loopback only |
|
||||||
|
| 5555 | Lepton ADB (unauthenticated shell into Android) | LAN |
|
||||||
|
| 3389 | xrdp | LAN |
|
||||||
|
|
||||||
|
The user accepted this on 2026-09-25, since the tailnet only holds their own
|
||||||
|
devices. Other options:
|
||||||
|
|
||||||
|
- `tailscale set --shields-up` blocks **all** inbound connections. That
|
||||||
|
includes SSH and Tailscale SSH (`--ssh`), both checked.
|
||||||
|
- A tailnet policy that tags the Frame (`tag:frame`) and allows only
|
||||||
|
`tag:frame:22` keeps the other ports private. This is an admin-console
|
||||||
|
change.
|
||||||
|
- Kernel-mode Tailscale (a root install, e.g. systemd-sysext) wouldn't expose
|
||||||
|
loopback-only ports, but it needs sudo and may not survive SteamOS updates.
|
||||||
|
|
||||||
|
If the Mac's Tailscale is off, the alias won't resolve. Use
|
||||||
|
`scripts/connect.sh frame.local` to go back to the LAN name.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# WebXR in Chromium on the Frame
|
||||||
|
|
||||||
|
Goal: open a web VR180 or 360 player (DeoVR and DL8 embeds, WebXR samples),
|
||||||
|
press its VR button, and watch in 3D in the headset.
|
||||||
|
|
||||||
|
The build and installer now live in their own public repo,
|
||||||
|
[saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame):
|
||||||
|
a build script for an x86-64 Linux host, the SO_PEERCRED patch, and a
|
||||||
|
Frame-side installer that adds "Chromium XR" to the Steam library. This page
|
||||||
|
keeps the findings and what was verified on this Frame.
|
||||||
|
|
||||||
|
## Why Flathub Chromium can't
|
||||||
|
|
||||||
|
**Verified 2026-09-25** (Frame BUILD_ID 20260922.6101926, Flathub
|
||||||
|
`org.chromium.Chromium` 154.0.8037.57 aarch64):
|
||||||
|
|
||||||
|
- `navigator.xr` exists, but `isSessionSupported("immersive-vr")` is `false`.
|
||||||
|
Flags don't change that, and neither does `--force-webxr-runtime=openxr`
|
||||||
|
with SteamVR exposed to the Flatpak.
|
||||||
|
- The binary has no OpenXR loader: no `XR_RUNTIME_JSON`,
|
||||||
|
`xrGetInstanceProcAddr` or `XR_LOADER_DEBUG` strings. The only OpenXR
|
||||||
|
strings are the `chrome://flags` entries.
|
||||||
|
|
||||||
|
**Cause (verified against Chromium source, same date).** M154 is the first
|
||||||
|
release that compiles OpenXR on Linux:
|
||||||
|
|
||||||
|
- `device/vr/buildflags/buildflags.gni` adds `is_linux` to `enable_openxr`.
|
||||||
|
- Flathub's tarball sets `checkout_openxr = true`.
|
||||||
|
- Flathub's GN args don't turn it off.
|
||||||
|
|
||||||
|
But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates
|
||||||
|
the OpenXR device under `ENABLE_OPENXR && IS_WIN`. That's true on 154, 155 and
|
||||||
|
`main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The
|
||||||
|
rest of the Linux port is in two unmerged CLs (bug 506004811):
|
||||||
|
|
||||||
|
- [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736)
|
||||||
|
runs the XR device service in a Linux sandbox that allows SteamVR's
|
||||||
|
sockets, `/dev/shm` and `flock`.
|
||||||
|
- [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979)
|
||||||
|
wires the provider to `OpenXrPlatformHelperLinux`. `kOpenXR` stays off by
|
||||||
|
default, so it needs `--enable-features=OpenXR`.
|
||||||
|
|
||||||
|
8132979 sits on top of 8441736, so fetching `refs/changes/79/8132979/<ps>`
|
||||||
|
gets both.
|
||||||
|
|
||||||
|
**The Frame side is ready.** `~/.config/openxr/1/active_runtime.json` names
|
||||||
|
SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The
|
||||||
|
Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`).
|
||||||
|
|
||||||
|
## Building and installing it
|
||||||
|
|
||||||
|
Follow the [public repo's README](https://github.com/saphid/chromium-webxr-steam-frame#build).
|
||||||
|
In short: `build/build.sh` on an x64 Linux host (no sudo, about 90 GB of
|
||||||
|
disk) produces `chromium-xr-arm64.tar.xz` (about 145 MB), and
|
||||||
|
`frame/install.sh` on the Frame unpacks it to `~/chromium-xr`, installs the
|
||||||
|
`chromium-xr` launcher in `~/.local/bin`, and adds the Steam library shortcut
|
||||||
|
through the Steam client's DevTools port, the same way as T3 Code
|
||||||
|
([apks.md](apks.md)). Launching the shortcut gives Chromium its own panel,
|
||||||
|
`valve.steam.desktopgame.<appid>`, like any other app.
|
||||||
|
|
||||||
|
First build, 2026-09-25, on a 12-thread, 31 GB x64 Linux box: 9 h 33 min for
|
||||||
|
94,835 steps, giving Chromium 156.0.8071.0. A rebuild after a one-file change
|
||||||
|
takes under a minute, plus about 4 minutes to repack.
|
||||||
|
|
||||||
|
To debug from the Mac, launch it as a panel with DevTools on the Frame
|
||||||
|
(verified 2026-09-27):
|
||||||
|
`scripts/panel-on-frame.sh -- '~/.local/bin/chromium-xr' --remote-debugging-port=9223 URL`
|
||||||
|
([panels.md](panels.md)). DevTools has no authentication. It listens on
|
||||||
|
loopback, but with the userspace Tailscale from [tailscale.md](tailscale.md)
|
||||||
|
running, loopback ports are reachable from your tailnet. Close the browser
|
||||||
|
when you're done. Chromium runs one browser per profile, so close the
|
||||||
|
Steam-launched one first or the flag is ignored.
|
||||||
|
|
||||||
|
**The SO_PEERCRED fix.** The XR seccomp policy refuses `getsockopt`. SteamVR's
|
||||||
|
client calls `getsockopt(SOL_SOCKET, SO_PEERCRED)` inside `xrCreateInstance`,
|
||||||
|
so the XR process died with a seccomp crash (arm64 syscall 209). The patch
|
||||||
|
allows that one option. It's needed but not enough: the launcher still turns
|
||||||
|
seccomp off (below), so the patch only matters once that's fixed too.
|
||||||
|
|
||||||
|
**Seccomp is off.** The launcher passes `--disable-seccomp-filter-sandbox`.
|
||||||
|
With the XR seccomp policy on, SteamVR's client reads `/proc/self/status`
|
||||||
|
through Chrome's file broker and gets the broker's pid. SteamVR then binds
|
||||||
|
the app to the wrong process ("Unable to init path manager:
|
||||||
|
VRInitError_Init_Internal") and `xrCreateInstance` fails. The broker can't
|
||||||
|
answer `/proc/self` for another process, so fixing this needs a change in
|
||||||
|
Chromium's broker client or in the CL. The namespace sandbox stays on, but
|
||||||
|
seccomp is off for every process, so use this profile for VR sites rather
|
||||||
|
than everyday browsing.
|
||||||
|
|
||||||
|
**Upstream (2026-09-27).** CL 8441736 (the XR sandbox) has merged into
|
||||||
|
Chromium, still refusing `getsockopt`; CL 8132979 is still in review. Valve
|
||||||
|
and the CLs' author are working on Steam Frame support
|
||||||
|
([utzcoz/chromium-webxr-linux#5](https://github.com/utzcoz/chromium-webxr-linux/issues/5)).
|
||||||
|
Both sandbox problems above, with the patch, are reported in
|
||||||
|
[utzcoz/chromium-webxr-linux#7](https://github.com/utzcoz/chromium-webxr-linux/issues/7).
|
||||||
|
|
||||||
|
**Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10,
|
||||||
|
this build):
|
||||||
|
|
||||||
|
- `isSessionSupported('immersive-vr')` is `true`. The WebXR samples page
|
||||||
|
shows "VR support detected".
|
||||||
|
- `requestSession('immersive-vr')` succeeds after the prompt. With a WebGL
|
||||||
|
layer, the first XR frame has a viewer pose with 2 views and a
|
||||||
|
2880 × 1440 framebuffer (1440 × 1440 per eye).
|
||||||
|
- SteamVR moves the app from `VRApplication_OpenXRInstance` to
|
||||||
|
`VRApplication_OpenXRScene` and gives it scene focus. `xrEndFrame` submits
|
||||||
|
both projection views, and the compositor receives the 2880 × 1440 scene.
|
||||||
|
- The OpenXR runtime uses Vulkan (`XR_KHR_vulkan_enable2`). Chromium's own GPU
|
||||||
|
process uses ANGLE on GL, running on zink over Turnip Vulkan (Adreno 750);
|
||||||
|
Chromium's Vulkan backend is off. That doesn't stop the session.
|
||||||
|
- Unprivileged user namespaces work (`unshare -Ur true`), so the namespace
|
||||||
|
sandbox runs without the setuid `chrome_sandbox`.
|
||||||
|
- **With the headset on** (same day, seccomp sandbox off): the WebXR
|
||||||
|
samples' Immersive VR Session showed its scene in the headset, and SteamVR
|
||||||
|
loaded the Frame controller bindings for the app. The three.js
|
||||||
|
[`webxr_vr_video`](https://threejs.org/examples/webxr_vr_video.html) demo,
|
||||||
|
a stereo 360 video, played in 3D after pressing Enter VR.
|
||||||
|
|
||||||
|
- **Launched from the Steam library, verified remotely 2026-09-27** with
|
||||||
|
nobody wearing the headset (standby workaround in
|
||||||
|
[how-the-frame-works.md](how-the-frame-works.md)). The installer's Steam
|
||||||
|
shortcut starts Chromium, and SteamVR takes it as scene app
|
||||||
|
`steam.app.<shortcut id>`. A minimal WebXR session that clears every frame
|
||||||
|
to red ran at about 75 frames per second, and the stereo headset capture
|
||||||
|
showed both eyes solid red. Steam preloads its overlay
|
||||||
|
(`gameoverlayrenderer.so`), which crashed Chromium's zygote about 30 s after
|
||||||
|
a Steam launch. The public repo's launcher now removes it from
|
||||||
|
`LD_PRELOAD`. With the headset outside its playspace, SteamVR shows
|
||||||
|
passthrough wherever the page leaves transparent pixels.
|
||||||
|
|
||||||
|
- **Frame rate and input, measured 2026-09-27** (standby workaround, red
|
||||||
|
test session): 72 fps with every frame at 13.9–14 ms over 16 s, and SteamVR
|
||||||
|
dropped frames only at startup. The right controller showed up as an
|
||||||
|
`oculus-touch` `tracked-pointer` with an `xr-standard` gamepad and a
|
||||||
|
25-joint hand, with poses on every frame. A real squeeze reached the page
|
||||||
|
as `squeezestart`/`squeeze`. Haptics aren't exposed (no actuators).
|
||||||
|
Details are in the public repo's technical notes.
|
||||||
|
|
||||||
|
**Not verified yet:** trigger, thumbstick and face buttons, the left
|
||||||
|
controller, bare-hand tracking, and third-party VR180 players (DeoVR and
|
||||||
|
DL8 web embeds).
|
||||||
@@ -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
|
||||||
|
))
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
#
|
#
|
||||||
# Exports every report through the app's own key (ui/frame_compat_db.py), keeps
|
# Exports every report through the app's own key (ui/frame_compat_db.py), keeps
|
||||||
# dated copies in ~/Library/Application Support/Frame Control/compat-db/backups (newest
|
# dated copies in ~/Library/Application Support/Frame Control/compat-db/backups (newest
|
||||||
# 60), and uploads to Google Drive (the backup folder) when
|
# 60), and uploads to the Google Drive folder DRIVE_FOLDER_ID when the data
|
||||||
# the data changed since the last upload. Run daily by the LaunchAgent
|
# changed since the last upload. Maintainer-only: it needs the database key.
|
||||||
# frame-compat-backup (see docs/apks.md).
|
# Run it daily from a LaunchAgent (see compat-db/README.md).
|
||||||
#
|
#
|
||||||
# Usage: scripts/compat-db-backup.sh [--no-upload] [--force-upload] [--accept-shrink]
|
# Usage: scripts/compat-db-backup.sh [--no-upload] [--force-upload] [--accept-shrink]
|
||||||
# Env: DRIVE_FOLDER_ID, GOG_WRAPPER
|
# Env: DRIVE_FOLDER_ID, GOG_WRAPPER
|
||||||
@@ -14,8 +14,8 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT="${0:A:h}/.."
|
ROOT="${0:A:h}/.."
|
||||||
DEST="$HOME/Library/Application Support/Frame Control/compat-db/backups"
|
DEST="$HOME/Library/Application Support/Frame Control/compat-db/backups"
|
||||||
DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-<drive-folder-id>}
|
DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-}
|
||||||
GOG_WRAPPER=${GOG_WRAPPER:-$HOME/bin/gog-with-keyring.sh}
|
GOG_WRAPPER=${GOG_WRAPPER:-$(command -v gog || true)}
|
||||||
upload=1 force=0 accept_shrink=0
|
upload=1 force=0 accept_shrink=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
@@ -60,9 +60,10 @@ if (( upload )); then
|
|||||||
print "==> Unchanged since the last Drive upload; skipped"
|
print "==> Unchanged since the last Drive upload; skipped"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
[[ -x "$GOG_WRAPPER" ]] || { print -u2 "gog wrapper not found at $GOG_WRAPPER"; exit 1; }
|
[[ -n "$DRIVE_FOLDER_ID" ]] || { print -u2 "Set DRIVE_FOLDER_ID, or pass --no-upload"; exit 1; }
|
||||||
|
[[ -n "$GOG_WRAPPER" && -x "$GOG_WRAPPER" ]] || { print -u2 "gog not found; install it or set GOG_WRAPPER"; exit 1; }
|
||||||
"$GOG_WRAPPER" drive upload "$out" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
|
"$GOG_WRAPPER" drive upload "$out" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
|
||||||
"$GOG_WRAPPER" drive upload "$out.sha256" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
|
"$GOG_WRAPPER" drive upload "$out.sha256" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
|
||||||
print -r -- "$digest" > "$last"
|
print -r -- "$digest" > "$last"
|
||||||
print "==> Uploaded to Google Drive (the backup folder)"
|
print "==> Uploaded to Google Drive"
|
||||||
fi
|
fi
|
||||||
+183
-42
@@ -1,8 +1,10 @@
|
|||||||
#!/usr/bin/env zsh
|
#!/usr/bin/env zsh
|
||||||
# Mac-side: find the Steam Frame, create a key, add a `Host frame` alias to
|
# Mac-side: find the Steam Frame, create keys, add a `Host frame` alias to
|
||||||
# ~/.ssh/config, copy the key, and optionally disable SSH password logins.
|
# ~/.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:
|
# Usage:
|
||||||
# scripts/connect.sh [HOST_OR_IP] # set up key + alias
|
# scripts/connect.sh [HOST_OR_IP] # set up key + alias
|
||||||
@@ -11,93 +13,232 @@
|
|||||||
# Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame).
|
# Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
user_from_env=${+FRAME_USER}
|
||||||
FRAME_USER=${FRAME_USER:-steamos}
|
FRAME_USER=${FRAME_USER:-steamos}
|
||||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||||
KEY="$HOME/.ssh/id_ed25519_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"
|
CONFIG="$HOME/.ssh/config"
|
||||||
BEGIN_MARK="# >>> steam-frame ($FRAME_ALIAS) >>>"
|
BEGIN_MARK="# >>> steam-frame ($FRAME_ALIAS) >>>"
|
||||||
END_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
|
harden=0
|
||||||
host_arg=""
|
host_arg=""
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--harden) harden=1 ;;
|
--harden) harden=1 ;;
|
||||||
-h|--help) sed -n '2,11p' "$0"; exit 0 ;;
|
-h|--help) sed -n '2,13p' "$0"; exit 0 ;;
|
||||||
*) host_arg="$arg" ;;
|
*) host_arg="$arg" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
port_open() {
|
port_open() {
|
||||||
# nc resolves through the system resolver (including mDNS for .local).
|
# 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() {
|
pick_host() {
|
||||||
local candidates=()
|
local candidates=()
|
||||||
[[ -n "$host_arg" ]] && candidates+=("$host_arg")
|
[[ -n "$host_arg" ]] && candidates+=("$host_arg")
|
||||||
candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
|
[[ -z "$host_arg" ]] && candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
|
||||||
local h
|
local h
|
||||||
for h in "${candidates[@]}"; do
|
for h in "${candidates[@]}"; do
|
||||||
if port_open "$h"; then
|
if reachable "$h"; then
|
||||||
print -r -- "$h"; return 0
|
print -r -- "$h"; return 0
|
||||||
fi
|
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
|
done
|
||||||
return 1
|
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"
|
print "==> Looking for the Steam Frame"
|
||||||
if ! HOST=$(pick_host); then
|
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 "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"
|
print -u2 "Then re-run with the IP from Quick Settings: scripts/connect.sh 192.168.x.y"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
print " found: $HOST"
|
print " found: $HOST"
|
||||||
|
|
||||||
print "==> SSH key"
|
print "==> SSH keys"
|
||||||
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
|
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
|
||||||
if [[ ! -f "$KEY" ]]; then
|
make_key "$KEY" ed25519 "mac->steam-frame"
|
||||||
ssh-keygen -q -t ed25519 -N '' -C "mac->steam-frame" -f "$KEY"
|
make_key "$DEVKIT_KEY" rsa "frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')" -b 3072
|
||||||
print " created $KEY"
|
|
||||||
else
|
|
||||||
print " exists: $KEY"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
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"
|
print "==> ~/.ssh/config alias '$FRAME_ALIAS' -> $HOST"
|
||||||
touch "$CONFIG" && chmod 600 "$CONFIG"
|
write_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"
|
|
||||||
|
|
||||||
print "==> Checking key login"
|
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"
|
print " key login already works"
|
||||||
|
elif pair_with_devkit; then
|
||||||
|
print " paired; key login OK"
|
||||||
else
|
else
|
||||||
|
print " $devkit_why; falling back to the password"
|
||||||
print " copying key (enter the Developer Mode password once)"
|
print " copying key (enter the Developer Mode password once)"
|
||||||
ssh-copy-id -i "$KEY.pub" -o IdentitiesOnly=yes "$FRAME_USER@$HOST"
|
ssh-copy-id -i "$KEY.pub" -o IdentitiesOnly=yes "$FRAME_USER@$HOST"
|
||||||
ssh -o BatchMode=yes -o ConnectTimeout=5 "$FRAME_ALIAS" true \
|
key_login_works || { print -u2 "Key login still failing after ssh-copy-id."; exit 1; }
|
||||||
|| { print -u2 "Key login still failing after ssh-copy-id."; exit 1; }
|
|
||||||
print " key login OK"
|
print " key login OK"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ PREFIX_VIDEOS=".local/share/Steam/steamapps/compatdata/$DEOVR_APPID/pfx/drive_c/
|
|||||||
launch=0 list=0
|
launch=0 list=0
|
||||||
while (( $# )); do
|
while (( $# )); do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
|
-h|--help) sed -n '2,17p' "$0"; exit 0 ;;
|
||||||
--launch) launch=1; shift ;;
|
--launch) launch=1; shift ;;
|
||||||
--list) list=1; shift ;;
|
--list) list=1; shift ;;
|
||||||
--) shift; break ;;
|
--) shift; break ;;
|
||||||
@@ -33,21 +33,25 @@ while (( $# )); do
|
|||||||
*) break ;;
|
*) break ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
(( $# || launch || list )) || { sed -n '2,18p' "$0"; exit 2; }
|
(( $# || launch || list )) || { sed -n '2,17p' "$0" >&2; exit 2; }
|
||||||
|
|
||||||
for f in "$@"; do
|
for f in "$@"; do
|
||||||
[[ -e "$f" ]] || { print -u2 "push-vr-video: no such file: $f"; exit 2; }
|
[[ -e "$f" ]] || { print -u2 "push-vr-video: no such file: $f"; exit 2; }
|
||||||
done
|
done
|
||||||
|
|
||||||
# Create the folder and link it into DeoVR's prefix (the prefix exists once
|
# Create the folder and link it into DeoVR's prefix (the prefix exists once
|
||||||
# DeoVR has run). Never replace a real directory that's already there.
|
# DeoVR has run). Refresh a stale link, but never replace a real directory.
|
||||||
ssh "$FRAME_ALIAS" "mkdir -p ~/$REMOTE_DIR
|
if (( $# || launch )); then
|
||||||
|
ssh "$FRAME_ALIAS" "mkdir -p ~/$REMOTE_DIR
|
||||||
p=~/$PREFIX_VIDEOS
|
p=~/$PREFIX_VIDEOS
|
||||||
if [ -d \"\$p\" ] && [ ! -e \"\$p/VR\" ]; then ln -s ~/$REMOTE_DIR \"\$p/VR\"; fi
|
if [ -d \"\$p\" ] && { [ -L \"\$p/VR\" ] || [ ! -e \"\$p/VR\" ]; }; then ln -sfn ~/$REMOTE_DIR \"\$p/VR\"
|
||||||
|
elif [ -d \"\$p/VR\" ]; then echo \"warning: \$p/VR is a real folder, so uploads won't show under DeoVR's Videos; browse Z:\\\\home\\\\steamos\\\\Videos\\\\VR instead\" >&2; fi
|
||||||
[ -d \"\$p\" ] || echo 'note: DeoVR has not run yet; use Z:\\home\\steamos\\Videos\\VR or run this again after starting it once' >&2"
|
[ -d \"\$p\" ] || echo 'note: DeoVR has not run yet; use Z:\\home\\steamos\\Videos\\VR or run this again after starting it once' >&2"
|
||||||
|
fi
|
||||||
|
|
||||||
if (( $# )); then
|
if (( $# )); then
|
||||||
rsync -a --partial --progress "$@" "$FRAME_ALIAS:$REMOTE_DIR/"
|
# -L: send what a symlink points at; a Mac-side link would dangle on the Frame
|
||||||
|
rsync -aL --partial --progress -- "$@" "$FRAME_ALIAS:$REMOTE_DIR/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if (( list )); then
|
if (( list )); then
|
||||||
@@ -55,6 +59,7 @@ if (( list )); then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if (( launch )); then
|
if (( launch )); then
|
||||||
ssh "$FRAME_ALIAS" "steam steam://rungameid/$DEOVR_APPID >/dev/null 2>&1 &"
|
ssh "$FRAME_ALIAS" "command -v steam >/dev/null || { echo 'steam not found on the Frame' >&2; exit 1; }
|
||||||
|
steam steam://rungameid/$DEOVR_APPID </dev/null >/dev/null 2>&1 &"
|
||||||
print "DeoVR starting on the Frame. Open Local files / the file browser → Videos → VR."
|
print "DeoVR starting on the Frame. Open Local files / the file browser → Videos → VR."
|
||||||
fi
|
fi
|
||||||
@@ -10,6 +10,11 @@
|
|||||||
# ~/.config/systemd/user/tailscaled.service
|
# ~/.config/systemd/user/tailscaled.service
|
||||||
# `tailscaled --tun=userspace-networking` needs no /dev/net/tun or root.
|
# `tailscaled --tun=userspace-networking` needs no /dev/net/tun or root.
|
||||||
#
|
#
|
||||||
|
# Exposure: in userspace mode tailscaled forwards inbound tailnet connections
|
||||||
|
# to the Frame's loopback, so EVERY port is reachable from the tailnet,
|
||||||
|
# including localhost-only ones (Steam's DevTools on 8080, SteamVR, ADB).
|
||||||
|
# `tailscale set --shields-up` blocks all inbound (SSH too). See docs/tailscale.md.
|
||||||
|
#
|
||||||
# Usage: scripts/tailscale-on-frame.sh [--version X.Y.Z] [--hostname NAME]
|
# Usage: scripts/tailscale-on-frame.sh [--version X.Y.Z] [--hostname NAME]
|
||||||
# scripts/tailscale-on-frame.sh --uninstall
|
# scripts/tailscale-on-frame.sh --uninstall
|
||||||
# The first run prints a login URL (and opens it on the Mac) to add the Frame
|
# The first run prints a login URL (and opens it on the Mac) to add the Frame
|
||||||
@@ -20,10 +25,10 @@ FRAME=${FRAME_ALIAS:-frame}
|
|||||||
version="" hostname="frame" uninstall=0
|
version="" hostname="frame" uninstall=0
|
||||||
while (( $# )); do
|
while (( $# )); do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--version) version=$2; shift ;;
|
--version) version=${2:?--version needs a value}; shift ;;
|
||||||
--hostname) hostname=$2; shift ;;
|
--hostname) hostname=${2:?--hostname needs a value}; shift ;;
|
||||||
--uninstall) uninstall=1 ;;
|
--uninstall) uninstall=1 ;;
|
||||||
-h|--help) sed -n '2,17p' "$0"; exit 0 ;;
|
-h|--help) sed -n "2,21p" "$0"; exit 0 ;;
|
||||||
*) print -u2 "unknown argument: $1"; exit 2 ;;
|
*) print -u2 "unknown argument: $1"; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
@@ -33,13 +38,21 @@ done
|
|||||||
if (( uninstall )); then
|
if (( uninstall )); then
|
||||||
ssh "$FRAME" 'set -e
|
ssh "$FRAME" 'set -e
|
||||||
systemctl --user disable --now tailscaled.service 2>/dev/null || true
|
systemctl --user disable --now tailscaled.service 2>/dev/null || true
|
||||||
rm -f ~/.config/systemd/user/tailscaled.service ~/.local/bin/tailscale ~/.local/bin/tailscaled
|
rm -f ~/.config/systemd/user/tailscaled.service ~/.local/bin/tailscale
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
echo "Removed the service and wrappers. Binaries and node state are still in ~/.local/share/tailscale;"
|
echo "Removed the service and the CLI wrapper. Binaries and node state are still in"
|
||||||
echo "delete that folder and remove the machine in the Tailscale admin console to finish."'
|
echo "~/.local/share/tailscale; delete that folder and remove the machine in the"
|
||||||
|
echo "Tailscale admin console to finish. Linger stays on (loginctl disable-linger to undo)."'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# BackendState of the Frame's tailscaled (Running, NeedsLogin, Stopped, …), or
|
||||||
|
# Unreachable when the probe itself fails (SSH down, daemon restarting).
|
||||||
|
ts_state() {
|
||||||
|
ssh -o ConnectTimeout=10 "$FRAME" '~/.local/bin/tailscale status --json 2>/dev/null |
|
||||||
|
python3 -c "import json,sys; print(json.load(sys.stdin)[\"BackendState\"])"' 2>/dev/null || print Unreachable
|
||||||
|
}
|
||||||
|
|
||||||
if [[ -z $version ]]; then
|
if [[ -z $version ]]; then
|
||||||
version=$(curl -fsS "https://pkgs.tailscale.com/stable/?mode=json" |
|
version=$(curl -fsS "https://pkgs.tailscale.com/stable/?mode=json" |
|
||||||
python3 -c 'import json,sys; print(json.load(sys.stdin)["TarballsVersion"])')
|
python3 -c 'import json,sys; print(json.load(sys.stdin)["TarballsVersion"])')
|
||||||
@@ -47,11 +60,13 @@ fi
|
|||||||
[[ $version =~ '^[0-9]+\.[0-9]+\.[0-9]+$' ]] || { print -u2 "bad version: $version"; exit 2; }
|
[[ $version =~ '^[0-9]+\.[0-9]+\.[0-9]+$' ]] || { print -u2 "bad version: $version"; exit 2; }
|
||||||
print "==> Installing Tailscale $version on $FRAME (userspace networking)"
|
print "==> Installing Tailscale $version on $FRAME (userspace networking)"
|
||||||
|
|
||||||
ssh "$FRAME" "VERSION=$version HOSTNAME_TS=$hostname sh -s" <<'REMOTE'
|
remote_out=$(ssh "$FRAME" "VERSION=$version sh -s" <<'REMOTE'
|
||||||
set -eu
|
set -eu
|
||||||
base="$HOME/.local/share/tailscale"
|
base="$HOME/.local/share/tailscale"
|
||||||
dir="$base/$VERSION"
|
dir="$base/$VERSION"
|
||||||
tgz="tailscale_${VERSION}_arm64.tgz"
|
tgz="tailscale_${VERSION}_arm64.tgz"
|
||||||
|
unit="$HOME/.config/systemd/user/tailscaled.service"
|
||||||
|
sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/tailscale/tailscaled.sock"
|
||||||
mkdir -p "$base/state" "$HOME/.local/bin" "$HOME/.config/systemd/user"
|
mkdir -p "$base/state" "$HOME/.local/bin" "$HOME/.config/systemd/user"
|
||||||
|
|
||||||
if [ ! -x "$dir/tailscaled" ]; then
|
if [ ! -x "$dir/tailscaled" ]; then
|
||||||
@@ -59,12 +74,15 @@ if [ ! -x "$dir/tailscaled" ]; then
|
|||||||
trap 'rm -rf "$tmp"' EXIT
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
curl -fsSL -o "$tmp/$tgz" "https://pkgs.tailscale.com/stable/$tgz"
|
curl -fsSL -o "$tmp/$tgz" "https://pkgs.tailscale.com/stable/$tgz"
|
||||||
want=$(curl -fsSL "https://pkgs.tailscale.com/stable/$tgz.sha256" | cut -d' ' -f1)
|
want=$(curl -fsSL "https://pkgs.tailscale.com/stable/$tgz.sha256" | cut -d' ' -f1)
|
||||||
|
[ -n "$want" ] || { echo "couldn't fetch $tgz.sha256" >&2; exit 1; }
|
||||||
got=$(sha256sum "$tmp/$tgz" | cut -d' ' -f1)
|
got=$(sha256sum "$tmp/$tgz" | cut -d' ' -f1)
|
||||||
[ "$want" = "$got" ] || { echo "checksum mismatch for $tgz" >&2; exit 1; }
|
[ "$want" = "$got" ] || { echo "checksum mismatch for $tgz" >&2; exit 1; }
|
||||||
tar -xzf "$tmp/$tgz" -C "$tmp"
|
tar -xzf "$tmp/$tgz" -C "$tmp"
|
||||||
mkdir -p "$dir"
|
mkdir -p "$dir"
|
||||||
mv "$tmp/tailscale_${VERSION}_arm64/tailscale" "$tmp/tailscale_${VERSION}_arm64/tailscaled" "$dir/"
|
mv "$tmp/tailscale_${VERSION}_arm64/tailscale" "$tmp/tailscale_${VERSION}_arm64/tailscaled" "$dir/"
|
||||||
fi
|
fi
|
||||||
|
# Neither exists on a first install; don't let that trip set -e.
|
||||||
|
before=$({ readlink "$base/current"; cat "$unit"; } 2>/dev/null || true)
|
||||||
ln -sfn "$dir" "$base/current"
|
ln -sfn "$dir" "$base/current"
|
||||||
|
|
||||||
# The CLI looks for the daemon at /var/run/tailscale by default; point it at ours.
|
# The CLI looks for the daemon at /var/run/tailscale by default; point it at ours.
|
||||||
@@ -74,7 +92,7 @@ exec "$HOME/.local/share/tailscale/current/tailscale" --socket="${XDG_RUNTIME_DI
|
|||||||
EOF
|
EOF
|
||||||
chmod +x "$HOME/.local/bin/tailscale"
|
chmod +x "$HOME/.local/bin/tailscale"
|
||||||
|
|
||||||
cat > "$HOME/.config/systemd/user/tailscaled.service" <<'EOF'
|
cat > "$unit" <<'EOF'
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Tailscale (userspace networking, no root)
|
Description=Tailscale (userspace networking, no root)
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
@@ -88,39 +106,74 @@ RestartSec=5
|
|||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
EOF
|
EOF
|
||||||
|
# Linger starts user services at boot, before anyone logs in; polkit allows it without sudo.
|
||||||
|
loginctl enable-linger 2>/dev/null || echo "note: couldn't enable linger; tailscaled starts when the session does" >&2
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable tailscaled.service >/dev/null 2>&1
|
systemctl --user enable tailscaled.service >/dev/null 2>&1
|
||||||
systemctl --user restart tailscaled.service
|
after=$(readlink "$base/current"; cat "$unit")
|
||||||
|
restart=0
|
||||||
|
if systemctl --user is-active --quiet tailscaled.service; then
|
||||||
|
[ "$before" = "$after" ] || restart=1
|
||||||
|
else
|
||||||
|
systemctl --user start tailscaled.service
|
||||||
|
fi
|
||||||
for i in $(seq 1 50); do
|
for i in $(seq 1 50); do
|
||||||
[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/tailscale/tailscaled.sock" ] && break
|
[ -S "$sock" ] && break
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
done
|
done
|
||||||
"$HOME/.local/bin/tailscale" version | head -n 1
|
[ -S "$sock" ] || { echo "tailscaled didn't open $sock; see: journalctl --user -u tailscaled" >&2; exit 1; }
|
||||||
|
v=$("$HOME/.local/bin/tailscale" version)
|
||||||
|
printf 'Tailscale %s\n' "$(printf '%s\n' "$v" | head -n 1)"
|
||||||
|
if [ "$restart" = 1 ]; then
|
||||||
|
# This SSH session may itself run over Tailscale, so restart detached, after
|
||||||
|
# it has ended; the Mac waits and reconnects.
|
||||||
|
systemd-run --user --quiet --on-active=3 --timer-property=AccuracySec=100ms --unit=tailscaled-restart --collect \
|
||||||
|
systemctl --user restart tailscaled.service >/dev/null
|
||||||
|
echo "RESTART_SCHEDULED"
|
||||||
|
fi
|
||||||
REMOTE
|
REMOTE
|
||||||
|
)
|
||||||
|
print -r -- "${remote_out//RESTART_SCHEDULED/Restarting tailscaled for the new version or unit…}"
|
||||||
|
|
||||||
# `up` blocks until the login is approved, so run it in the background on the
|
# Wait out a scheduled restart, then read a definite state.
|
||||||
# Frame and fetch the URL from its log.
|
[[ $remote_out == *RESTART_SCHEDULED* ]] && sleep 6
|
||||||
state=$(ssh "$FRAME" '~/.local/bin/tailscale status --json 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin)[\"BackendState\"])" 2>/dev/null || echo Unknown')
|
state=""
|
||||||
if [[ $state != Running ]]; then
|
for i in {1..30}; do
|
||||||
ssh "$FRAME" "nohup ~/.local/bin/tailscale up --hostname=$hostname --timeout=10m > /tmp/tailscale-up.log 2>&1 &"
|
state=$(ts_state)
|
||||||
url=""
|
[[ $state == (Running|NeedsLogin|NeedsMachineAuth|Stopped|NoState) ]] && break
|
||||||
for i in {1..40}; do
|
sleep 2
|
||||||
url=$(ssh "$FRAME" 'grep -Eo "https://login\.tailscale\.com/[A-Za-z0-9/_-]+" /tmp/tailscale-up.log | head -n 1' || true)
|
done
|
||||||
[[ -n $url ]] && break
|
|
||||||
sleep 0.5
|
case $state in
|
||||||
done
|
Running) ;;
|
||||||
if [[ -n $url ]]; then
|
NeedsLogin|Stopped|NoState)
|
||||||
|
# `up` blocks until the login is approved, so run it as its own transient
|
||||||
|
# unit (it outlives this SSH session) and fetch the URL from its log.
|
||||||
|
ssh "$FRAME" "rm -f /tmp/tailscale-up.log; systemd-run --user --quiet --collect --unit=tailscale-up-\$\$ \
|
||||||
|
sh -c '~/.local/bin/tailscale up --hostname=$hostname --timeout=10m > /tmp/tailscale-up.log 2>&1' >/dev/null"
|
||||||
|
url=""
|
||||||
|
for i in {1..40}; do
|
||||||
|
url=$(ssh "$FRAME" 'grep -Eo "https://login\.tailscale\.com/[A-Za-z0-9/_-]+" /tmp/tailscale-up.log 2>/dev/null | head -n 1' || true)
|
||||||
|
[[ -n $url ]] && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
[[ -n $url ]] || { print -u2 "No login URL after 20 s; see /tmp/tailscale-up.log on the Frame."; exit 1; }
|
||||||
print "==> Approve the Frame in your tailnet: $url"
|
print "==> Approve the Frame in your tailnet: $url"
|
||||||
open "$url" 2>/dev/null || true
|
open "$url" 2>/dev/null || true
|
||||||
print " Waiting for approval (up to 10 minutes)…"
|
print " Waiting for approval (up to 10 minutes)…"
|
||||||
for i in {1..300}; do
|
for i in {1..300}; do
|
||||||
state=$(ssh "$FRAME" '~/.local/bin/tailscale status --json 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin)[\"BackendState\"])"' || true)
|
state=$(ts_state)
|
||||||
[[ $state == Running ]] && break
|
[[ $state == Running ]] && break
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
else
|
[[ $state == Running ]] || { print -u2 "Not approved yet (state: $state). Re-run to get a new URL."; exit 1; }
|
||||||
print -u2 "No login URL yet; see /tmp/tailscale-up.log on the Frame."
|
;;
|
||||||
fi
|
NeedsMachineAuth) print -u2 "Logged in; approve the device in the Tailscale admin console, then re-run."; exit 1 ;;
|
||||||
fi
|
*) print -u2 "Couldn't read tailscaled's state (last: $state). Check: ssh $FRAME 'journalctl --user -u tailscaled'"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
ssh "$FRAME" '~/.local/bin/tailscale status --self --peers=false; printf "Tailscale IP: "; ~/.local/bin/tailscale ip -4'
|
ssh "$FRAME" '~/.local/bin/tailscale status --self --peers=false; printf "Tailscale IP: "; ~/.local/bin/tailscale ip -4'
|
||||||
|
name=$(ssh "$FRAME" '~/.local/bin/tailscale status --json' | python3 -c 'import json,sys; print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')
|
||||||
|
print "==> To use the Frame from anywhere, point the alias at Tailscale:"
|
||||||
|
print " ssh-keyscan -t ed25519 $name >> ~/.ssh/known_hosts # after checking it matches"
|
||||||
|
print " scripts/connect.sh $name"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Compatibility reports without the maintainer's key: saved locally, never sent.
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s tests
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "ui"))
|
||||||
|
|
||||||
|
import frame_compat_db as db # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class NoKey(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
state = tmp.name
|
||||||
|
for name, value in (("STATE", state), ("OUTBOX", os.path.join(state, "outbox.jsonl")),
|
||||||
|
("MIRROR", os.path.join(state, "mirror.json"))):
|
||||||
|
p = mock.patch.object(db, name, value)
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
db._mem.update(at=0, reports=None, source=None)
|
||||||
|
env = mock.patch.dict(os.environ, {}, clear=False)
|
||||||
|
env.start()
|
||||||
|
self.addCleanup(env.stop)
|
||||||
|
os.environ.pop("FRAME_CONTROL_KEY", None)
|
||||||
|
# No Keychain entry, and any network use fails the test.
|
||||||
|
no_key = mock.patch.object(db.subprocess, "run",
|
||||||
|
return_value=mock.Mock(returncode=44, stdout=""))
|
||||||
|
no_key.start()
|
||||||
|
self.addCleanup(no_key.stop)
|
||||||
|
net = mock.patch.object(db._opener, "open", side_effect=AssertionError("network used"))
|
||||||
|
net.start()
|
||||||
|
self.addCleanup(net.stop)
|
||||||
|
|
||||||
|
def test_report_is_kept_locally(self):
|
||||||
|
self.assertFalse(db.shared())
|
||||||
|
r = db.add({"package": "org.example.app", "date": "2026-09-26T10:00:00", "rating": "works"})
|
||||||
|
reports = db.load()
|
||||||
|
self.assertEqual([x["id"] for x in reports], [r["id"]])
|
||||||
|
self.assertEqual(db._mem["source"], "mirror")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
+77
-1
@@ -6,15 +6,19 @@ request guards and input validation, which all run before any SSH call.
|
|||||||
Run: python3 -m unittest discover -s tests
|
Run: python3 -m unittest discover -s tests
|
||||||
"""
|
"""
|
||||||
import http.client
|
import http.client
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
@@ -53,7 +57,7 @@ class ServerGuards(unittest.TestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def request(cls, method, path, body=None, headers=None):
|
def request(cls, method, path, body=None, headers=None):
|
||||||
conn = http.client.HTTPConnection("127.0.0.1", cls.port, timeout=10)
|
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 {})
|
conn.request(method, path, body=data, headers=headers or {})
|
||||||
r = conn.getresponse()
|
r = conn.getresponse()
|
||||||
payload = r.read()
|
payload = r.read()
|
||||||
@@ -81,6 +85,9 @@ class ServerGuards(unittest.TestCase):
|
|||||||
# <img src> and plain form posts from other sites can't set it.
|
# <img src> and plain form posts from other sites can't set it.
|
||||||
self.assertEqual(self.request("GET", "/api/status")[0], 403)
|
self.assertEqual(self.request("GET", "/api/status")[0], 403)
|
||||||
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
|
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
|
||||||
|
self.assertEqual(self.request("GET", "/api/shots")[0], 403)
|
||||||
|
self.assertEqual(self.request("GET", "/api/stream")[0], 403)
|
||||||
|
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403)
|
||||||
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
|
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
|
||||||
|
|
||||||
def test_captures_are_not_cacheable(self):
|
def test_captures_are_not_cacheable(self):
|
||||||
@@ -98,11 +105,26 @@ class ServerGuards(unittest.TestCase):
|
|||||||
("/api/volume", {"level": 1.5}),
|
("/api/volume", {"level": 1.5}),
|
||||||
("/api/clipboard", {"text": ""}),
|
("/api/clipboard", {"text": ""}),
|
||||||
("/api/open", {"what": "anything-else"}),
|
("/api/open", {"what": "anything-else"}),
|
||||||
|
("/api/shots/save", {"ids": []}),
|
||||||
|
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
|
||||||
|
("/api/shots/save", {"ids": [1]}),
|
||||||
|
("/api/shots/save", {"ids": ["1/250820/../../.ssh/id_ed25519"]}),
|
||||||
|
("/api/shots/save", {"ids": ["1/250820/20260925225208_1.jpg; rm -rf ~"]}),
|
||||||
]
|
]
|
||||||
for path, body in cases:
|
for path, body in cases:
|
||||||
status, payload = self.post(path, body)
|
status, payload = self.post(path, body)
|
||||||
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
||||||
|
|
||||||
|
def test_screenshot_ids_checked_before_ssh(self):
|
||||||
|
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
|
||||||
|
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
|
||||||
|
self.assertEqual(status, 400, shot)
|
||||||
|
|
||||||
|
def test_stream_settings_checked_before_ssh(self):
|
||||||
|
for query in ("h=480", "fps=24", "h=abc", "h=1080&fps=120"):
|
||||||
|
status, _, _ = self.request("GET", f"/api/stream?{query}", headers={"X-Frame-UI": "1"})
|
||||||
|
self.assertEqual(status, 400, query)
|
||||||
|
|
||||||
def test_bad_bodies(self):
|
def test_bad_bodies(self):
|
||||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||||
conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"})
|
conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"})
|
||||||
@@ -111,12 +133,66 @@ class ServerGuards(unittest.TestCase):
|
|||||||
status, _ = self.post("/api/launch", ["not", "an", "object"])
|
status, _ = self.post("/api/launch", ["not", "an", "object"])
|
||||||
self.assertEqual(status, 400)
|
self.assertEqual(status, 400)
|
||||||
|
|
||||||
|
def test_title_upload_is_inspected_then_discarded(self):
|
||||||
|
# A zip holding a Windows x86-64 program: inspected locally, no SSH until install.
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as z:
|
||||||
|
z.writestr("Tiny Game/Tiny Game.exe",
|
||||||
|
b"MZ" + b"\0" * 0x3A + struct.pack("<I", 0x40) + b"PE\0\0" + struct.pack("<HHIIIHH", 0x8664, 1, 0, 0, 0, 0xF0, 0x22))
|
||||||
|
status, _, payload = self.request("POST", "/api/upload", buf.getvalue(),
|
||||||
|
{"X-Frame-UI": "1", "X-Mode": "title", "X-Filename": quote("Tiny Game-win64.zip")})
|
||||||
|
r = json.loads(payload)
|
||||||
|
self.assertEqual(status, 200, r)
|
||||||
|
self.assertEqual((r["plan"]["id"], r["plan"]["target"], r["plan"]["runtime"]),
|
||||||
|
("Tiny_Game", "Tiny Game.exe", "proton-experimental"))
|
||||||
|
self.assertNotIn("root", r["plan"])
|
||||||
|
self.assertEqual(self.post("/api/titles", {"action": "discard", "token": r["token"]})[0], 200)
|
||||||
|
self.assertEqual(self.post("/api/titles", {"action": "install", "token": r["token"]})[0], 400)
|
||||||
|
|
||||||
|
def test_title_input_validation(self):
|
||||||
|
status, _, _ = self.request("POST", "/api/upload", b"not a zip",
|
||||||
|
{"X-Frame-UI": "1", "X-Mode": "title", "X-Filename": "x.zip"})
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
for body in ({"action": "inspect", "path": "relative/game.zip"},
|
||||||
|
{"action": "inspect", "path": "/nonexistent/frame-control/game.zip"},
|
||||||
|
{"action": "install", "token": "nope"},
|
||||||
|
{"action": "launch", "id": "x; rm -rf ~"},
|
||||||
|
{"action": "remove", "id": "../etc"},
|
||||||
|
{"action": "explode"}):
|
||||||
|
status, payload = self.post("/api/titles", body)
|
||||||
|
self.assertEqual(status, 400, f"{body} -> {payload}")
|
||||||
|
self.assertEqual(self.request("GET", "/api/titles/job?token=nope", headers={"X-Frame-UI": "1"})[0], 404)
|
||||||
|
self.assertEqual(self.request("POST", "/api/titles", {"action": "list"})[0], 403)
|
||||||
|
|
||||||
|
def test_web_install_needs_the_app_page(self):
|
||||||
|
# A website can only open frame-control:// links; it can't call these itself.
|
||||||
|
link = {"url": "https://cdn.example.com/game.apk"}
|
||||||
|
self.assertEqual(self.request("POST", "/api/webinstall/check", link)[0], 403)
|
||||||
|
self.assertEqual(self.request("POST", "/api/webinstall/start", {"id": "x"})[0], 403)
|
||||||
|
status, _, _ = self.request("POST", "/api/webinstall/check", link,
|
||||||
|
{"X-Frame-UI": "1", "Host": f"evil.example:{self.port}"})
|
||||||
|
self.assertEqual(status, 403)
|
||||||
|
|
||||||
|
def test_web_install_validation(self):
|
||||||
|
for body in ({}, {"url": 5}, {"url": "http://cdn.example.com/game.apk"}, {"url": "https://10.0.0.2/game.apk"},
|
||||||
|
{"url": "https://u:p@example.com/game.apk"}, {"url": "https://example.com/"},
|
||||||
|
{"url": "https://1.1.1.1/game.sh"}, {"manifest": "file:///etc/passwd"},
|
||||||
|
{"manifest": "https://example.com/m.json", "url": "https://example.com/g.apk"}):
|
||||||
|
status, payload = self.post("/api/webinstall/check", body)
|
||||||
|
self.assertEqual(status, 400, f"{body} -> {payload}")
|
||||||
|
# Only an id from /check starts an install, and only once.
|
||||||
|
self.assertEqual(self.post("/api/webinstall/start", {"id": "made-up"})[0], 400)
|
||||||
|
self.assertEqual(self.request("GET", "/api/webinstall/job?id=x", headers={"X-Frame-UI": "1"})[0], 404)
|
||||||
|
self.assertEqual(self.post("/api/webinstall/cancel", {"job": "x"})[0], 404)
|
||||||
|
|
||||||
def test_unknown_routes(self):
|
def test_unknown_routes(self):
|
||||||
self.assertEqual(self.request("GET", "/nope")[0], 404)
|
self.assertEqual(self.request("GET", "/nope")[0], 404)
|
||||||
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
||||||
|
|
||||||
|
|
||||||
class StatusProbe(unittest.TestCase):
|
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):
|
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.
|
# 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")],
|
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}
|
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__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
|
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
|
||||||
@@ -27,7 +30,9 @@ class FrameError(RuntimeError):
|
|||||||
|
|
||||||
def ssh(cmd, input=None, timeout=120):
|
def ssh(cmd, input=None, timeout=120):
|
||||||
try:
|
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)
|
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise FrameError(f'timed out talking to {FRAME}')
|
raise FrameError(f'timed out talking to {FRAME}')
|
||||||
@@ -52,47 +57,12 @@ def game_id(shortcut_appid):
|
|||||||
return (int(shortcut_appid) << 32) | 0x02000000
|
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):
|
def apk_info(path):
|
||||||
"""Package, label, version, native ABIs and the best PNG icon inside the APK."""
|
"""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:
|
try:
|
||||||
z = zipfile.ZipFile(path)
|
return frame_apk.apk_info(path)
|
||||||
except (zipfile.BadZipFile, OSError) as e:
|
except frame_apk.ApkError as e:
|
||||||
raise FrameError(f'not a readable APK: {e}')
|
raise FrameError(f'{os.path.basename(path)}: {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
|
|
||||||
|
|
||||||
|
|
||||||
def check_installable(info):
|
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
|
_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:
|
try:
|
||||||
subprocess.run(['rsync', '-a', *extra, '-e', 'ssh ' + ' '.join(SSH_OPTS), src, f'{FRAME}:{dest}'],
|
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||||
check=True, capture_output=True, text=True, timeout=timeout)
|
|
||||||
except subprocess.TimeoutExpired:
|
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:
|
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():
|
def _shortcut_ids():
|
||||||
@@ -146,8 +125,8 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
|
|||||||
ok = False
|
ok = False
|
||||||
try:
|
try:
|
||||||
ssh(f'mkdir -p {d}')
|
ssh(f'mkdir -p {d}')
|
||||||
_rsync(apk_path, f'{d}/app.apk.part')
|
_copy(apk_path, f'{d}/app.apk.part')
|
||||||
_rsync(LAUNCHER, f'{d}/launch.sh', '--chmod=u+x', timeout=120)
|
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
|
||||||
icon = ''
|
icon = ''
|
||||||
if info['icon_png']:
|
if info['icon_png']:
|
||||||
ssh(f'cat > {d}/icon.png', input=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 build as catalog_build # noqa: E402
|
||||||
import reports # noqa: E402
|
import reports # noqa: E402
|
||||||
import frame_android # noqa: E402
|
import frame_android # noqa: E402
|
||||||
|
import frame_host # noqa: E402
|
||||||
import frame_compat_db as compat_db # 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'))
|
else os.path.join(CATALOG, 'data', 'cache'))
|
||||||
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
|
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|||||||
+29
-11
@@ -1,20 +1,23 @@
|
|||||||
"""Frame Control's compatibility database: a private Lakebed capsule
|
"""Frame Control's compatibility database: a private Lakebed capsule
|
||||||
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
|
(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,
|
write, using a key from $FRAME_CONTROL_KEY or the macOS Keychain (service
|
||||||
account app-key).
|
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
|
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
|
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}
|
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 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 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')
|
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
|
||||||
KEYCHAIN = ('frame-control-compat-db', 'app-key')
|
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')
|
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
|
||||||
MIRROR = os.path.join(STATE, 'compat-mirror.json')
|
MIRROR = os.path.join(STATE, 'compat-mirror.json')
|
||||||
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
|
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
|
||||||
@@ -32,14 +35,26 @@ def key():
|
|||||||
k = os.environ.get('FRAME_CONTROL_KEY')
|
k = os.environ.get('FRAME_CONTROL_KEY')
|
||||||
if k:
|
if k:
|
||||||
return k
|
return k
|
||||||
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
p = None
|
||||||
capture_output=True, text=True)
|
if frame_host.MAC:
|
||||||
if p.returncode != 0 or not p.stdout.strip():
|
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
||||||
raise DBError('No compatibility-database key in the Keychain '
|
capture_output=True, text=True)
|
||||||
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
|
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()
|
return p.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def shared():
|
||||||
|
"""Whether reports reach the shared database. Without the key (anyone but the
|
||||||
|
maintainer), reports stay in this computer's outbox and ratings come from the catalogue."""
|
||||||
|
try:
|
||||||
|
key()
|
||||||
|
return True
|
||||||
|
except DBError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
"""Never follow redirects: urllib would copy the key header to the new host."""
|
"""Never follow redirects: urllib would copy the key header to the new host."""
|
||||||
def redirect_request(self, *args, **kwargs):
|
def redirect_request(self, *args, **kwargs):
|
||||||
@@ -169,6 +184,8 @@ def load():
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
if _mem['reports'] is None or now - _mem['at'] > TTL:
|
if _mem['reports'] is None or now - _mem['at'] > TTL:
|
||||||
try:
|
try:
|
||||||
|
if not shared():
|
||||||
|
raise DBError('no key')
|
||||||
try:
|
try:
|
||||||
flush()
|
flush()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -196,8 +213,9 @@ def add(report):
|
|||||||
with _lock, open(OUTBOX, 'a') as f:
|
with _lock, open(OUTBOX, 'a') as f:
|
||||||
f.write(json.dumps(r, ensure_ascii=False) + '\n')
|
f.write(json.dumps(r, ensure_ascii=False) + '\n')
|
||||||
try:
|
try:
|
||||||
flush()
|
if shared():
|
||||||
_mem['at'] = 0 # refetch on next load
|
flush()
|
||||||
|
_mem['at'] = 0 # refetch on next load
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # stays queued; load() shows it and a later call sends it
|
pass # stays queued; load() shows it and a later call sends it
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -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):
|
Uses the store's public endpoints (no key, no login):
|
||||||
api/storesearch name search, price in the IP's currency
|
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}
|
||||||
+570
-33
@@ -152,6 +152,17 @@
|
|||||||
background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.5); cursor: pointer; }
|
background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.5); cursor: pointer; }
|
||||||
.vol .num { width: 40px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
.vol .num { width: 40px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ---- Steam screenshots from the headset ---- */
|
||||||
|
.shot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
|
||||||
|
.shot-card { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.shot-card .thumb { width: 100%; aspect-ratio: 16 / 9; border-radius: 3px; object-fit: cover; background: rgba(0,0,0,.3);
|
||||||
|
display: block; cursor: zoom-in; box-shadow: 0 6px 16px rgba(0,0,0,.45); }
|
||||||
|
.shot-card .thumb:hover { box-shadow: 0 6px 16px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.25); }
|
||||||
|
.shot-card .row { flex-wrap: nowrap; }
|
||||||
|
.shot-card .grow { flex: 1; min-width: 0; }
|
||||||
|
.shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.shot-card .s { color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
|
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
|
||||||
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
||||||
.capsule { position: relative; aspect-ratio: 2 / 3; border-radius: 3px; overflow: hidden; background: #2a2f38 center/cover no-repeat;
|
.capsule { position: relative; aspect-ratio: 2 / 3; border-radius: 3px; overflow: hidden; background: #2a2f38 center/cover no-repeat;
|
||||||
@@ -210,12 +221,23 @@
|
|||||||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||||||
.and-col { display: grid; gap: 22px; align-content: start; }
|
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||||
.rep-item .s { white-space: normal; }
|
.rep-item .s { white-space: normal; }
|
||||||
#repDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
#repDlg, #titleDlg, #wiDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||||
#repDlg::backdrop { background: rgba(0,0,0,.55); }
|
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||||
#repDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
#repDlg h2, #titleDlg h2, #wiDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||||
#repForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||||
#repForm label input[type=text], #repForm textarea { margin-top: 5px; }
|
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||||||
|
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||||
|
border-radius: 3px; padding: 8px 10px; font: inherit; }
|
||||||
|
#titleForm select:focus { outline: none; border-color: var(--blue); }
|
||||||
|
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||||
|
#titleForm .note.warn { color: #d9a23a; }
|
||||||
|
#titleList { margin-top: 8px; }
|
||||||
|
#wiFacts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; font-size: 13.5px; }
|
||||||
|
#wiFacts dt { color: var(--muted); }
|
||||||
|
#wiFacts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; }
|
||||||
|
#wiWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 14px 0 0; }
|
||||||
|
#wiProg:not([hidden]) { display: block; }
|
||||||
#repForm fieldset { border: 0; padding: 0; margin: 12px 0 0; }
|
#repForm fieldset { border: 0; padding: 0; margin: 12px 0 0; }
|
||||||
#repForm legend { font-size: 12.5px; color: var(--muted); padding: 0; margin-bottom: 4px; }
|
#repForm legend { font-size: 12.5px; color: var(--muted); padding: 0; margin-bottom: 4px; }
|
||||||
#repForm label.opt { display: inline-flex; align-items: center; gap: 6px; margin: 4px 14px 0 0; color: var(--text); font-size: 13.5px; }
|
#repForm label.opt { display: inline-flex; align-items: center; gap: 6px; margin: 4px 14px 0 0; color: var(--text); font-size: 13.5px; }
|
||||||
@@ -279,6 +301,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<nav id="nav">
|
<nav id="nav">
|
||||||
<a href="#view" class="on">View</a>
|
<a href="#view" class="on">View</a>
|
||||||
|
<a href="#shots">Shots</a>
|
||||||
<a href="#library">Library</a>
|
<a href="#library">Library</a>
|
||||||
<a href="#getgames">Games</a>
|
<a href="#getgames">Games</a>
|
||||||
<a href="#android">Android</a>
|
<a href="#android">Android</a>
|
||||||
@@ -309,7 +332,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button class="action" id="shotBtn">Capture</button>
|
<button class="action" id="shotBtn">Capture</button>
|
||||||
<button id="liveBtn" title="Keep capturing">Live</button>
|
<button id="liveBtn" title="Keep updating: video of the headset view, or repeated captures of the desktop panel">Live</button>
|
||||||
<button id="saveBtn" disabled>Save</button>
|
<button id="saveBtn" disabled>Save</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer" id="viewer">
|
<div class="viewer" id="viewer">
|
||||||
@@ -369,6 +392,16 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section id="shots">
|
||||||
|
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
|
||||||
|
<button class="small" id="shotsRefresh">Refresh</button>
|
||||||
|
<button class="small" id="shotsFolder" title="Open the SteamFrame folder in your Pictures">Show folder</button>
|
||||||
|
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
||||||
|
</div>
|
||||||
|
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
||||||
|
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="library">
|
<section id="library">
|
||||||
<div class="shelf-head"><h2>Library</h2><span class="count" id="gameCount"></span></div>
|
<div class="shelf-head"><h2>Library</h2><span class="count" id="gameCount"></span></div>
|
||||||
<div class="shelf" id="games"><div class="sub">Loading…</div></div>
|
<div class="shelf" id="games"><div class="sub">Loading…</div></div>
|
||||||
@@ -412,8 +445,8 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="shelf-head"><h2>Recent reports</h2><span class="count" id="repCount"></span></div>
|
<div class="shelf-head"><h2>Recent reports</h2><span class="count" id="repCount"></span></div>
|
||||||
<div class="list" id="repList"><div class="sub">Loading…</div></div>
|
<div class="list" id="repList"><div class="sub">Loading…</div></div>
|
||||||
<div class="hint">Reports go to Frame Control's private compatibility database and change the
|
<div class="hint" id="repHint">Reports change the verdicts in the catalogue. Any APK can be
|
||||||
verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid.</div>
|
reported, including ones not on F-Droid.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -436,14 +469,17 @@
|
|||||||
<div class="shelf-head"><h2>Send to Frame</h2></div>
|
<div class="shelf-head"><h2>Send to Frame</h2></div>
|
||||||
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
|
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
|
||||||
<b>Drop files here</b>
|
<b>Drop files here</b>
|
||||||
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app.
|
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app;
|
||||||
|
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
|
||||||
<input type="file" id="fileInput" multiple hidden>
|
<input type="file" id="fileInput" multiple hidden>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress" id="prog"><i></i></div>
|
<div class="progress" id="prog"><i></i></div>
|
||||||
|
<div class="shelf-head" style="margin-top:16px"><h2>Sideloaded titles</h2><span class="count" id="titleCount"></span></div>
|
||||||
|
<div class="list" id="titleList"><div class="sub">Loading…</div></div>
|
||||||
<textarea id="clipText" style="margin-top:16px" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
<textarea id="clipText" style="margin-top:16px" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
||||||
<div class="row" style="margin-top:8px">
|
<div class="row" style="margin-top:8px">
|
||||||
<button class="action small" id="clipSend">Send text</button>
|
<button class="action small" id="clipSend">Send text</button>
|
||||||
<button class="small" id="clipMac">Send Mac clipboard</button>
|
<button class="small" id="clipMac">Send this computer's clipboard</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -500,7 +536,7 @@
|
|||||||
<button data-open="reboot" data-confirm="Restart the Frame?"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>Restart</button>
|
<button data-open="reboot" data-confirm="Restart the Frame?"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>Restart</button>
|
||||||
<button data-open="poweroff" data-confirm="Shut the Frame down?" class="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v9"/><path d="M6.3 7.3a8 8 0 1 0 11.4 0"/></svg>Shut down</button>
|
<button data-open="poweroff" data-confirm="Shut the Frame down?" class="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v9"/><path d="M6.3 7.3a8 8 0 1 0 11.4 0"/></svg>Shut down</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">Sleep, Restart and Shut down open Terminal for the Developer Mode password.</div>
|
<div class="hint">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
|
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
|
||||||
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
|
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
|
||||||
@@ -545,6 +581,32 @@
|
|||||||
<button type="submit" class="action small" id="repSave">Save report</button></div>
|
<button type="submit" class="action small" id="repSave">Save report</button></div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<dialog id="titleDlg" aria-labelledby="titleTitle">
|
||||||
|
<form method="dialog" id="titleForm">
|
||||||
|
<h2 id="titleTitle">Add to the Steam library</h2>
|
||||||
|
<div class="sub" id="titleSrc"></div>
|
||||||
|
<label>Name<input type="text" id="titleName" maxlength="120" required></label>
|
||||||
|
<div class="note" id="titleIdNote"></div>
|
||||||
|
<label>Launches<select id="titleExe"></select></label>
|
||||||
|
<label>Runtime<select id="titleRt"></select></label>
|
||||||
|
<div class="note" id="titleNote"></div>
|
||||||
|
<div class="note warn" id="titleWarn"></div>
|
||||||
|
<div class="row rep-actions"><span class="sub" id="titleMsg"></span><span class="spacer"></span>
|
||||||
|
<button type="button" class="small" id="titleCancel">Cancel</button>
|
||||||
|
<button type="button" class="small" id="titlePush">Copy to ~/Downloads instead</button>
|
||||||
|
<button type="submit" class="action small" id="titleInstall">Install</button></div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
<dialog id="wiDlg" aria-labelledby="wiTitle">
|
||||||
|
<h2 id="wiTitle">Install from a website</h2>
|
||||||
|
<dl id="wiFacts"></dl>
|
||||||
|
<p id="wiWarn">A website asked Frame Control to install this. Nothing is downloaded until you click Install.
|
||||||
|
Only install software from sites you trust.</p>
|
||||||
|
<div class="progress" id="wiProg" hidden><i></i></div>
|
||||||
|
<div class="row rep-actions"><span class="sub" id="wiMsg"></span><span class="spacer"></span>
|
||||||
|
<button type="button" class="small" id="wiCancel">Cancel</button>
|
||||||
|
<button type="button" class="action small" id="wiGo" disabled>Install</button></div>
|
||||||
|
</dialog>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -553,12 +615,12 @@ const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_
|
|||||||
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
|
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
|
||||||
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
||||||
const HINTS = {
|
const HINTS = {
|
||||||
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live refreshes about twice a second. Captures show everything on screen, including anything private.",
|
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live streams it as video (one eye, about 30 fps); Capture takes a still of both eyes. Captures show everything on screen, including anything private.",
|
||||||
flat: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.",
|
flat: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.",
|
||||||
};
|
};
|
||||||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel" };
|
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", shot: "Screenshot" };
|
||||||
let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
|
let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
|
||||||
let lastImg = null, lastSource = null;
|
let lastImg = null, lastSource = null, lastShot = null, viewGen = 0;
|
||||||
|
|
||||||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
||||||
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
|
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
|
||||||
@@ -591,6 +653,15 @@ $("bottombar").onclick = () => {
|
|||||||
$("drawerHint").textContent = open ? "Hide ▾" : "Show ▴";
|
$("drawerHint").textContent = open ? "Hide ▾" : "Show ▴";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wording for the computer the app runs on (Mac or PC, Finder or File Explorer).
|
||||||
|
const HOST = { computer: "computer" };
|
||||||
|
function applyHostWording(h) {
|
||||||
|
Object.assign(HOST, h);
|
||||||
|
$("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
||||||
|
$("shotsSaveNew").textContent = `Save new to ${h.computer}`;
|
||||||
|
$("clipMac").textContent = `Send ${h.computer} clipboard`;
|
||||||
|
}
|
||||||
|
|
||||||
async function api(path, body) {
|
async function api(path, body) {
|
||||||
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
|
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
|
||||||
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
|
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
|
||||||
@@ -729,10 +800,12 @@ function render(s) {
|
|||||||
|
|
||||||
// ---- view ----
|
// ---- view ----
|
||||||
function setView(v) {
|
function setView(v) {
|
||||||
|
const changed = v !== view;
|
||||||
view = v;
|
view = v;
|
||||||
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v));
|
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v));
|
||||||
$("eyeSeg").style.visibility = v === "headset" ? "visible" : "hidden";
|
$("eyeSeg").style.visibility = v === "headset" && !video.ctl ? "visible" : "hidden";
|
||||||
$("viewHint").textContent = HINTS[v];
|
$("viewHint").textContent = HINTS[v];
|
||||||
|
if (live && changed) { toggleLive(false); toggleLive(true); } // video for the headset, captures for the panel
|
||||||
}
|
}
|
||||||
function setEye(e) {
|
function setEye(e) {
|
||||||
eye = e;
|
eye = e;
|
||||||
@@ -792,7 +865,7 @@ document.addEventListener("keydown", e => {
|
|||||||
function draw() {
|
function draw() {
|
||||||
const img = lastImg, c = $("canvas");
|
const img = lastImg, c = $("canvas");
|
||||||
// Side-by-side stereo captures: crop to the left half for "one eye".
|
// Side-by-side stereo captures: crop to the left half for "one eye".
|
||||||
const sbs = lastSource !== "gamescope" && img.naturalWidth >= img.naturalHeight * 1.5;
|
const sbs = lastSource === "steamvr" && img.naturalWidth >= img.naturalHeight * 1.5;
|
||||||
const crop = sbs && eye === "left";
|
const crop = sbs && eye === "left";
|
||||||
const sw = crop ? img.naturalWidth / 2 : img.naturalWidth, sh = img.naturalHeight;
|
const sw = crop ? img.naturalWidth / 2 : img.naturalWidth, sh = img.naturalHeight;
|
||||||
if (c.width !== sw || c.height !== sh) {
|
if (c.width !== sw || c.height !== sh) {
|
||||||
@@ -819,6 +892,8 @@ function isBlank(ctx, w, h) {
|
|||||||
return max - min < 6;
|
return max - min < 6;
|
||||||
}
|
}
|
||||||
async function capture() {
|
async function capture() {
|
||||||
|
if (video.ctl) toggleLive(false); // Capture during live video takes a still of both eyes instead
|
||||||
|
const gen = viewGen; // a screenshot opened meanwhile wins over this capture
|
||||||
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
|
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
|
||||||
let url = null;
|
let url = null;
|
||||||
try {
|
try {
|
||||||
@@ -829,7 +904,8 @@ async function capture() {
|
|||||||
url = URL.createObjectURL(await r.blob());
|
url = URL.createObjectURL(await r.blob());
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
|
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
|
||||||
lastImg = img; lastSource = source;
|
if (gen !== viewGen) return true;
|
||||||
|
lastImg = img; lastSource = source; lastShot = null;
|
||||||
draw();
|
draw();
|
||||||
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
|
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
|
||||||
$("saveBtn").disabled = false;
|
$("saveBtn").disabled = false;
|
||||||
@@ -859,25 +935,136 @@ function toggleLive(on) {
|
|||||||
liveFailures = 0;
|
liveFailures = 0;
|
||||||
$("liveBtn").classList.toggle("on", live);
|
$("liveBtn").classList.toggle("on", live);
|
||||||
$("liveBadge").hidden = !live;
|
$("liveBadge").hidden = !live;
|
||||||
if (live) liveLoop(); else clearTimeout(liveTimer);
|
clearTimeout(liveTimer);
|
||||||
|
stopVideo();
|
||||||
|
if (!live) return;
|
||||||
|
if (view === "headset" && "VideoDecoder" in window) {
|
||||||
|
const started = startVideo();
|
||||||
|
const ctl = video.ctl;
|
||||||
|
started.catch(e => {
|
||||||
|
if (!live || ctl !== video.ctl || e.name === "AbortError") return;
|
||||||
|
log("Live video failed: " + e.message, "e");
|
||||||
|
toast("Live video failed, using captures instead: " + e.message, true);
|
||||||
|
stopVideo();
|
||||||
|
liveLoop();
|
||||||
|
});
|
||||||
|
} else liveLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- live video of the headset view ----
|
||||||
|
// The server relays raw H.264 (Annex B) from SteamVR's headset-view device.
|
||||||
|
// Every frame starts with an access unit delimiter (NAL type 9), which is how
|
||||||
|
// the stream is cut into frames for WebCodecs.
|
||||||
|
const STREAM_QUERY = "h=720&fps=30", STREAM_FPS = 30;
|
||||||
|
const video = { ctl: null, dec: null, gen: 0 };
|
||||||
|
function startCode(b, i) { return b[i] === 0 && b[i + 1] === 0 && b[i + 2] === 1; }
|
||||||
|
function nalTypes(au) {
|
||||||
|
const types = [];
|
||||||
|
for (let i = 0; i + 3 < au.length; i++) if (startCode(au, i)) { types.push(au[i + 3] & 0x1f); i += 3; }
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
async function startVideo() {
|
||||||
|
const gen = ++video.gen, ctl = new AbortController();
|
||||||
|
video.ctl = ctl;
|
||||||
|
$("eyeSeg").style.visibility = "hidden";
|
||||||
|
if (!lastImg) $("viewer").classList.add("busy");
|
||||||
|
let frames = 0, shown = 0, second = performance.now(), needKey = true, ts = 0;
|
||||||
|
const c = $("canvas"), ctx = c.getContext("2d");
|
||||||
|
const dec = video.dec = new VideoDecoder({
|
||||||
|
output: frame => {
|
||||||
|
if (gen !== video.gen) return frame.close();
|
||||||
|
if (c.width !== frame.displayWidth || c.height !== frame.displayHeight) {
|
||||||
|
c.width = frame.displayWidth; c.height = frame.displayHeight;
|
||||||
|
$("viewer").style.aspectRatio = `${c.width} / ${c.height}`;
|
||||||
|
setZoom(1);
|
||||||
|
}
|
||||||
|
ctx.drawImage(frame, 0, 0);
|
||||||
|
frame.close();
|
||||||
|
if (!shown++) {
|
||||||
|
viewGen++; // a capture still in flight mustn't replace the video
|
||||||
|
lastImg = null; lastSource = "video"; lastShot = null;
|
||||||
|
$("viewer").classList.remove("busy");
|
||||||
|
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
|
||||||
|
$("srcBadge").hidden = false; $("srcBadge").textContent = "Headset view · video";
|
||||||
|
$("stamp").hidden = false; $("saveBtn").disabled = false;
|
||||||
|
}
|
||||||
|
frames++;
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - second >= 1000) {
|
||||||
|
$("stamp").textContent = `${Math.round(frames * 1000 / (now - second))} fps`;
|
||||||
|
frames = 0; second = now;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: e => log("Video decoder: " + e.message, "e"),
|
||||||
|
});
|
||||||
|
const r = await fetch(`/api/stream?${STREAM_QUERY}`, { headers: {"X-Frame-UI": "1"}, signal: ctl.signal });
|
||||||
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||||
|
const reader = r.body.getReader();
|
||||||
|
let buf = new Uint8Array(0), scan = 0, auStart = -1;
|
||||||
|
const onAU = au => {
|
||||||
|
const types = nalTypes(au), key = types.includes(5);
|
||||||
|
if (dec.state === "unconfigured") {
|
||||||
|
const sps = types.indexOf(7);
|
||||||
|
if (sps < 0) return;
|
||||||
|
let i = 0, n = -1; // find the SPS bytes: profile, constraints, level follow its header
|
||||||
|
for (; i + 3 < au.length; i++) if (startCode(au, i) && ++n === sps) break;
|
||||||
|
if (i + 6 >= au.length) return;
|
||||||
|
const hex = [au[i + 4], au[i + 5], au[i + 6]].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
dec.configure({ codec: `avc1.${hex}`, optimizeForLatency: true });
|
||||||
|
}
|
||||||
|
// If decoding falls behind, drop frames until the next keyframe rather than lag.
|
||||||
|
if (dec.decodeQueueSize > 3) needKey = true;
|
||||||
|
if (needKey && !key) return;
|
||||||
|
needKey = false;
|
||||||
|
dec.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: ts, data: au }));
|
||||||
|
ts += 1e6 / STREAM_FPS;
|
||||||
|
};
|
||||||
|
for (;;) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done || gen !== video.gen) break;
|
||||||
|
const next = new Uint8Array(buf.length + value.length);
|
||||||
|
next.set(buf); next.set(value, buf.length);
|
||||||
|
buf = next;
|
||||||
|
if (buf.length > 8 << 20) throw new Error("the stream isn't split into frames");
|
||||||
|
for (; scan + 3 < buf.length; scan++) {
|
||||||
|
if (!startCode(buf, scan) || (buf[scan + 3] & 0x1f) !== 9) continue;
|
||||||
|
if (auStart >= 0) onAU(buf.subarray(auStart, scan));
|
||||||
|
auStart = scan;
|
||||||
|
scan += 3;
|
||||||
|
}
|
||||||
|
if (auStart > 0) { buf = buf.slice(auStart); scan -= auStart; auStart = 0; }
|
||||||
|
}
|
||||||
|
if (gen === video.gen && live) throw new Error(shown ? "the stream ended" : "no video arrived");
|
||||||
|
}
|
||||||
|
function stopVideo() {
|
||||||
|
video.gen++;
|
||||||
|
if (video.ctl) video.ctl.abort();
|
||||||
|
if (video.dec && video.dec.state !== "closed") video.dec.close();
|
||||||
|
video.ctl = video.dec = null;
|
||||||
|
$("viewer").classList.remove("busy");
|
||||||
|
if (lastSource === "video") { $("srcBadge").textContent = "Headset view · video (stopped)"; $("stamp").hidden = true; }
|
||||||
|
$("eyeSeg").style.visibility = view === "headset" ? "visible" : "hidden";
|
||||||
}
|
}
|
||||||
$("shotBtn").onclick = () => capture();
|
$("shotBtn").onclick = () => capture();
|
||||||
$("liveBtn").onclick = () => toggleLive(!live);
|
$("liveBtn").onclick = () => toggleLive(!live);
|
||||||
$("saveBtn").onclick = () => $("canvas").toBlob(b => {
|
$("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) : $("canvas").toBlob(b => {
|
||||||
if (!b) return toast("Couldn't encode the image", true);
|
if (!b) return toast("Couldn't encode the image", true);
|
||||||
|
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
|
||||||
|
}, "image/png");
|
||||||
|
function download(blob, name) {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(b);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`;
|
a.download = name;
|
||||||
a.click();
|
a.click();
|
||||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
}, "image/png");
|
}
|
||||||
document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(b.dataset.view));
|
document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(b.dataset.view));
|
||||||
document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye));
|
document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye));
|
||||||
|
|
||||||
// ---- controls ----
|
// ---- controls ----
|
||||||
$("refreshAll").onclick = refresh;
|
$("refreshAll").onclick = () => { refresh(); loadTitles(); }; // titles too: the Frame may have been asleep at start
|
||||||
document.addEventListener("keydown", e => {
|
document.addEventListener("keydown", e => {
|
||||||
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !/INPUT|TEXTAREA/.test(document.activeElement.tagName)) refresh();
|
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) $("refreshAll").onclick();
|
||||||
});
|
});
|
||||||
document.body.addEventListener("click", async (e) => {
|
document.body.addEventListener("click", async (e) => {
|
||||||
const b = e.target.closest("button");
|
const b = e.target.closest("button");
|
||||||
@@ -922,7 +1109,14 @@ $("clipSend").onclick = () => {
|
|||||||
if (!text) return toast("Nothing to send", true);
|
if (!text) return toast("Nothing to send", true);
|
||||||
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
|
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
|
||||||
};
|
};
|
||||||
$("clipMac").onclick = () => act("Send Mac clipboard", () => api("/api/clipboard", { fromMac: true }), $("clipMac"));
|
// In the app, Electron reads the clipboard; in a browser, the server does.
|
||||||
|
async function sendComputerClipboard() {
|
||||||
|
if (!window.frameApp) return api("/api/clipboard", { fromComputer: true });
|
||||||
|
const text = await window.frameApp.readClipboard();
|
||||||
|
if (!text) throw new Error("The clipboard is empty (or holds something other than text)");
|
||||||
|
return api("/api/clipboard", { text });
|
||||||
|
}
|
||||||
|
$("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard, $("clipMac"));
|
||||||
|
|
||||||
// ---- file drop ----
|
// ---- file drop ----
|
||||||
const drop = $("drop");
|
const drop = $("drop");
|
||||||
@@ -931,7 +1125,12 @@ drop.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefau
|
|||||||
$("fileInput").onchange = () => sendFiles([...$("fileInput").files]);
|
$("fileInput").onchange = () => sendFiles([...$("fileInput").files]);
|
||||||
["dragenter", "dragover"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.add("over"); }));
|
["dragenter", "dragover"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.add("over"); }));
|
||||||
["dragleave", "drop"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.remove("over"); }));
|
["dragleave", "drop"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.remove("over"); }));
|
||||||
drop.addEventListener("drop", e => sendFiles([...e.dataTransfer.files]));
|
// Folders only show up as directory entries, and only during the drop event itself.
|
||||||
|
drop.addEventListener("drop", e => {
|
||||||
|
const items = [...(e.dataTransfer.items || [])].filter(it => it.kind === "file");
|
||||||
|
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
|
||||||
|
sendFiles([...e.dataTransfer.files], dirs);
|
||||||
|
});
|
||||||
function upload(file, mode) {
|
function upload(file, mode) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
@@ -951,9 +1150,17 @@ function upload(file, mode) {
|
|||||||
xhr.send(file);
|
xhr.send(file);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function sendFiles(files) {
|
const TITLE_EXT = /\.(zip|exe)$/i;
|
||||||
for (const f of files) {
|
async function sendFiles(files, dirs = new Set()) {
|
||||||
if (!f.size) { toast(`${f.name}: folders and empty files aren't supported here; use scripts/push.sh`, true); continue; }
|
for (const [i, f] of files.entries()) {
|
||||||
|
const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : "";
|
||||||
|
if (dirs.has(i)) {
|
||||||
|
if (path) await sideload(f, path, true);
|
||||||
|
else toast(`${f.name}: zip the folder first (folders need the Frame Control app)`, true);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
|
||||||
|
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
|
||||||
const apk = f.name.toLowerCase().endsWith(".apk");
|
const apk = f.name.toLowerCase().endsWith(".apk");
|
||||||
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
|
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
|
||||||
}
|
}
|
||||||
@@ -961,6 +1168,143 @@ async function sendFiles(files) {
|
|||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- sideloaded titles (a Linux or Windows build as a Steam Devkit Game) ----
|
||||||
|
const RESERVED_IDS = ["steam", "steamdeckard", "steamvr", "steamvrdeckard", "devkit-steam"];
|
||||||
|
function titleId(name) { // mirrors frame_titles.title_id: the name Steam shows
|
||||||
|
const trim = x => x.replace(/^[_-]+|[_-]+$/g, "");
|
||||||
|
let s = trim(trim(String(name).trim().replace(/[^A-Za-z0-9_-]+/g, "_").replace(/_+/g, "_")).slice(0, 64));
|
||||||
|
if (RESERVED_IDS.includes(s.toLowerCase())) s += "-game";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
const ARCH = { arm64: "ARM64", x86_64: "x86-64", x86: "32-bit x86" };
|
||||||
|
function candLabel(c) {
|
||||||
|
const kind = c.format === "pe" ? `Windows ${ARCH[c.arch] || c.arch}` : c.format === "elf" ? `Linux ${ARCH[c.arch] || c.arch}` : "script";
|
||||||
|
return `${c.path} · ${kind} · ${gb(c.size)}${c.skip ? " · helper?" : ""}`;
|
||||||
|
}
|
||||||
|
// Inspect (upload, or the local path in the app), confirm in the dialog, then install with progress.
|
||||||
|
// One at a time, so a second drop doesn't take over the dialog of the first.
|
||||||
|
let sideloadQueue = Promise.resolve();
|
||||||
|
function sideload(...args) {
|
||||||
|
const run = sideloadQueue.then(() => sideloadOne(...args));
|
||||||
|
sideloadQueue = run.catch(() => {});
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
async function sideloadOne(file, path, isDir = false) {
|
||||||
|
const canPush = !isDir && file.size > 0;
|
||||||
|
log(`Reading ${file.name}…`);
|
||||||
|
let r;
|
||||||
|
try { r = path ? await api("/api/titles", { action: "inspect", path }) : await upload(file, "title"); }
|
||||||
|
catch (e) {
|
||||||
|
log(`${file.name}: ${e.message}`, "e");
|
||||||
|
if (canPush && confirm(`${file.name}: ${e.message}\n\nCopy it to ~/Downloads instead?`))
|
||||||
|
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Its own fresh list, so the dialog can say whether this replaces an installed title.
|
||||||
|
let installed = null;
|
||||||
|
try { installed = new Set((await api("/api/titles")).titles.map(t => String(t.id).toLowerCase())); } catch {}
|
||||||
|
const choice = await confirmTitle(r.plan, canPush, installed);
|
||||||
|
if (choice === "push") {
|
||||||
|
api("/api/titles", { action: "discard", token: r.token }).catch(() => {});
|
||||||
|
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); return; }
|
||||||
|
await installTitle(r.token, choice);
|
||||||
|
}
|
||||||
|
function confirmTitle(plan, canPush, installed) { // installed: a Set of lower-case ids, or null if unknown
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const dlg = $("titleDlg");
|
||||||
|
$("titlePush").hidden = !canPush;
|
||||||
|
$("titleSrc").textContent = `${plan.source} · ${gb(plan.size)}`;
|
||||||
|
$("titleName").value = plan.name;
|
||||||
|
const idNote = () => { const id = titleId($("titleName").value);
|
||||||
|
$("titleIdNote").textContent = !id ? "Needs some letters or digits"
|
||||||
|
: !installed ? `Shows in Steam as ${id}. Couldn't check whether it's installed already; if it is, this replaces it`
|
||||||
|
: installed.has(id.toLowerCase()) ? `Replaces the installed ${id}, and everything in its folder on the Frame`
|
||||||
|
: `Shows in Steam as ${id}`; };
|
||||||
|
$("titleName").oninput = idNote; idNote();
|
||||||
|
$("titleExe").innerHTML = plan.candidates.map(c =>
|
||||||
|
`<option value="${esc(c.path)}"${c.path === plan.target ? " selected" : ""}${c.blocked ? " disabled" : ""}>${esc(candLabel(c))}</option>`).join("");
|
||||||
|
const runtimes = () => {
|
||||||
|
const c = plan.candidates.find(x => x.path === $("titleExe").value) || {};
|
||||||
|
const list = c.runtimes || [];
|
||||||
|
$("titleRt").innerHTML = list.map(k => `<option value="${esc(k)}">${esc(plan.runtime_labels[k] || k)}</option>`).join("");
|
||||||
|
$("titleRt").disabled = list.length < 2;
|
||||||
|
if (c.path === plan.target) {
|
||||||
|
$("titleRt").value = plan.runtime;
|
||||||
|
$("titleNote").textContent = plan.note + " Device behaviour is inferred from Valve's devkit tools.";
|
||||||
|
} else $("titleNote").textContent = "Runtime follows the chosen program.";
|
||||||
|
$("titleWarn").textContent = c.skip ? "This looks like an installer or helper, not the game itself." : "";
|
||||||
|
};
|
||||||
|
$("titleExe").onchange = runtimes; runtimes();
|
||||||
|
$("titleMsg").textContent = "";
|
||||||
|
// Resolve on this dialog's own close event (it arrives a frame later), so a
|
||||||
|
// queued next title can't receive it. Escape closes with the choice left null.
|
||||||
|
let choice = null;
|
||||||
|
dlg.addEventListener("close", () => resolve(choice), { once: true });
|
||||||
|
const finish = v => { choice = v; dlg.close(); };
|
||||||
|
$("titleCancel").onclick = () => finish(null);
|
||||||
|
$("titlePush").onclick = () => finish("push");
|
||||||
|
$("titleForm").onsubmit = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!titleId($("titleName").value)) return ($("titleMsg").textContent = "Enter a name");
|
||||||
|
finish({ name: $("titleName").value.trim(), exe: $("titleExe").value, runtime: $("titleRt").value });
|
||||||
|
};
|
||||||
|
dlg.showModal();
|
||||||
|
$("titleName").focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function installTitle(token, choice) {
|
||||||
|
const label = `Install ${titleId(choice.name)}`;
|
||||||
|
const start = await act(label, () => api("/api/titles", { action: "install", token, ...choice }));
|
||||||
|
if (!start) return;
|
||||||
|
const bar = $("prog").firstElementChild;
|
||||||
|
$("prog").style.display = "block"; bar.style.width = "0";
|
||||||
|
let stage = "";
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
await new Promise(ok => setTimeout(ok, 1000));
|
||||||
|
let j;
|
||||||
|
try { j = await api(`/api/titles/job?token=${encodeURIComponent(start.job)}`); }
|
||||||
|
catch (e) { log(`${label}: ${e.message}`, "e"); return; }
|
||||||
|
bar.style.width = (100 * (j.fraction || 0)) + "%";
|
||||||
|
if (j.stage && j.stage !== stage && !j.done) { stage = j.stage; log(`${stage}…`); $("lastLog").textContent = stage + "…"; }
|
||||||
|
if (j.done) {
|
||||||
|
if (j.error) { log(`${label} failed: ${j.error}`, "e"); toast(`${label} failed: ${j.error}`, true); }
|
||||||
|
else { log(j.message, "ok"); toast(j.message); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally { $("prog").style.display = "none"; loadTitles(); }
|
||||||
|
}
|
||||||
|
let titlesSeq = 0; // a slower, older request mustn't overwrite a newer list
|
||||||
|
async function loadTitles() {
|
||||||
|
const seq = ++titlesSeq;
|
||||||
|
let list, err;
|
||||||
|
try { list = (await api("/api/titles")).titles; } catch (e) { err = e; }
|
||||||
|
if (seq !== titlesSeq) return;
|
||||||
|
if (err) { $("titleList").innerHTML = `<div class="sub">${esc(err.message)}</div>`; return; }
|
||||||
|
$("titleCount").textContent = list.length ? `${list.length}` : "";
|
||||||
|
$("titleList").innerHTML = list.length ? list.map(t => `
|
||||||
|
<div class="item">
|
||||||
|
<div class="grow"><div class="t">${esc(t.id)}</div>
|
||||||
|
<div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div>
|
||||||
|
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">▶</button>
|
||||||
|
<button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
|
||||||
|
</div>`).join("") : `<div class="sub">None yet. Drop a game's .zip, folder or .exe above.</div>`;
|
||||||
|
}
|
||||||
|
$("titleList").onclick = async e => {
|
||||||
|
const b = e.target.closest("[data-title]"); if (!b) return;
|
||||||
|
const id = b.dataset.id;
|
||||||
|
if (b.dataset.title === "remove") {
|
||||||
|
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
|
||||||
|
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
|
||||||
|
loadTitles();
|
||||||
|
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
|
||||||
|
};
|
||||||
|
loadTitles();
|
||||||
|
|
||||||
|
|
||||||
// ---- Steam games: install owned games, find and buy on the store ----
|
// ---- Steam games: install owned games, find and buy on the store ----
|
||||||
const FLABEL = { 3: "Frame Verified", 2: "Playable", 1: "Unsupported", 0: "Unknown" };
|
const FLABEL = { 3: "Frame Verified", 2: "Playable", 1: "Unsupported", 0: "Unknown" };
|
||||||
@@ -1318,9 +1662,12 @@ const RLABEL = { works: "Works", issues: "Problems", broken: "Doesn't work", run
|
|||||||
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
|
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
|
||||||
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
|
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
|
||||||
async function loadReports() {
|
async function loadReports() {
|
||||||
let reps;
|
let reps, shared;
|
||||||
try { reps = (await api("/api/android/reports")).reports; }
|
try { ({ reports: reps, shared } = await api("/api/android/reports")); }
|
||||||
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||||||
|
$("repHint").textContent = shared
|
||||||
|
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
|
||||||
|
: "Reports are saved on this computer and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
|
||||||
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
||||||
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
|
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
|
||||||
const k = r.rating || r.result;
|
const k = r.rating || r.result;
|
||||||
@@ -1381,16 +1728,206 @@ $("repForm").onsubmit = async e => {
|
|||||||
finally { $("repSave").disabled = false; }
|
finally { $("repSave").disabled = false; }
|
||||||
};
|
};
|
||||||
loadReports();
|
loadReports();
|
||||||
|
api("/api/host").then(applyHostWording).catch(() => {});
|
||||||
|
|
||||||
|
// ---- Steam screenshots from the headset ----
|
||||||
|
const shots = { list: [], urls: [] };
|
||||||
|
const STEAMVR_APPID = "250820";
|
||||||
|
function shotApp(appid) {
|
||||||
|
if (appid === STEAMVR_APPID) return "SteamVR";
|
||||||
|
const g = state?.games?.find(x => x.appid === appid);
|
||||||
|
return g ? g.name : `App ${appid}`;
|
||||||
|
}
|
||||||
|
async function shotBlob(id, thumb) {
|
||||||
|
const r = await fetch(`/api/shots/image?id=${encodeURIComponent(id)}${thumb ? "&thumb=1" : ""}`,
|
||||||
|
{ headers: {"X-Frame-UI": "1"} });
|
||||||
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||||
|
return r.blob();
|
||||||
|
}
|
||||||
|
async function loadShots() {
|
||||||
|
$("shotsRefresh").disabled = true;
|
||||||
|
try {
|
||||||
|
shots.list = (await api("/api/shots")).shots;
|
||||||
|
} catch (e) {
|
||||||
|
$("shotGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`;
|
||||||
|
return;
|
||||||
|
} finally { $("shotsRefresh").disabled = false; }
|
||||||
|
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
||||||
|
const unsaved = shots.list.filter(s => !s.saved).length;
|
||||||
|
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
|
||||||
|
$("shotsSaveNew").disabled = !unsaved;
|
||||||
|
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
|
||||||
|
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
|
||||||
|
<div class="row"><div class="grow">
|
||||||
|
<div class="t">${esc(shotApp(s.appid))}</div>
|
||||||
|
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
|
||||||
|
${s.saved ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
||||||
|
</div></div>`).join("")
|
||||||
|
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
||||||
|
// Thumbnails one at a time over the shared SSH connection.
|
||||||
|
for (const img of document.querySelectorAll("#shotGrid img[data-shot]")) {
|
||||||
|
const s = shots.list[+img.dataset.shot];
|
||||||
|
try {
|
||||||
|
const url = URL.createObjectURL(await shotBlob(s.id, true));
|
||||||
|
shots.urls.push(url);
|
||||||
|
img.src = url;
|
||||||
|
} catch (e) { img.alt = "Preview failed"; }
|
||||||
|
if (!img.isConnected) return; // the list was reloaded meanwhile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function openShot(s) {
|
||||||
|
if (live) toggleLive(false);
|
||||||
|
const gen = ++viewGen;
|
||||||
|
$("viewer").classList.add("busy");
|
||||||
|
let url = null;
|
||||||
|
try {
|
||||||
|
const blob = await shotBlob(s.id, false);
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
|
||||||
|
if (gen !== viewGen) return;
|
||||||
|
lastImg = img; lastSource = "shot"; lastShot = { blob, file: s.file };
|
||||||
|
draw();
|
||||||
|
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
|
||||||
|
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
|
||||||
|
$("saveBtn").disabled = false;
|
||||||
|
$("view").scrollIntoView({ behavior: "smooth" });
|
||||||
|
} catch (e) {
|
||||||
|
toast("Couldn't open the screenshot: " + e.message, true);
|
||||||
|
} finally {
|
||||||
|
if (url) URL.revokeObjectURL(url);
|
||||||
|
$("viewer").classList.remove("busy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function saveShots(list, btn) {
|
||||||
|
if (!list.length) return;
|
||||||
|
const res = await act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
|
||||||
|
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
|
||||||
|
if (res) loadShots();
|
||||||
|
}
|
||||||
|
$("shotGrid").onclick = e => {
|
||||||
|
const img = e.target.closest("img[data-shot]");
|
||||||
|
if (img) return openShot(shots.list[+img.dataset.shot]);
|
||||||
|
const b = e.target.closest("[data-shot-save]");
|
||||||
|
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
|
||||||
|
};
|
||||||
|
$("shotsRefresh").onclick = loadShots;
|
||||||
|
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
||||||
|
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
|
||||||
|
|
||||||
// ---- nav highlight follows scroll ----
|
// ---- nav highlight follows scroll ----
|
||||||
const spy = new IntersectionObserver(entries => {
|
const spy = new IntersectionObserver(entries => {
|
||||||
const top = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
|
const top = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
|
||||||
if (top) document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + top.target.id));
|
if (top) document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + top.target.id));
|
||||||
}, { rootMargin: "-80px 0px -55% 0px" });
|
}, { rootMargin: "-80px 0px -55% 0px" });
|
||||||
["view", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id)));
|
["view", "shots", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id)));
|
||||||
|
|
||||||
|
// ---- install links from websites (frame-control://install, docs/web-install.md) ----
|
||||||
|
// The app passes each link here. The server checks it and reads the manifest;
|
||||||
|
// nothing downloads until the user clicks Install in this dialog.
|
||||||
|
const wi = { queue: [], open: false, gen: 0, plan: null, job: null, starting: false };
|
||||||
|
function wiSize(n) {
|
||||||
|
if (n == null) return "Not given";
|
||||||
|
return n >= 1e9 ? gb(n) : n >= 1e6 ? (n / 1e6).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
|
||||||
|
}
|
||||||
|
function wiFacts(rows) { $("wiFacts").innerHTML = rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join(""); }
|
||||||
|
function wiButtons(cancel, go) {
|
||||||
|
$("wiCancel").textContent = cancel[0]; $("wiCancel").disabled = !cancel[1];
|
||||||
|
$("wiGo").hidden = !go; $("wiGo").disabled = go !== "on";
|
||||||
|
}
|
||||||
|
async function wiNext() {
|
||||||
|
if (wi.open || !wi.queue.length) return;
|
||||||
|
const req = wi.queue.shift(), gen = ++wi.gen;
|
||||||
|
wi.open = true; wi.plan = null; wi.job = null;
|
||||||
|
let host = "";
|
||||||
|
try { host = new URL(req.target).hostname; } catch {}
|
||||||
|
wiFacts([["From", host], [req.kind === "manifest" ? "Manifest" : "File", req.target]]);
|
||||||
|
$("wiProg").hidden = true; $("wiMsg").textContent = "Checking the link…";
|
||||||
|
wiButtons(["Cancel", true], "off");
|
||||||
|
$("wiDlg").showModal();
|
||||||
|
log(`Install link from ${host}`);
|
||||||
|
try {
|
||||||
|
const p = await api("/api/webinstall/check", { [req.kind]: req.target });
|
||||||
|
if (gen !== wi.gen) return;
|
||||||
|
wi.plan = p;
|
||||||
|
const from = p.host === p.linkHost ? p.host : `${p.linkHost} (file on ${p.host})`;
|
||||||
|
wiFacts([["Title", p.name], ["From", from], ["File", p.file], ["Type", p.kindLabel], ["Size", wiSize(p.size)],
|
||||||
|
["SHA-256", p.sha256 ? "Given; checked after downloading" : "Not given; the download can't be checked"]]);
|
||||||
|
$("wiMsg").textContent = "";
|
||||||
|
wiButtons(["Cancel", true], "on");
|
||||||
|
$("wiGo").focus();
|
||||||
|
} catch (e) {
|
||||||
|
if (gen !== wi.gen) return;
|
||||||
|
$("wiMsg").textContent = e.message;
|
||||||
|
wiButtons(["Close", true], null);
|
||||||
|
log(`Install link refused: ${e.message}`, "e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function wiPoll(gen) {
|
||||||
|
if (gen !== wi.gen || !wi.job) return;
|
||||||
|
let j;
|
||||||
|
try {
|
||||||
|
j = await api(`/api/webinstall/job?id=${encodeURIComponent(wi.job)}`);
|
||||||
|
} catch (e) {
|
||||||
|
$("wiMsg").textContent = e.message;
|
||||||
|
return setTimeout(() => wiPoll(gen), 1500);
|
||||||
|
}
|
||||||
|
if (gen !== wi.gen) return;
|
||||||
|
const bar = $("wiProg").firstElementChild;
|
||||||
|
$("wiProg").hidden = false;
|
||||||
|
if (j.phase === "download") {
|
||||||
|
bar.style.width = j.total ? (100 * j.done / j.total) + "%" : "0";
|
||||||
|
$("wiMsg").textContent = `Downloading ${wiSize(j.done)}` + (j.total ? ` of ${wiSize(j.total)}` : "");
|
||||||
|
wiButtons(["Stop download", true], "off");
|
||||||
|
} else if (j.phase === "install") {
|
||||||
|
bar.style.width = "100%";
|
||||||
|
$("wiMsg").textContent = j.detail || "Installing on the Frame…";
|
||||||
|
wiButtons(["Stop download", false], "off");
|
||||||
|
} else {
|
||||||
|
wi.job = null;
|
||||||
|
$("wiProg").hidden = true;
|
||||||
|
if (j.phase === "done") {
|
||||||
|
log(j.message, "ok"); toast(j.message);
|
||||||
|
$("wiDlg").close();
|
||||||
|
loadAndroid(); loadTitles(); refresh();
|
||||||
|
} else {
|
||||||
|
$("wiMsg").textContent = j.error;
|
||||||
|
log(`Install from link failed: ${j.error}`, "e"); toast(`Install failed: ${j.error}`, true);
|
||||||
|
wiButtons(["Close", true], null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(() => wiPoll(gen), 500);
|
||||||
|
}
|
||||||
|
$("wiGo").onclick = async () => {
|
||||||
|
const p = wi.plan, gen = wi.gen;
|
||||||
|
if (!p) return;
|
||||||
|
wi.plan = null; // one click, one install
|
||||||
|
wiButtons(["Cancel", false], "off");
|
||||||
|
$("wiMsg").textContent = "Starting…";
|
||||||
|
wi.starting = true; // the dialog stays open until the job is known
|
||||||
|
try {
|
||||||
|
wi.job = (await api("/api/webinstall/start", { id: p.id })).job;
|
||||||
|
log(`Installing ${p.name} from ${p.host}…`);
|
||||||
|
wiPoll(gen);
|
||||||
|
} catch (e) {
|
||||||
|
$("wiMsg").textContent = e.message;
|
||||||
|
wiButtons(["Close", true], null);
|
||||||
|
} finally { wi.starting = false; }
|
||||||
|
};
|
||||||
|
$("wiCancel").onclick = async () => {
|
||||||
|
if (!wi.job) return $("wiDlg").close();
|
||||||
|
try { await api("/api/webinstall/cancel", { job: wi.job }); } catch (e) { $("wiMsg").textContent = e.message; }
|
||||||
|
};
|
||||||
|
// Escape doesn't close the dialog while an install runs; it keeps showing progress.
|
||||||
|
$("wiDlg").addEventListener("cancel", e => { if (wi.job || wi.starting) e.preventDefault(); });
|
||||||
|
$("wiDlg").addEventListener("close", () => { wi.gen++; wi.open = false; wi.plan = null; wi.job = null; setTimeout(wiNext); });
|
||||||
|
if (window.frameApp && window.frameApp.onInstallLink) {
|
||||||
|
window.frameApp.onInstallLink(req => { if (wi.queue.length < 5) wi.queue.push(req); wiNext(); });
|
||||||
|
}
|
||||||
|
|
||||||
setView("headset");
|
setView("headset");
|
||||||
refresh();
|
refresh().then(loadShots); // after status, so app names resolve
|
||||||
setInterval(() => { if (!document.hidden) refresh(); }, 30000);
|
setInterval(() => { if (!document.hidden) refresh(); }, 30000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+687
-87
@@ -1,17 +1,21 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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`
|
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
|
||||||
SSH alias set up by scripts/connect.sh, reusing the scripts in ../scripts.
|
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)
|
Env: FRAME_ALIAS (default frame)
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
@@ -25,19 +29,29 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
import frame_android
|
# The app runs Python with -I, which leaves the script's own folder off
|
||||||
import frame_catalog
|
# sys.path, so add it for the sibling modules below.
|
||||||
import frame_store
|
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
|
HERE = Path(__file__).resolve().parent
|
||||||
SCRIPTS = HERE.parent / "scripts"
|
|
||||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||||
# Reuse one SSH connection for the frequent status/screenshot calls. /tmp, not
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
|
||||||
# $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
|
||||||
CONTROL = f"/tmp/frame-ui-{os.getuid()}-%C"
|
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||||
MUX = ["ssh", "-o", "BatchMode=yes", "-o", f"ControlPath={CONTROL}"]
|
# 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.
|
# 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.
|
# Android helpers share the multiplexed connection when it's up.
|
||||||
frame_android.SSH_OPTS = SSH[1:]
|
frame_android.SSH_OPTS = SSH[1:]
|
||||||
@@ -81,9 +95,12 @@ def ensure_master():
|
|||||||
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
||||||
"""
|
"""
|
||||||
global _master
|
global _master
|
||||||
|
if not CONTROL:
|
||||||
|
return
|
||||||
|
|
||||||
def up():
|
def up():
|
||||||
try:
|
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
|
timeout=5).returncode == 0
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return False
|
return False
|
||||||
@@ -96,7 +113,7 @@ def ensure_master():
|
|||||||
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
||||||
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
stderr=subprocess.DEVNULL, **frame_host.DETACHED)
|
||||||
for _ in range(60):
|
for _ in range(60):
|
||||||
if up() or _master.poll() is not None:
|
if up() or _master.poll() is not None:
|
||||||
return
|
return
|
||||||
@@ -106,7 +123,10 @@ def ensure_master():
|
|||||||
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||||
try:
|
try:
|
||||||
ensure_master()
|
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)
|
text=text, errors="replace" if text else None, timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"Timed out talking to {FRAME}")
|
raise Failure(f"Timed out talking to {FRAME}")
|
||||||
@@ -118,40 +138,16 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
|||||||
return r.stdout
|
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):
|
def strip_ansi(s):
|
||||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
||||||
|
|
||||||
|
|
||||||
def terminal(command):
|
def terminal(argv):
|
||||||
"""Open Terminal.app running `command` (for anything needing a password)."""
|
"""Open a terminal window running argv (for anything needing a password)."""
|
||||||
as_str = command.replace("\\", "\\\\").replace('"', '\\"')
|
try:
|
||||||
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
|
return frame_host.open_terminal(argv)
|
||||||
"-e", f'do script "{as_str}"', "-e", "activate", "-e", "end tell"],
|
except frame_host.HostError as e:
|
||||||
capture_output=True, text=True)
|
raise Failure(str(e), 500)
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
# ---- actions ---------------------------------------------------------------
|
# ---- actions ---------------------------------------------------------------
|
||||||
@@ -189,6 +185,124 @@ def headset_view():
|
|||||||
pass # frame_vrshot.py sweeps leftovers on the next capture
|
pass # frame_vrshot.py sweeps leftovers on the next capture
|
||||||
|
|
||||||
|
|
||||||
|
# Screenshots taken in the headset with Steam's shortcut. Steam files each under the app
|
||||||
|
# it was taken in: userdata/<account>/760/remote/<appid>/screenshots/<file>,
|
||||||
|
# with a smaller copy in screenshots/thumbnails/. A shot's id is
|
||||||
|
# "<account>/<appid>/<file>", checked here before it goes near a shell.
|
||||||
|
SHOT_ROOT = ".local/share/Steam/userdata"
|
||||||
|
SHOT_ID = re.compile(r"(\d{1,12})/(\d{1,20})/(\d{14}_\d{1,4}\.(?:jpg|png))")
|
||||||
|
SHOTS_DIR = Path.home() / "Pictures" / "SteamFrame"
|
||||||
|
LIST_SHOTS = f"""cd ~/{SHOT_ROOT} 2>/dev/null || exit 0
|
||||||
|
find . -mindepth 6 -maxdepth 6 -path './*/760/remote/*/screenshots/*' -type f \\
|
||||||
|
\\( -name '*.jpg' -o -name '*.png' \\) -printf '%P\\t%s\\t%T@\\n'"""
|
||||||
|
|
||||||
|
|
||||||
|
def shot_path(shot_id, thumb=False):
|
||||||
|
m = SHOT_ID.fullmatch(shot_id) if isinstance(shot_id, str) else None
|
||||||
|
if not m:
|
||||||
|
raise Failure("bad screenshot id", 400)
|
||||||
|
return f"{SHOT_ROOT}/{m[1]}/760/remote/{m[2]}/screenshots/{'thumbnails/' if thumb else ''}{m[3]}"
|
||||||
|
|
||||||
|
|
||||||
|
def list_shots():
|
||||||
|
shots = []
|
||||||
|
for line in ssh(LIST_SHOTS, timeout=20).splitlines():
|
||||||
|
rel, _, rest = line.partition("\t")
|
||||||
|
parts = rel.split("/") # account/760/remote/appid/screenshots/file
|
||||||
|
size, _, mtime = rest.partition("\t")
|
||||||
|
shot_id = f"{parts[0]}/{parts[3]}/{parts[-1]}" if len(parts) == 6 else ""
|
||||||
|
if not SHOT_ID.fullmatch(shot_id) or not size.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
when = float(mtime)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
local = SHOTS_DIR / parts[-1]
|
||||||
|
shots.append({"id": shot_id, "appid": parts[3], "file": parts[-1], "size": int(size), "time": when,
|
||||||
|
"saved": local.exists() and local.stat().st_size == int(size)})
|
||||||
|
shots.sort(key=lambda s: s["time"], reverse=True)
|
||||||
|
return {"shots": shots, "folder": str(SHOTS_DIR)}
|
||||||
|
|
||||||
|
|
||||||
|
def shot_image(query):
|
||||||
|
q = parse_qs(query)
|
||||||
|
shot_id = (q.get("id") or [""])[0]
|
||||||
|
full = shot_path(shot_id)
|
||||||
|
if q.get("thumb") == ["1"]:
|
||||||
|
# Steam writes the thumbnail a moment after the shot; fall back to the full image.
|
||||||
|
thumb = shot_path(shot_id, thumb=True)
|
||||||
|
remote = f"if [ -s {thumb} ]; then cat {thumb}; else cat {full}; fi"
|
||||||
|
else:
|
||||||
|
remote = f"cat {full}"
|
||||||
|
ctype = "image/png" if shot_id.endswith(".png") else "image/jpeg"
|
||||||
|
return ssh(remote, timeout=30, text=False), ctype
|
||||||
|
|
||||||
|
|
||||||
|
def save_shots(body):
|
||||||
|
"""Copy screenshots to ~/Pictures/SteamFrame, skipping ones already there."""
|
||||||
|
ids = body.get("ids")
|
||||||
|
if not isinstance(ids, list) or not 0 < len(ids) <= 1000:
|
||||||
|
raise Failure("ids must be a list of 1-1000 screenshot ids", 400)
|
||||||
|
paths = [shot_path(i) for i in ids]
|
||||||
|
todo = [p for p in paths if not (SHOTS_DIR / p.rsplit("/", 1)[-1]).exists()]
|
||||||
|
if todo:
|
||||||
|
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
ensure_master()
|
||||||
|
# Copy into a hidden folder and move complete files in, so a cut-off
|
||||||
|
# copy never looks saved. -p keeps the time the shot was taken.
|
||||||
|
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
||||||
|
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise Failure("Copying screenshots timed out")
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise Failure(strip_ansi(r.stderr).strip() or f"scp exited {r.returncode}")
|
||||||
|
for f in incoming.iterdir():
|
||||||
|
os.replace(f, SHOTS_DIR / f.name)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(incoming, ignore_errors=True)
|
||||||
|
n, skipped = len(todo), len(ids) - len(todo)
|
||||||
|
msg = f"Saved {n} screenshot{'s' * (n != 1)} to ~/Pictures/SteamFrame"
|
||||||
|
return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n}
|
||||||
|
|
||||||
|
|
||||||
|
# Live video of the headset view. SteamVR's steamvr-v4l2cam.service copies the
|
||||||
|
# headset view (one undistorted 1920x1080 image) into the v4l2loopback device
|
||||||
|
# /dev/video99. ffmpeg encodes it with x264 (the hardware encoder crashes
|
||||||
|
# ffmpeg) and the raw H.264 comes back over SSH for the page to decode with
|
||||||
|
# WebCodecs. An access unit delimiter starts every frame so the page can split
|
||||||
|
# the stream, and repeated SPS/PPS let it start at any keyframe. ffmpeg runs in
|
||||||
|
# the background while the shell waits for our stdin to close: when the local
|
||||||
|
# ssh goes, the channel closes and the shell kills ffmpeg, even one that has
|
||||||
|
# stopped writing (and so would never get SIGPIPE).
|
||||||
|
STREAM_DEVICE = "/dev/video99"
|
||||||
|
STREAM_HEIGHTS = (720, 1080)
|
||||||
|
STREAM_FPS = (30, 60)
|
||||||
|
STREAM_STALL = 10 # seconds without video before the stream is dropped
|
||||||
|
_stream_lock = threading.Lock()
|
||||||
|
_stream_proc = None
|
||||||
|
|
||||||
|
|
||||||
|
def stream_command(query):
|
||||||
|
q = parse_qs(query)
|
||||||
|
try:
|
||||||
|
height = int((q.get("h") or ["720"])[0])
|
||||||
|
fps = int((q.get("fps") or ["30"])[0])
|
||||||
|
except ValueError:
|
||||||
|
raise Failure("h and fps must be integers", 400)
|
||||||
|
if height not in STREAM_HEIGHTS or fps not in STREAM_FPS:
|
||||||
|
raise Failure(f"h must be one of {STREAM_HEIGHTS} and fps one of {STREAM_FPS}", 400)
|
||||||
|
rate = 3 if height == 720 else 6 # Mbit/s
|
||||||
|
return (f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
|
||||||
|
f"ffmpeg -hide_banner -loglevel error -nostdin -f v4l2 -video_size 1920x1080 -i {STREAM_DEVICE} "
|
||||||
|
f"-vf fps={fps},scale=-2:{height},format=yuv420p -c:v libx264 -preset ultrafast -tune zerolatency "
|
||||||
|
f"-g {fps * 2} -bf 0 -b:v {rate}M -maxrate {rate}M -bufsize {rate // 2 or 1}M "
|
||||||
|
f"-x264-params aud=1:repeat-headers=1 -f h264 - & p=$!; "
|
||||||
|
f"exec >&-; cat >/dev/null; kill $p 2>/dev/null; wait $p")
|
||||||
|
|
||||||
|
|
||||||
def launch(body):
|
def launch(body):
|
||||||
appid = str(body.get("appid", ""))
|
appid = str(body.get("appid", ""))
|
||||||
if not APPID.match(appid):
|
if not APPID.match(appid):
|
||||||
@@ -249,13 +363,44 @@ def set_volume(body):
|
|||||||
return {"message": "Volume updated"}
|
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):
|
def clipboard(body):
|
||||||
if body.get("fromMac"):
|
if body.get("fromMac") or body.get("fromComputer"):
|
||||||
return {"message": script("paste-to-frame.sh", timeout=30)}
|
try:
|
||||||
text = body.get("text")
|
text = frame_host.clipboard_text()
|
||||||
if not isinstance(text, str) or not text:
|
except frame_host.HostError as e:
|
||||||
raise Failure("nothing to send", 400)
|
raise Failure(str(e), 500)
|
||||||
return {"message": script("paste-to-frame.sh", "-", stdin=text, timeout=30)}
|
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):
|
def flatpak(body):
|
||||||
@@ -263,7 +408,11 @@ def flatpak(body):
|
|||||||
if not FLATPAK_ID.match(app):
|
if not FLATPAK_ID.match(app):
|
||||||
raise Failure("bad Flatpak app ID", 400)
|
raise Failure("bad Flatpak app ID", 400)
|
||||||
if action == "install":
|
if action == "install":
|
||||||
return {"message": script("install-apps.sh", app)}
|
# 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=900)
|
||||||
|
return {"message": f"Installed {app}"}
|
||||||
if action == "uninstall":
|
if action == "uninstall":
|
||||||
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
|
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
|
||||||
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
|
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
|
||||||
@@ -272,21 +421,25 @@ def flatpak(body):
|
|||||||
|
|
||||||
def open_thing(body):
|
def open_thing(body):
|
||||||
what = body.get("what")
|
what = body.get("what")
|
||||||
alias = shlex.quote(FRAME)
|
try:
|
||||||
if what == "terminal":
|
if what == "terminal":
|
||||||
terminal(f"ssh {alias}")
|
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
|
||||||
return {"message": "Opened an SSH session in Terminal"}
|
if what in ("reboot", "poweroff", "suspend"):
|
||||||
if what in ("reboot", "poweroff", "suspend"):
|
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
||||||
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
|
||||||
terminal(f"ssh -t {alias} sudo systemctl {what}")
|
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
|
||||||
return {"message": f"Confirm with the Developer Mode password in Terminal to {what}"}
|
if what == "steamlink":
|
||||||
if what == "steamlink":
|
return {"message": frame_host.open_steam_link()}
|
||||||
return {"message": open_app("Steam Link", "https://store.steampowered.com/remoteplay")}
|
if what == "rdp":
|
||||||
if what == "rdp":
|
return {"message": frame_host.open_rdp(FRAME)}
|
||||||
return {"message": open_app("Windows App", "https://apps.apple.com/app/windows-app/id1295203466")}
|
if what == "sftp":
|
||||||
if what == "sftp":
|
return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
|
||||||
terminal(f"sftp {alias}")
|
if what == "shots":
|
||||||
return {"message": "Opened an SFTP session in Terminal"}
|
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)
|
raise Failure("unknown target", 400)
|
||||||
|
|
||||||
|
|
||||||
@@ -315,12 +468,127 @@ def android(body):
|
|||||||
runtime=body.get("runtime") or "instance",
|
runtime=body.get("runtime") or "instance",
|
||||||
label=body.get("label"), source=body.get("source"))
|
label=body.get("label"), source=body.get("source"))
|
||||||
name = r.get("label") or pkg
|
name = r.get("label") or pkg
|
||||||
return {"message": f"Saved your report for {name}", "report": r}
|
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:
|
except frame_android.FrameError as e:
|
||||||
raise Failure(str(e))
|
raise Failure(str(e))
|
||||||
raise Failure("unknown action", 400)
|
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) -----------
|
# ---- Android display (wm size / wm density / font_scale over ADB) -----------
|
||||||
#
|
#
|
||||||
# Each running Lepton instance listens for ADB on the Frame (5555 is Lepton
|
# Each running Lepton instance listens for ADB on the Frame (5555 is Lepton
|
||||||
@@ -336,20 +604,19 @@ FONT_RANGE = (0.5, 2.0)
|
|||||||
KNOWN_LABELS = {"com.t3tools.t3code": "T3 Code", "org.fdroid.fdroid": "F-Droid"}
|
KNOWN_LABELS = {"com.t3tools.t3code": "T3 Code", "org.fdroid.fdroid": "F-Droid"}
|
||||||
# One ADB session at a time: requests are rare, and it keeps adb's state simple.
|
# One ADB session at a time: requests are rare, and it keeps adb's state simple.
|
||||||
_adb_lock = threading.Lock()
|
_adb_lock = threading.Lock()
|
||||||
_live_tunnels = set() # ssh processes to kill if the server stops mid-request
|
_live_tunnels = set() # ssh processes (ADB forwards, live video) to kill if the server stops mid-request
|
||||||
|
|
||||||
|
|
||||||
def adb_path():
|
def adb_path():
|
||||||
for cand in (os.environ.get("ADB"), shutil.which("adb"), "/opt/homebrew/bin/adb",
|
try:
|
||||||
str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"):
|
return frame_host.adb()
|
||||||
if cand and os.access(cand, os.X_OK):
|
except frame_host.HostError as e:
|
||||||
return cand
|
raise Failure(str(e), 500)
|
||||||
raise Failure("adb missing on the Mac: brew install android-platform-tools", 500)
|
|
||||||
|
|
||||||
|
|
||||||
def adb(adb_bin, *args, timeout=20):
|
def adb(adb_bin, *args, timeout=20):
|
||||||
try:
|
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)
|
errors="replace", timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"adb {' '.join(args[-2:])} timed out")
|
raise Failure(f"adb {' '.join(args[-2:])} timed out")
|
||||||
@@ -366,7 +633,7 @@ def free_local_port():
|
|||||||
|
|
||||||
|
|
||||||
class AdbTunnel:
|
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
|
`with AdbTunnel([5555, 5557]) as t: t.shell(5555, "wm size")`. On exit it
|
||||||
disconnects adb and kills the ssh process, whatever happened inside.
|
disconnects adb and kills the ssh process, whatever happened inside.
|
||||||
@@ -464,7 +731,7 @@ class AdbTunnel:
|
|||||||
self._stop_ssh()
|
self._stop_ssh()
|
||||||
for p in self.local:
|
for p in self.local:
|
||||||
try:
|
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):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
@@ -612,12 +879,245 @@ def android_display(body):
|
|||||||
return {"message": f"Port {port}: " + "; ".join(c.split(";")[0] for c in cmds), "display": now}
|
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,
|
# ---- install links from websites (frame-control://install, docs/web-install.md) ----
|
||||||
"/api/flatpak": flatpak, "/api/open": open_thing}
|
# 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 ------------------------------------------------------------------
|
# ---- 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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "FrameControl/1"
|
server_version = "FrameControl/1"
|
||||||
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
||||||
@@ -664,13 +1164,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
if path in ("/", "/index.html"):
|
if path in ("/", "/index.html"):
|
||||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
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":
|
elif path == "/api/android":
|
||||||
ensure_master()
|
ensure_master()
|
||||||
self.send_json({"apps": frame_android.list_apps()})
|
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/android/displays":
|
elif path == "/api/android/displays":
|
||||||
self.send_json(android_displays())
|
self.send_json(android_displays())
|
||||||
elif path == "/api/android/reports":
|
elif path == "/api/android/reports":
|
||||||
self.send_json({"reports": frame_catalog.recent_reports()})
|
self.send_json({"reports": frame_catalog.recent_reports(),
|
||||||
|
"shared": frame_catalog.compat_db.shared()})
|
||||||
elif path == "/api/android/catalog":
|
elif path == "/api/android/catalog":
|
||||||
self.send_json({"apps": frame_catalog.catalog()})
|
self.send_json({"apps": frame_catalog.catalog()})
|
||||||
elif path == "/api/status":
|
elif path == "/api/status":
|
||||||
@@ -679,6 +1188,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(steam_frame("owned"))
|
self.send_json(steam_frame("owned"))
|
||||||
elif path == "/api/steam/search":
|
elif path == "/api/steam/search":
|
||||||
self.send_json(steam_search(url.query))
|
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":
|
||||||
|
self.send_bytes(*shot_image(url.query))
|
||||||
|
elif path == "/api/stream":
|
||||||
|
self.stream_video(url.query)
|
||||||
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
|
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
|
||||||
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
|
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
|
||||||
elif path == "/api/screenshot":
|
elif path == "/api/screenshot":
|
||||||
@@ -688,6 +1205,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"error": "not found"}, 404)
|
self.send_json({"error": "not found"}, 404)
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
self.send_json({"error": str(e)}, e.status)
|
self.send_json({"error": str(e)}, e.status)
|
||||||
|
except frame_android.FrameError as e:
|
||||||
|
self.send_json({"error": str(e)}, 502)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||||
|
|
||||||
@@ -714,11 +1233,70 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"error": str(e)}, e.status)
|
self.send_json({"error": str(e)}, e.status)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
self.send_json({"error": f"bad request: {e}"}, 400)
|
self.send_json({"error": f"bad request: {e}"}, 400)
|
||||||
|
except frame_android.FrameError as e:
|
||||||
|
self.send_json({"error": str(e)}, 502)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||||
|
|
||||||
|
def stream_video(self, query):
|
||||||
|
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
|
||||||
|
global _stream_proc
|
||||||
|
remote = stream_command(query)
|
||||||
|
ensure_master()
|
||||||
|
# stderr goes to a file: nothing reads it while streaming, and a full
|
||||||
|
# pipe would stall ffmpeg. It's only read if the stream fails to start.
|
||||||
|
errors = tempfile.TemporaryFile()
|
||||||
|
proc = subprocess.Popen([*SSH, FRAME, remote], stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE, stderr=errors)
|
||||||
|
try:
|
||||||
|
_live_tunnels.add(proc)
|
||||||
|
# One viewer at a time: a new stream (another tab, a reload) ends the last one.
|
||||||
|
with _stream_lock:
|
||||||
|
old, _stream_proc = _stream_proc, proc
|
||||||
|
if old and old.poll() is None:
|
||||||
|
old.terminate()
|
||||||
|
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.
|
||||||
|
first = _next_chunk(chunks, 20)
|
||||||
|
if not first:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
errors.seek(0)
|
||||||
|
err = strip_ansi(errors.read().decode(errors="replace")).strip()
|
||||||
|
raise Failure(err or "The headset view sent no video for 20 s")
|
||||||
|
chunk = first
|
||||||
|
try:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "video/h264")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
|
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||||
|
self.end_headers()
|
||||||
|
self.close_connection = True # the body ends when the connection does
|
||||||
|
while chunk:
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
self.wfile.flush()
|
||||||
|
# A stalled headset view ends the stream rather than
|
||||||
|
# holding this thread (and the page) forever.
|
||||||
|
chunk = _next_chunk(chunks, STREAM_STALL)
|
||||||
|
except OSError:
|
||||||
|
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
|
||||||
|
finally:
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
for f in (proc.stdin, proc.stdout, errors):
|
||||||
|
f.close()
|
||||||
|
_live_tunnels.discard(proc)
|
||||||
|
|
||||||
def upload(self):
|
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", "")))
|
name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
|
||||||
mode = self.headers.get("X-Mode", "push")
|
mode = self.headers.get("X-Mode", "push")
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
@@ -729,6 +1307,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
|
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
|
||||||
raise Failure("APK install needs a .apk file", 400)
|
raise Failure("APK install needs a .apk file", 400)
|
||||||
tmp = Path(tempfile.mkdtemp(prefix="frame-ui-"))
|
tmp = Path(tempfile.mkdtemp(prefix="frame-ui-"))
|
||||||
|
keep = False
|
||||||
try:
|
try:
|
||||||
dest = tmp / name
|
dest = tmp / name
|
||||||
with open(dest, "wb") as f:
|
with open(dest, "wb") as f:
|
||||||
@@ -752,6 +1331,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
info["blocker"] = str(e)
|
info["blocker"] = str(e)
|
||||||
return {"message": f"Read {info['label']} {info['version']}", "apk": info}
|
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":
|
if mode == "apk":
|
||||||
ensure_master()
|
ensure_master()
|
||||||
try:
|
try:
|
||||||
@@ -759,30 +1341,48 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
raise Failure(str(e), 400)
|
raise Failure(str(e), 400)
|
||||||
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
|
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:
|
finally:
|
||||||
shutil.rmtree(tmp, ignore_errors=True)
|
if not keep:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
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()
|
args = ap.parse_args()
|
||||||
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
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)
|
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||||
try:
|
try:
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
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.
|
# 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:
|
if _master and _master.poll() is None:
|
||||||
_master.terminate()
|
_master.terminate()
|
||||||
for proc in list(_live_tunnels): # ADB forwards of requests cut off mid-way
|
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
|
_purge_titles(now=float("inf")) # unconfirmed title uploads
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in new issue
Block a user