mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Frame Control: Mac app, web UI, Android and Steam tooling
Package the Frame Control web UI as an installable Electron Mac app and bring in the tooling built alongside it. - app/: Electron wrapper that starts ui/server.py on a free loopback port, hardened window (sandbox, no navigation, runAsNode fuse off), login-shell PATH so Homebrew tools work from Finder, first-run offer to run connect.sh, ad-hoc signed DMG/zip via electron-builder. - ui/: headset view (OpenVR screenshots), device status, library, Steam "Get games" (owned games, install, store search), Android apps as persistent Lepton instances with a rated F-Droid catalogue and a private compatibility database, Android display controls over ADB, file and clipboard transfer, Flatpaks, remote and power actions. - apk-catalog/, compat-db/, frame/: catalogue build pipeline, Lakebed capsule for compatibility reports, Frame-side launchers. - tests/ and CI: server guard and validation tests plus Steam helper tests, run on Python 3.9 with script and app syntax checks. - Docs: README leads with the Mac app; new Android, panels, Steam games and field-notes docs; security notes on LAN-exposed ADB ports. Screenshot values for the headset's IP and Wi-Fi name are placeholders. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6ccf562756
commit
d4486a7681
56 files changed
+19298
-11
No files matched your search
@@ -0,0 +1,46 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# Steam Frame
|
||||
|
||||
The repo is `~/projects/steam-frame`. SSH works through the `frame` alias
|
||||
(user `steamos`). The headset has to be awake for anything that touches its
|
||||
desktop or panels.
|
||||
|
||||
## Start here
|
||||
|
||||
1. Read `docs/how-the-frame-works.md`. It's the map: the layer cake (SteamVR →
|
||||
gamescope → nested Plasma), the verified facts, and debug recipes.
|
||||
2. Open the topic doc for the task:
|
||||
|
||||
| Task | Doc | Script |
|
||||
|---|---|---|
|
||||
| Floating windows in the room, one panel per app | `docs/panels.md` | `scripts/panel-on-frame.sh` |
|
||||
| First-time access, SSH keys | `docs/ssh.md` | `scripts/connect.sh` |
|
||||
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
|
||||
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
|
||||
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
|
||||
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||
| Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` |
|
||||
| Mac GUI over all of this | `README.md` → Frame Control | `scripts/frame-ui.sh` |
|
||||
| What's still unverified | `docs/open-questions.md` | — |
|
||||
|
||||
Each script's usage is in its header comment. Read the header rather than
|
||||
running `--help`: `paste-to-frame.sh`, `serve-bootstrap.sh` and
|
||||
`bootstrap-on-frame.sh` act on any argument.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- Label every claim **verified** (seen on the device, with the date and
|
||||
SteamOS build) or **inferred**. The docs use this convention. Keep it, and
|
||||
move items out of `docs/open-questions.md` once they're checked.
|
||||
- When you learn something new about the Frame, record it in
|
||||
`docs/how-the-frame-works.md` (or the topic doc) in the same change.
|
||||
- The Frame's rootfs is read-only and SteamOS updates replace it. Put changes in
|
||||
`~` (`--user` Flatpaks, `~/.config`) rather than `steamos-readonly disable`.
|
||||
- `sudo` on the Frame asks for the user's Developer Mode password. Hand those
|
||||
steps to the user (open Terminal) and keep automation to non-sudo commands.
|
||||
- The Mac uses BSD userland and zsh (no `timeout`, use `head -n`).
|
||||
@@ -0,0 +1,34 @@
|
||||
name: checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.9" # the oldest python3 the Mac app may pick up (Xcode CLT)
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
- name: Script syntax
|
||||
run: |
|
||||
for f in scripts/*.sh frame/*/*.sh; do
|
||||
case "$(head -n 1 "$f")" in
|
||||
*zsh*) zsh -n "$f" ;;
|
||||
*) bash -n "$f" ;;
|
||||
esac
|
||||
done
|
||||
- name: Python compiles
|
||||
run: python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js
|
||||
@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
apk-catalog/data/cache/
|
||||
apk-catalog/data/index-v2.json*
|
||||
compat-db/.env.lakebed.server
|
||||
compat-db/.lakebed/
|
||||
@@ -4,10 +4,14 @@ This repo holds notes and Mac-side helpers for controlling a Valve Steam Frame
|
||||
(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: research written 2026-09-25, then checked against a real Frame the same
|
||||
day (SteamOS 0.3.0, variant `vr`, build 20260922). `connect.sh`, `push.sh`,
|
||||
`paste-to-frame.sh` and `install-apps.sh` work; the rest are still untested. See
|
||||
[docs/open-questions.md](docs/open-questions.md#verified-on-device-2026-09-25).
|
||||
Status: written 2026-09-25 and checked against a real Frame the same day
|
||||
(SteamOS 0.3.0, variant `vr`, build 20260922). The **Frame Control** Mac app
|
||||
and most scripts are **verified** on the device. The scripts table below marks
|
||||
each one, and [docs/open-questions.md](docs/open-questions.md#verified-on-device-2026-09-25)
|
||||
lists what's still unchecked.
|
||||
|
||||
**Quick start:** set up SSH once (next section), then install
|
||||
[Frame Control](#frame-control-mac-app) from the DMG.
|
||||
|
||||
## Minimum typing on the headset
|
||||
|
||||
@@ -67,7 +71,121 @@ enables `sshd`. See [docs/ssh.md](docs/ssh.md#fallback-bootstrap-one-liner).
|
||||
|
||||
Details: [docs/ssh.md](docs/ssh.md), [docs/streaming.md](docs/streaming.md),
|
||||
[docs/file-transfer.md](docs/file-transfer.md),
|
||||
[docs/open-questions.md](docs/open-questions.md).
|
||||
[docs/open-questions.md](docs/open-questions.md). For how the Frame's software
|
||||
fits together, see [docs/how-the-frame-works.md](docs/how-the-frame-works.md).
|
||||
|
||||
## 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](docs/panels.md).
|
||||
|
||||
## Frame Control (Mac app)
|
||||
|
||||
As of 2026-09-25 no other Mac app manages the Frame end to end.
|
||||
[Stream Frame](https://streamframe.app/) (macOS 14+, free) records and screenshots
|
||||
the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is
|
||||
Windows-only. Steam Link views the headset. **Frame Control** is a Mac app over
|
||||
the scripts below. Install it from the DMG (see [Mac app](#mac-app)), or run
|
||||
the same UI in a browser without packaging:
|
||||
|
||||
```sh
|
||||
./scripts/frame-ui.sh # opens http://127.0.0.1:47810 in its own window
|
||||
```
|
||||
|
||||

|
||||
|
||||
- **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; 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
|
||||
with a non-local `Host` header, and any `/api/` request without a custom
|
||||
header, so other websites can't drive it or read captures. It keeps a single multiplexed SSH connection open, so
|
||||
status and each capture take about 0.3s. Headset captures are deleted from the
|
||||
Frame as soon as they're copied, because they show everything on screen,
|
||||
including anything private. The look follows the Steam client: its palette,
|
||||
Motiva Sans (loaded from Valve's CDN), portrait library capsules and green
|
||||
Play buttons. **Verified on the Frame 2026-09-25:** status and charging details,
|
||||
both capture modes (headset view while in use, and a blank frame in standby,
|
||||
which the UI labels), clipboard, volume, file push, and input validation. **Not yet exercised from the UI:** Launch, Flatpak
|
||||
install/remove, APK drop, and the power buttons. Each of these calls a
|
||||
command or script that was verified separately.
|
||||
|
||||
### Mac app
|
||||
|
||||
`app/` wraps the same UI as a standalone Mac app (Electron). The app bundles
|
||||
`ui/`, `scripts/`, `frame/android/` and the rated catalogue from `apk-catalog/`.
|
||||
It starts `ui/server.py` on a free loopback port and shows it in its own window.
|
||||
The server stops when you quit the app. A prebuilt DMG for Apple Silicon is
|
||||
attached to each [GitHub release](https://github.com/saphid/steam-frame/releases).
|
||||
|
||||
```sh
|
||||
cd app
|
||||
npm install
|
||||
npm run dist # → app/dist/Frame Control-<version>-arm64.dmg (and a .zip)
|
||||
npm start # run from the checkout without packaging
|
||||
```
|
||||
|
||||
Open the DMG and drag **Frame Control** to Applications. You need `python3` on
|
||||
the Mac (Xcode Command Line Tools or Homebrew). The app reads `PATH` from your
|
||||
login shell, so Homebrew's `rsync` and `adb` work when you launch it from
|
||||
Finder. On first launch, if there's no `frame` SSH alias yet, 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
|
||||
normally. A copy downloaded from GitHub Releases is quarantined; clear it with
|
||||
`xattr -dr com.apple.quarantine "/Applications/Frame Control.app"`. The first
|
||||
time you use them, macOS asks to allow local network access (for SSH) and
|
||||
control of Terminal (for SSH and power actions). **Verified 2026-09-25:**
|
||||
installed from the DMG, launched from Finder, connected to the Frame, and
|
||||
showed live status and the library.
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -76,20 +194,42 @@ Details: [docs/ssh.md](docs/ssh.md), [docs/streaming.md](docs/streaming.md),
|
||||
| `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 into Lepton with ADB tunnelled over SSH; starts Lepton Development if needed (**verified**; see [docs/apks.md](docs/apks.md) for app compatibility) |
|
||||
| `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.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
|
||||
|
||||
- With Developer Mode on, `sshd`, ADB (Wi-Fi, port 5555, while a Lepton
|
||||
session is running), and xrdp are all reachable on your LAN. Use trusted
|
||||
networks only. Turn Developer Mode off when you don't need it.
|
||||
- With Developer Mode on, `sshd`, ADB and xrdp are all reachable on your LAN.
|
||||
Each running Lepton (Android) instance opens its own ADB port in 5555–5599,
|
||||
listening on `0.0.0.0` rather than only loopback. This was seen on the
|
||||
device on 2026-09-25, so anyone on the network can reach it. Use trusted
|
||||
networks only, and turn Developer Mode off when you don't need it.
|
||||
- Frame Control reaches ADB and the Steam client's DevTools port (Frame
|
||||
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
|
||||
database key lives in the macOS Keychain and is never written to the repo.
|
||||
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
|
||||
you've switched to key auth, a short password still protects `sudo` and
|
||||
RDP, so pick one that isn't trivially guessable.
|
||||
- Don't port-forward 22, 3389, or 5555 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
|
||||
checked).
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests # server guards, validation, Steam helpers; no headset needed
|
||||
cd app && npm install && npm run dist # build the DMG
|
||||
```
|
||||
|
||||
GitHub Actions runs the tests on Python 3.9, which is the oldest `python3` the app
|
||||
may find (Xcode Command Line Tools), plus syntax checks for every script and the
|
||||
Electron main process (`.github/workflows/checks.yml`). Anything that touches the
|
||||
headset is verified by hand against a real Frame, and the docs label it
|
||||
**verified** or **inferred**.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Android app catalogue and compatibility reports
|
||||
|
||||
The data behind Frame Control's **Android apps** section: every app in the
|
||||
F-Droid main repo, rated for Lepton (the Frame's Android container), plus our
|
||||
own compatibility reports. No ProtonDB-style database for sideloaded Android
|
||||
apps on the Frame existed as of 2026-09-25 (Steam Frame Hub and Valve's
|
||||
"Great on Frame" cover Steam games only), so we keep our own.
|
||||
|
||||
```sh
|
||||
scripts/frame-ui.sh # Frame Control → Android apps: search, Install, Test, Report
|
||||
scripts/apk-catalog.sh # refresh the F-Droid data (only scans what changed)
|
||||
```
|
||||
|
||||
## Verdicts
|
||||
|
||||
| Verdict | Meaning |
|
||||
|---|---|
|
||||
| Works on Frame | The latest report says it runs (automated Test or a person's rating) |
|
||||
| Should work | No known blocker found in the APK |
|
||||
| Might work | Something uncertain: Compose version unknown, Godot, Qt, Play Services, no launcher icon (widgets, tiles, keyboards), or a report of issues |
|
||||
| Probably crashes | Compose UI < 1.11, SDL or Kivy |
|
||||
| Won't work | Needs Android 12+ or has no 64-bit ARM build, or a report says it's broken |
|
||||
|
||||
"Should work" means the app opens. Features that need something Lepton lacks
|
||||
(browser links, file picker, Play Services, camera app) can still fail. The
|
||||
rules and the evidence behind them are in [docs/apks.md](../docs/apks.md).
|
||||
|
||||
## Compatibility reports
|
||||
|
||||
Reports live in Frame Control's private database, a Lakebed capsule at
|
||||
`https://frame-compat.lakebed.app` that only the app can read or write (see
|
||||
[compat-db/README.md](../compat-db/README.md), including backups). **Test**
|
||||
records whether an app stays up in its own instance (`result`); **Report**
|
||||
(on any installed app, catalogue card, or **+ Report an APK** for anything else, e.g. an
|
||||
APK file or your own build) records `works`, `issues` or `broken`, how it was run
|
||||
(own instance, Lepton Development, other), where the APK came from, and notes. Each report carries
|
||||
the SteamOS `BUILD_ID` and the Lepton build id. Newest wins, and a person's
|
||||
rating beats an automated result (`reports.py`).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `zipcd.py` | Reads an APK's zip directory and single entries with HTTP range requests |
|
||||
| `scan.py` | Per app: native ABIs, frameworks (from `lib/*.so`), Compose/GMS/Firebase resource names from `resources.arsc`. Writes `data/scan.jsonl` |
|
||||
| `scan2.py` | Per app: Compose UI version, launcher/IME/feature strings from `AndroidManifest.xml`. Writes `data/scan2.jsonl` |
|
||||
| `pick.py` | Which version to rate and install: newest with an arm64 build (or no native code) and minSdk ≤ 30 |
|
||||
| `pins.json` | Versions pinned by hand (F-Droid 1.17.2) |
|
||||
| `reports.py` | How reports override predictions (the reports are in compat-db) |
|
||||
| `build.py` | Applies the rules and writes `site/apps.js` (predictions; Frame Control adds reports at runtime) |
|
||||
|
||||
Frame Control's `ui/frame_catalog.py` loads `site/apps.js`, applies the
|
||||
reports from `ui/frame_compat_db.py`, downloads APKs (SHA-256 checked against the
|
||||
F-Droid index), and installs them with `ui/frame_android.py`.
|
||||
|
||||
Both scans skip apps whose version hasn't changed. Compose is detected by its
|
||||
resource ids (`compose_view_saveable_id_tag`) because many apps strip the
|
||||
`META-INF` version files; those apps are rated "Might work".
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Merge the F-Droid index, both APK scans and the on-device results into
|
||||
site/apps.js, applying the Lepton compatibility rules in docs/apks.md.
|
||||
|
||||
Usage: python3 build.py (run from apk-catalog/, after scan.py and scan2.py)
|
||||
"""
|
||||
import json, os, re, time
|
||||
from pick import pick_version, LEPTON_SDK
|
||||
import reports
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA = os.path.join(HERE, 'data')
|
||||
REPO = 'https://f-droid.org/repo'
|
||||
# Compose UI below this crashes on any Compose screen: it casts the missing
|
||||
# clipboard service to non-null while building AndroidComposeView.
|
||||
COMPOSE_OK = (1, 11)
|
||||
RANK = {'works': 0, 'likely': 1, 'maybe': 2, 'unlikely': 3, 'no': 4}
|
||||
|
||||
|
||||
def jsonl(path):
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
return {r['pkg']: r for r in map(json.loads, open(path))}
|
||||
|
||||
|
||||
def loc(d):
|
||||
if not isinstance(d, dict):
|
||||
return d or ''
|
||||
return d.get('en-US') or d.get('en') or next(iter(d.values()), '')
|
||||
|
||||
|
||||
def ver_tuple(v):
|
||||
m = re.match(r'(\d+)\.(\d+)', v or '')
|
||||
return (int(m[1]), int(m[2])) if m else None
|
||||
|
||||
|
||||
def classify(m, s, s2):
|
||||
"""Return (verdict, reasons). Hard failures first, then crash signals."""
|
||||
no, bad, maybe, notes = [], [], [], []
|
||||
min_sdk = m.get('usesSdk', {}).get('minSdkVersion', 1)
|
||||
if min_sdk > LEPTON_SDK:
|
||||
no.append(f'Needs Android API {min_sdk}; Lepton is Android 11 (API 30), so it won\'t install')
|
||||
native = m.get('nativecode') or s.get('abis') or []
|
||||
if native and 'arm64-v8a' not in native:
|
||||
no.append(f'Native code only for {", ".join(native)}; Lepton is 64-bit ARM only, so it won\'t install')
|
||||
|
||||
cv = s2.get('compose_ver')
|
||||
if cv and ver_tuple(cv) and ver_tuple(cv) < COMPOSE_OK:
|
||||
bad.append(f'Jetpack Compose {cv}: Compose screens crash (no clipboard service); 1.11+ is fine')
|
||||
elif s.get('compose') and not cv:
|
||||
maybe.append('Uses Jetpack Compose, version unknown: crashes if older than 1.11')
|
||||
elif cv:
|
||||
notes.append(f'Jetpack Compose {cv} (fine)')
|
||||
fw = set(s.get('frameworks', []))
|
||||
if 'sdl' in fw or s2.get('sdl3'):
|
||||
bad.append('SDL app: registers a clipboard listener at start-up and crashes')
|
||||
if 'kivy' in fw:
|
||||
bad.append('Kivy (SDL) app: crashes at start-up on the missing clipboard')
|
||||
if 'godot' in fw:
|
||||
maybe.append('Godot: 4.3 crashed (clipboard), 4.6 worked')
|
||||
if 'reactnative' in fw or 'hermes' in fw:
|
||||
notes.append('React Native: 2 of 3 tested apps worked')
|
||||
if 'qt' in fw or 'qt6' in fw:
|
||||
maybe.append('Qt app: the one tested crashed on a missing libc++ symbol')
|
||||
if 'flutter' in fw:
|
||||
notes.append('Flutter (tested apps worked)')
|
||||
if 'gdx' in fw:
|
||||
notes.append('libGDX (tested games worked)')
|
||||
if s.get('gms') or s2.get('gms_meta'):
|
||||
maybe.append('Uses Google Play Services, which Lepton lacks')
|
||||
if s2 and not s2.get('launcher'):
|
||||
if s2.get('ime'):
|
||||
maybe.append('Keyboard (IME), not an app you open; untested in Lepton')
|
||||
else:
|
||||
maybe.append('No launcher icon (widget, tile, wallpaper or plug-in)')
|
||||
feats = s2.get('features', [])
|
||||
if 'android.hardware.touchscreen.multitouch' in feats:
|
||||
notes.append('Mentions multi-touch; the Frame pointer is single-touch (inferred)')
|
||||
if any(f in feats for f in ('android.hardware.telephony', 'android.hardware.nfc')):
|
||||
notes.append('Mentions telephony or NFC, which Lepton lacks')
|
||||
if 'android.hardware.type.watch' in feats:
|
||||
maybe.append('Wear OS watch app')
|
||||
|
||||
if no:
|
||||
return 'no', no + bad + maybe + notes
|
||||
if bad:
|
||||
return 'unlikely', bad + maybe + notes
|
||||
if maybe:
|
||||
return 'maybe', maybe + notes
|
||||
if not s or 'error' in s:
|
||||
return 'maybe', ['APK not scanned'] + notes
|
||||
return 'likely', notes or ['No known blockers']
|
||||
|
||||
|
||||
def finalize(app, reps):
|
||||
"""Set the shown verdict ('r', 'why', 't') from the prediction plus any reports."""
|
||||
rv = reports.verdict(reps)
|
||||
if rv:
|
||||
app['r'], lines = rv
|
||||
app['why'] = lines + ['Rule check: ' + r for r in app['pw'] if not r.startswith('No known')]
|
||||
else:
|
||||
app['r'], app['why'] = app['pr'], list(app['pw'])
|
||||
app['t'] = bool(rv)
|
||||
return app
|
||||
|
||||
|
||||
def load_catalog(path=None):
|
||||
"""Read site/apps.js back into a list (for serve.py and Frame Control)."""
|
||||
src = open(path or os.path.join(HERE, 'site', 'apps.js'), encoding='utf-8').read()
|
||||
return json.loads(src.split('window.APPS=', 1)[1].rstrip().rstrip(';'))
|
||||
|
||||
|
||||
def main():
|
||||
idx = json.load(open(os.path.join(DATA, 'index-v2.json')))
|
||||
s1 = jsonl(os.path.join(DATA, 'scan.jsonl'))
|
||||
s2 = jsonl(os.path.join(DATA, 'scan2.jsonl'))
|
||||
pins = json.load(open(os.path.join(HERE, 'pins.json'))) if os.path.exists(os.path.join(HERE, 'pins.json')) else {}
|
||||
cats = idx.get('repo', {}).get('categories', {})
|
||||
apps = []
|
||||
for pkg, p in idx['packages'].items():
|
||||
if not p.get('versions'):
|
||||
continue
|
||||
v = pick_version(p)
|
||||
md, m = p['metadata'], v['manifest']
|
||||
verdict, why = classify(m, s1.get(pkg, {}), s2.get(pkg, {}))
|
||||
apk, sha, shown_ver = REPO + v['file']['name'], v['file'].get('sha256'), m.get('versionName')
|
||||
pin = pins.get(pkg)
|
||||
if pin:
|
||||
apk, sha, shown_ver = pin['apk'], pin['sha256'], pin['version']
|
||||
why = [pin['why']] + why
|
||||
icon = loc(md.get('icon'))
|
||||
apps.append(finalize({
|
||||
'p': pkg,
|
||||
'n': loc(md.get('name')) or pkg,
|
||||
's': loc(md.get('summary')),
|
||||
'c': [loc(cats.get(c, {}).get('name')) or c for c in md.get('categories', [])],
|
||||
'i': REPO + icon['name'] if isinstance(icon, dict) and icon.get('name') else '',
|
||||
'v': shown_ver,
|
||||
'z': v['file'].get('size'),
|
||||
'u': md.get('lastUpdated'),
|
||||
'a': apk,
|
||||
'h': sha,
|
||||
'af': sorted(v.get('antiFeatures', {}).keys()),
|
||||
'pr': verdict,
|
||||
'pw': why,
|
||||
}, None))
|
||||
apps.sort(key=lambda a: (RANK[a['r']], a['n'].lower()))
|
||||
out = os.path.join(HERE, 'site', 'apps.js')
|
||||
meta = {'built': time.strftime('%Y-%m-%d'), 'count': len(apps),
|
||||
'source': 'F-Droid main repo, rated on the newest version each app has that Lepton can install'}
|
||||
with open(out, 'w') as f:
|
||||
f.write('window.CATALOG_META=' + json.dumps(meta) + ';\n')
|
||||
f.write('window.APPS=' + json.dumps(apps, separators=(',', ':'), ensure_ascii=False) + ';\n')
|
||||
counts = {k: sum(a['r'] == k for a in apps) for k in RANK}
|
||||
print(out, counts)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
"""Choose which F-Droid version of an app to rate and install.
|
||||
|
||||
F-Droid often publishes one APK per ABI under different version codes, and
|
||||
the highest code is frequently the x86_64 build. Prefer the newest version
|
||||
Lepton can install (arm64-v8a or no native code, minSdk <= 30), else the newest.
|
||||
"""
|
||||
LEPTON_SDK = 30
|
||||
|
||||
|
||||
def installable(v):
|
||||
m = v['manifest']
|
||||
native = m.get('nativecode') or []
|
||||
return (not native or 'arm64-v8a' in native) and \
|
||||
m.get('usesSdk', {}).get('minSdkVersion', 1) <= LEPTON_SDK
|
||||
|
||||
|
||||
def pick_version(p):
|
||||
vs = sorted(p['versions'].values(), key=lambda v: v['manifest'].get('versionCode', 0), reverse=True)
|
||||
return next((v for v in vs if installable(v)), vs[0])
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"org.fdroid.fdroid": {
|
||||
"apk": "https://f-droid.org/archive/org.fdroid.fdroid_1017002.apk",
|
||||
"sha256": "756b7dfc7fb43ef28c27d276428a2f7826cd482fd794bbd9eeaef24016b2081c",
|
||||
"version": "1.17.2",
|
||||
"why": "Pinned to 1.17.2: 1.23.2 crashed (old Compose). 2.0 uses Compose 1.12 but is untested. Don't let it update itself."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""How compatibility reports turn into a verdict. The reports themselves live
|
||||
in Frame Control's private database (ui/frame_compat_db.py, a Lakebed capsule
|
||||
in compat-db/); this module is the pure logic shared by the build and the app.
|
||||
|
||||
A report: package, version, result (runs | crashes | install_failed |
|
||||
instance_failed, from an automated test), rating (works | issues | broken, from
|
||||
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
|
||||
Newest wins, and a person's rating beats an automated result.
|
||||
"""
|
||||
|
||||
|
||||
def verdict(reports):
|
||||
"""(verdict, summary lines) for one package's reports, or None."""
|
||||
if not reports:
|
||||
return None
|
||||
rs = sorted(reports, key=lambda r: r.get('date') or '')
|
||||
people = [r for r in rs if r.get('rating')]
|
||||
best = people[-1] if people else rs[-1]
|
||||
kind = best.get('rating') or best.get('result')
|
||||
v = {'works': 'works', 'runs': 'works', 'issues': 'maybe'}.get(kind, 'no')
|
||||
n_ok = sum((r.get('rating') or r.get('result')) in ('works', 'runs') for r in rs)
|
||||
lines = [f"Reported on a Frame {(best.get('date') or '')[:10]} (v{best.get('version')}): "
|
||||
f"{kind}{' – ' + best['notes'] if best.get('notes') else ''}"]
|
||||
if len(rs) > 1:
|
||||
lines.append(f'{len(rs)} reports, {n_ok} working')
|
||||
return v, lines
|
||||
|
||||
|
||||
def by_package(reports):
|
||||
out = {}
|
||||
for r in reports:
|
||||
out.setdefault(r['package'], []).append(r)
|
||||
return out
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Scan F-Droid APKs (latest version per app) for Steam Frame / Lepton signals.
|
||||
|
||||
Reads only the zip central directory and the resources.arsc key-string pool
|
||||
through HTTP range requests. Output: one JSON line per package (resumable).
|
||||
"""
|
||||
import json, os, struct, sys, zlib, threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import zipcd
|
||||
from pick import pick_version
|
||||
|
||||
REPO = 'https://f-droid.org/repo'
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
IDX = os.path.join(HERE, 'data', 'index-v2.json')
|
||||
OUT = os.path.join(HERE, 'data', 'scan.jsonl')
|
||||
|
||||
KEYS = {
|
||||
'compose': [b'compose_view_saveable_id_tag', b'wrapped_composition_tag',
|
||||
b'androidx_compose_ui_view_compositionlocal_map'],
|
||||
'gms': [b'common_google_play_services_unknown_issue',
|
||||
b'common_google_play_services_install_title'],
|
||||
'firebase': [b'google_app_id', b'gcm_defaultSenderId'],
|
||||
}
|
||||
LIBS = {
|
||||
'unity': 'libunity.so', 'flutter': 'libflutter.so', 'reactnative': 'libreactnative',
|
||||
'hermes': 'libhermes', 'godot': 'libgodot_android.so', 'gdx': 'libgdx.so',
|
||||
'sdl': 'libSDL2.so', 'unreal': 'libUE4.so', 'unreal5': 'libUnreal.so',
|
||||
'xamarin': 'libmonodroid.so', 'qt': 'libQt5Core', 'qt6': 'libQt6Core',
|
||||
'cocos': 'libcocos', 'love': 'liblove.so', 'renpy': 'librenpy',
|
||||
'kivy': 'libpython', 'gomobile': 'libgojni.so',
|
||||
}
|
||||
|
||||
|
||||
def arsc_keys(url, entries):
|
||||
comp, csize, lho = entries['resources.arsc']
|
||||
h = zipcd.rng(url, lho, lho + 29)
|
||||
nl, el = struct.unpack('<HH', h[26:30])
|
||||
base = lho + 30 + nl + el
|
||||
if comp == 8:
|
||||
blob = zlib.decompress(zipcd.rng(url, base, base + csize - 1), -15)
|
||||
read = lambda o, n: blob[o:o + n]
|
||||
elif comp == 0:
|
||||
read = lambda o, n: zipcd.rng(url, base + o, base + o + n - 1)
|
||||
else:
|
||||
raise ValueError(f'arsc compression {comp}')
|
||||
th = read(0, 12)
|
||||
if struct.unpack('<H', th[:2])[0] != 2:
|
||||
raise ValueError('not a ResTable')
|
||||
off = struct.unpack('<H', th[2:4])[0]
|
||||
pools = []
|
||||
total = struct.unpack('<I', th[4:8])[0]
|
||||
while off < total:
|
||||
ch = read(off, 8)
|
||||
ctype, chdr, csz = struct.unpack('<HHI', ch)
|
||||
if ctype == 0x0200: # package
|
||||
ph = read(off, 288)
|
||||
key_off = struct.unpack('<I', ph[8 + 4 + 256 + 8:8 + 4 + 256 + 12])[0]
|
||||
kh = read(off + key_off, 8)
|
||||
ksz = struct.unpack('<I', kh[4:8])[0]
|
||||
pools.append(read(off + key_off, ksz))
|
||||
if csz <= 0:
|
||||
break
|
||||
off += csz
|
||||
return b''.join(pools)
|
||||
|
||||
|
||||
def scan(pkg, meta, ver):
|
||||
f = ver['file']
|
||||
url = REPO + f['name']
|
||||
m = ver['manifest']
|
||||
r = {'pkg': pkg, 'vc': m.get('versionCode'), 'vn': m.get('versionName'),
|
||||
'apk': url, 'size': f.get('size')}
|
||||
try:
|
||||
names, entries, _ = zipcd.list_names(url, f['size'])
|
||||
libs = {n for n in names if n.startswith('lib/')}
|
||||
r['frameworks'] = sorted(k for k, s in LIBS.items() if any(s in n for n in libs))
|
||||
r['abis'] = sorted({n.split('/')[1] for n in libs if n.count('/') >= 2})
|
||||
r['metainf_compose'] = any(n.startswith('META-INF/androidx.compose.ui') for n in names)
|
||||
r['assets_bin_data'] = any(n.startswith('assets/bin/Data/') for n in names)
|
||||
if 'resources.arsc' in entries:
|
||||
kp = arsc_keys(url, entries)
|
||||
for k, pats in KEYS.items():
|
||||
r[k] = any(p in kp or p.decode().encode('utf-16-le') in kp for p in pats)
|
||||
else:
|
||||
r['no_arsc'] = True
|
||||
except Exception as e: # keep going; record the failure
|
||||
r['error'] = f'{type(e).__name__}: {e}'[:200]
|
||||
return r
|
||||
|
||||
|
||||
def main():
|
||||
idx = json.load(open(IDX))
|
||||
done = set()
|
||||
if os.path.exists(OUT):
|
||||
for line in open(OUT):
|
||||
try:
|
||||
r = json.loads(line)
|
||||
done.add((r['pkg'], r.get('vc')))
|
||||
except Exception:
|
||||
pass
|
||||
jobs = []
|
||||
for pkg, p in idx['packages'].items():
|
||||
if not p.get('versions'):
|
||||
continue
|
||||
ver = pick_version(p)
|
||||
if (pkg, ver['manifest'].get('versionCode')) not in done:
|
||||
jobs.append((pkg, p['metadata'], ver))
|
||||
print(f'{len(done)} done, {len(jobs)} to scan', flush=True)
|
||||
lock = threading.Lock()
|
||||
n = 0
|
||||
with open(OUT, 'a') as out, ThreadPoolExecutor(int(os.environ.get('WORKERS', '12'))) as ex:
|
||||
futs = [ex.submit(scan, *j) for j in jobs]
|
||||
for fu in as_completed(futs):
|
||||
r = fu.result()
|
||||
with lock:
|
||||
out.write(json.dumps(r) + '\n'); out.flush()
|
||||
n += 1
|
||||
if n % 100 == 0:
|
||||
print(f'{n}/{len(jobs)}', flush=True)
|
||||
print('finished', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Second pass: Compose UI version, launcher activity, GMS meta-data, uses-feature
|
||||
strings from AndroidManifest.xml (binary XML string pool). Resumable JSONL."""
|
||||
import json, os, struct, sys, threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import zipcd
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
IN = os.path.join(HERE, 'data', 'scan.jsonl')
|
||||
OUT = os.path.join(HERE, 'data', 'scan2.jsonl')
|
||||
FEATURES = ['android.hardware.touchscreen.multitouch', 'android.hardware.telephony',
|
||||
'android.hardware.nfc', 'android.hardware.bluetooth_le', 'android.hardware.usb.host',
|
||||
'android.hardware.camera', 'android.hardware.vr.high_performance',
|
||||
'android.software.leanback', 'android.hardware.type.watch']
|
||||
|
||||
|
||||
def axml_strings(b):
|
||||
# ResXMLTree_header (8) then string pool chunk
|
||||
off = struct.unpack('<H', b[2:4])[0]
|
||||
t, hs, sz, cnt, _styles, flags, sstart, _ = struct.unpack('<HHIIIIII', b[off:off + 28])
|
||||
utf8 = flags & 0x100
|
||||
offs = struct.unpack(f'<{cnt}I', b[off + hs:off + hs + 4 * cnt])
|
||||
base = off + sstart
|
||||
out = []
|
||||
for o in offs:
|
||||
p = base + o
|
||||
if utf8:
|
||||
n = b[p]; p += 2 if n & 0x80 else 1
|
||||
n = b[p]; hi = n & 0x80
|
||||
if hi:
|
||||
n = ((n & 0x7f) << 8) | b[p + 1]; p += 2
|
||||
else:
|
||||
p += 1
|
||||
out.append(b[p:p + n].decode('utf-8', 'replace'))
|
||||
else:
|
||||
n = struct.unpack('<H', b[p:p + 2])[0]; p += 2
|
||||
if n & 0x8000:
|
||||
n = ((n & 0x7fff) << 16) | struct.unpack('<H', b[p:p + 2])[0]; p += 2
|
||||
out.append(b[p:p + 2 * n].decode('utf-16-le', 'replace'))
|
||||
return out
|
||||
|
||||
|
||||
def scan(r):
|
||||
o = {'pkg': r['pkg'], 'vc': r.get('vc')}
|
||||
try:
|
||||
names, ent, _ = zipcd.list_names(r['apk'], r['size'])
|
||||
for n in ('META-INF/androidx.compose.ui_ui.version', 'META-INF/androidx.compose.ui_ui-android.version'):
|
||||
if n in ent:
|
||||
o['compose_ver'] = zipcd.read_entry(r['apk'], ent, n).decode().strip()
|
||||
break
|
||||
o['sdl3'] = any(n.endswith('/libSDL3.so') for n in names)
|
||||
s = set(axml_strings(zipcd.read_entry(r['apk'], ent, 'AndroidManifest.xml')))
|
||||
o['launcher'] = 'android.intent.category.LAUNCHER' in s
|
||||
o['leanback_launcher'] = 'android.intent.category.LEANBACK_LAUNCHER' in s
|
||||
o['gms_meta'] = 'com.google.android.gms.version' in s
|
||||
o['ime'] = 'android.view.InputMethod' in s
|
||||
o['features'] = [f for f in FEATURES if f in s]
|
||||
except Exception as e:
|
||||
o['error2'] = f'{type(e).__name__}: {e}'[:200]
|
||||
return o
|
||||
|
||||
|
||||
def main():
|
||||
rows = list({r['pkg']: r for r in map(json.loads, open(IN))}.values()) # latest per app
|
||||
done = set()
|
||||
if os.path.exists(OUT):
|
||||
done = {(o['pkg'], o.get('vc')) for o in map(json.loads, open(OUT))}
|
||||
rows = [r for r in rows if (r['pkg'], r.get('vc')) not in done and 'error' not in r]
|
||||
print(len(done), 'done', len(rows), 'todo', flush=True)
|
||||
lock = threading.Lock(); n = 0
|
||||
with open(OUT, 'a') as out, ThreadPoolExecutor(int(os.environ.get('WORKERS', '40'))) as ex:
|
||||
for fu in as_completed([ex.submit(scan, r) for r in rows]):
|
||||
with lock:
|
||||
out.write(json.dumps(fu.result()) + '\n'); out.flush(); n += 1
|
||||
if n % 200 == 0:
|
||||
print(n, flush=True)
|
||||
print('finished', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
window.CATALOG_META={"built": "2026-09-25", "count": 4455, "source": "F-Droid main repo, rated on the newest version each app has that Lepton can install"};
|
||||
window.APPS=[{"p":"com.terokarvinen.x54ask","n":"0x54ask","s":"Todo.txt manager. Offline, works with Syncthing. Fork of SimpleTask Cloudless","c":["Calendar & Agenda","Note","Task"],"i":"https://f-droid.org/repo/com.terokarvinen.x54ask/en-US/icon_FOzSYq6etfsaWRiMc7bx-8vLVKtsug1dmhT9NvxRj9w=.png","v":"1.1.2 (fork of Simpletask)","z":13037003,"u":1788427366142,"a":"https://f-droid.org/repo/com.terokarvinen.x54ask_1010200.apk","h":"f05781226bb84205caa5b5aa6a511afcb8df86de8bc4b53e33b7de34c2940e8a","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.github.ashutoshgngwr.tenbitclockwidget","n":"10-bit Clock Widget","s":"A beautiful BCD clock for your home screen","c":["Clock"],"i":"https://f-droid.org/repo/com.github.ashutoshgngwr.tenbitclockwidget/en-US/icon_TrUyJLRoXZGniCc2uQM3OnsVmlOokr_KZk0ZQaPrtjY=.png","v":"2.2-1","z":1281564,"u":1696789501000,"a":"https://f-droid.org/repo/com.github.ashutoshgngwr.tenbitclockwidget_221.apk","h":"35ff9940fd3d73acd1099f3640be6367c311ec9f6c34fa17e4748e874ecfe763","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"dev.lonami.klooni","n":"1010! Klooni","s":"A libGDX game based on 1010","c":["Puzzle Game"],"i":"https://f-droid.org/repo/icons/dev.lonami.klooni.860.png","v":"0.8.6","z":2735506,"u":1598918400000,"a":"https://f-droid.org/repo/dev.lonami.klooni_860.apk","h":"55641cdb5dba7f30c1d229cf8a34f390a8ff6b3f60cdff9b45d277919f33ce24","af":[],"pr":"likely","pw":["libGDX (tested games worked)"],"r":"likely","why":["libGDX (tested games worked)"],"t":false},{"p":"eu.quelltext.counting","n":"12345 - Learn Counting","s":"Learn counting in different languages with pictures","c":["Educational Game","Science & Education"],"i":"https://f-droid.org/repo/eu.quelltext.counting/en-US/icon_30ymRTCTMZiTzSNXPRLEOukBSubDfmp1CV_cpbGudKw=.png","v":"1.3","z":2413060,"u":1646352000000,"a":"https://f-droid.org/repo/eu.quelltext.counting_3.apk","h":"98fe65f21ff8e51918b94e80d25d99d52f5527d24a69dcd8dd9ca1a5da9b7a02","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.roufsyed.onekey","n":"1Key Password Manager","s":"Offline password manager. 2FA + notes. No account, no network, no telemetry.","c":["Password & 2FA","Security"],"i":"https://f-droid.org/repo/com.roufsyed.onekey/en-US/icon_7Oq_UnE5rGthf-UdC05ENbWiZZe00b9J8cKU2qrdVMQ=.png","v":"1.1.1","z":4738420,"u":1784362608829,"a":"https://f-droid.org/repo/com.roufsyed.onekey_3.apk","h":"690a58bb75780d9f835183ae6deb563e06db659218a4275ddd40ba15353d66ce","af":[],"pr":"likely","pw":["Jetpack Compose 1.11.2 (fine)"],"r":"likely","why":["Jetpack Compose 1.11.2 (fine)"],"t":false},{"p":"org.og8.a1tox","n":"1toX","s":"Remember numbers quick to train your brain","c":["Educational Game"],"i":"https://f-droid.org/repo/icons/org.og8.a1tox.1.png","v":"1.00","z":639637,"u":1567641600000,"a":"https://f-droid.org/repo/org.og8.a1tox_1.apk","h":"34895a84a638d53bd5ed57d134511eee9468f5461cb0e41874a1968ac256e4c8","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.dasp.worldcup2026","n":"2026 Football Fixtures Widget","s":"2026 football fixtures and widgets.","c":["Sports & Health"],"i":"","v":"0.1.0","z":33934,"u":1780506857489,"a":"https://f-droid.org/repo/com.dasp.worldcup2026_1.apk","h":"8c7b60c9cef5a6343f000f12ff0a0714bc6f3de72ced17c57f1ce4a147bc4a67","af":["NonFreeNet"],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"org.secuso.privacyfriendly2048","n":"2048 (Privacy Friendly)","s":"(SECUSO) Try to reach 2048 in this puzzle game","c":["Puzzle Game"],"i":"https://f-droid.org/repo/org.secuso.privacyfriendly2048/en-US/icon__EtkwPp725lQQYnzjkzDUiOqD2X5nnY1CiZSIYN9TVU=.png","v":"1.4.2","z":9294779,"u":1753701498000,"a":"https://f-droid.org/repo/org.secuso.privacyfriendly2048_100.apk","h":"02c799d3d582669daf2acf920093c68d2933f60aa937bb72fa2a805557233fe8","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"org.mattvchandler.a2050","n":"2050","s":"A game loosely based on 2048, but with circles instead of squares","c":["Puzzle Game"],"i":"https://f-droid.org/repo/org.mattvchandler.a2050/en-US/icon_3BMQD76YZDYHbtVP8WR8CTKi6E7pd6L82YveKdLHjR4=.png","v":"1.0.10","z":5079962,"u":1693608133000,"a":"https://f-droid.org/repo/org.mattvchandler.a2050_190010010.apk","h":"98a0e75e589c319093db56cf98bfa32d920b9436a9cbe7c30b32dcf7a4a6d284","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"nl.eventinfra.wifisetup","n":"37C3 Wifi Setup","s":"Official NOC application for connecting to the 36C3 Wi-Fi","c":["Connectivity"],"i":"","v":"0.37","z":2405866,"u":1729155289000,"a":"https://f-droid.org/repo/nl.eventinfra.wifisetup_20231222.apk","h":"aa0ca052e9e48ad7945f9aa535f5fd691018a5356a8ff95b0e5bf94662a54a10"Line truncated
|
||||
@@ -0,0 +1,31 @@
|
||||
import struct, urllib.request, zlib
|
||||
UA={'User-Agent':'steam-frame-compat-scan/1.0'}
|
||||
def rng(url, start, end=None):
|
||||
h=dict(UA); h['Range']=f'bytes={start}-' if end is None else f'bytes={start}-{end}'
|
||||
with urllib.request.urlopen(urllib.request.Request(url,headers=h),timeout=60) as r:
|
||||
return r.read()
|
||||
def tail(url, n):
|
||||
h=dict(UA); h['Range']=f'bytes=-{n}'
|
||||
with urllib.request.urlopen(urllib.request.Request(url,headers=h),timeout=60) as r:
|
||||
return r.read()
|
||||
def list_names(url, size):
|
||||
t=tail(url, min(size, 65557))
|
||||
i=t.rfind(b'PK\x05\x06')
|
||||
if i<0: raise ValueError('no EOCD')
|
||||
cd_size, cd_off = struct.unpack('<II', t[i+12:i+20])
|
||||
base=size-len(t)
|
||||
cd = t[cd_off-base:cd_off-base+cd_size] if cd_off>=base else rng(url, cd_off, cd_off+cd_size-1)
|
||||
names=[]; p=0; entries={}
|
||||
while p+46<=len(cd) and cd[p:p+4]==b'PK\x01\x02':
|
||||
comp,=struct.unpack('<H',cd[p+10:p+12])
|
||||
csize,usize=struct.unpack('<II',cd[p+20:p+28])
|
||||
nl,el,cl=struct.unpack('<HHH',cd[p+28:p+34]); lho,=struct.unpack('<I',cd[p+42:p+46])
|
||||
n=cd[p+46:p+46+nl].decode('utf-8','replace'); names.append(n); entries[n]=(comp,csize,lho)
|
||||
p+=46+nl+el+cl
|
||||
return names, entries, cd_size
|
||||
def read_entry(url, entries, name):
|
||||
comp,csize,lho=entries[name]
|
||||
h=rng(url, lho, lho+29)
|
||||
nl,el=struct.unpack('<HH',h[26:30])
|
||||
data=rng(url, lho+30+nl+el, lho+30+nl+el+csize-1)
|
||||
return zlib.decompress(data,-15) if comp==8 else data
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1a9fff"/>
|
||||
<stop offset="1" stop-color="#6f42c1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="visor" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#dfe8f5"/>
|
||||
</linearGradient>
|
||||
<clipPath id="tile"><rect x="100" y="100" width="824" height="824" rx="185"/></clipPath>
|
||||
<mask id="nose">
|
||||
<rect width="1024" height="1024" fill="#fff"/>
|
||||
<ellipse cx="512" cy="690" rx="78" ry="96" fill="#000"/>
|
||||
</mask>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#0b1020" flood-opacity=".35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<g clip-path="url(#tile)">
|
||||
<rect x="100" y="100" width="824" height="824" fill="url(#bg)"/>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="222" y="350" width="580" height="320" rx="130" fill="url(#visor)" mask="url(#nose)"/>
|
||||
</g>
|
||||
<rect x="300" y="430" width="160" height="124" rx="50" fill="#13233a"/>
|
||||
<rect x="564" y="430" width="160" height="124" rx="50" fill="#13233a"/>
|
||||
<rect x="320" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
|
||||
<rect x="584" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,32 @@
|
||||
// Renders build/icon.svg to icon.png (1024px) and icon.icns. Run: npm run icon
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { execFileSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
app.dock?.hide();
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ width: 1024, height: 1024, show: false, transparent: true, frame: false,
|
||||
useContentSize: true, webPreferences: { offscreen: true } });
|
||||
const svg = fs.readFileSync(path.join(__dirname, "icon.svg"), "utf8");
|
||||
await win.loadURL("data:text/html," + encodeURIComponent(
|
||||
`<body style="margin:0;background:transparent">${svg}</body>`));
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const png = (await win.webContents.capturePage({ x: 0, y: 0, width: 1024, height: 1024 }))
|
||||
.resize({ width: 1024, height: 1024 }).toPNG();
|
||||
fs.writeFileSync(path.join(__dirname, "icon.png"), png);
|
||||
|
||||
const set = fs.mkdtempSync(path.join(os.tmpdir(), "icon-")) + "/icon.iconset";
|
||||
fs.mkdirSync(set);
|
||||
for (const size of [16, 32, 128, 256, 512]) {
|
||||
for (const scale of [1, 2]) {
|
||||
const px = size * scale, name = `icon_${size}x${size}${scale === 2 ? "@2x" : ""}.png`;
|
||||
execFileSync("sips", ["-z", String(px), String(px), path.join(__dirname, "icon.png"),
|
||||
"--out", path.join(set, name)], { stdio: "ignore" });
|
||||
}
|
||||
}
|
||||
execFileSync("iconutil", ["-c", "icns", set, "-o", path.join(__dirname, "icon.icns")]);
|
||||
console.log("wrote build/icon.png and build/icon.icns");
|
||||
app.quit();
|
||||
});
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
// Frame Control as a Mac app: starts ui/server.py on a free loopback port and
|
||||
// shows it in a native window. The server does all the work over the `frame`
|
||||
// SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
|
||||
const { execFile, execFileSync, spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
// Packaged: Contents/Resources/{ui,scripts}. Dev: the repo checkout.
|
||||
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
|
||||
const SERVER = path.join(ROOT, "ui", "server.py");
|
||||
const SCRIPTS = path.join(ROOT, "scripts");
|
||||
const LOG_DIR = path.join(os.homedir(), "Library", "Logs", "Frame Control");
|
||||
const LOG = path.join(LOG_DIR, "server.log");
|
||||
const BG = "#0d1117";
|
||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||
|
||||
let server = null;
|
||||
let url = null;
|
||||
let win = null;
|
||||
let quitting = false;
|
||||
|
||||
// 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.
|
||||
let cachedPath = null;
|
||||
function loginPath() {
|
||||
if (cachedPath) return cachedPath;
|
||||
const shellPath = process.env.SHELL || "/bin/zsh";
|
||||
const extra = ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")];
|
||||
let fromShell = "";
|
||||
try {
|
||||
const out = execFileSync(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] });
|
||||
fromShell = (out.match(/__PATH__(.*)__PATH__/) || [])[1] || "";
|
||||
} catch {}
|
||||
const parts = [...fromShell.split(":"), ...(process.env.PATH || "").split(":"), ...extra];
|
||||
cachedPath = [...new Set(parts.filter(Boolean))].join(":");
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
function findPython(env) {
|
||||
for (const dir of env.PATH.split(":")) {
|
||||
const p = path.join(dir, "python3");
|
||||
try {
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
// /usr/bin/python3 is a stub until the Command Line Tools are installed.
|
||||
execFileSync(p, ["-c", "import http.server"], { timeout: 10000, stdio: "ignore", env });
|
||||
return p;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = net.createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); });
|
||||
});
|
||||
}
|
||||
|
||||
// Ready once the port answers with our Server header.
|
||||
function ping(target) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(target, { timeout: 1000 }, (res) => {
|
||||
res.resume();
|
||||
resolve(/^FrameControl/.test(res.headers.server || ""));
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.on("timeout", () => { req.destroy(); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const env = { ...process.env, PATH: loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" };
|
||||
const python = findPython(env);
|
||||
if (!python) {
|
||||
throw new Error("Frame Control needs python3. Install the Xcode Command Line Tools "
|
||||
+ "(xcode-select --install) or Homebrew's python, then reopen the app.");
|
||||
}
|
||||
const port = await freePort();
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
const log = fs.openSync(LOG, "a");
|
||||
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
|
||||
const child = spawn(python, [SERVER, "--port", String(port)], { env, stdio: ["ignore", log, log] });
|
||||
fs.closeSync(log);
|
||||
server = child;
|
||||
let exited = null;
|
||||
child.once("error", (err) => {
|
||||
exited = err.message;
|
||||
if (server === child) { server = null; if (!quitting && url) serverDied(err.message); }
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
exited = signal || code;
|
||||
if (server !== child) return; // replaced by Restart Server
|
||||
server = null;
|
||||
if (!quitting && url) serverDied(exited);
|
||||
});
|
||||
|
||||
const target = `http://127.0.0.1:${port}/`;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
||||
if (await ping(target)) { url = target; return; }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
|
||||
}
|
||||
|
||||
function stopServer() {
|
||||
// server.py handles SIGTERM by closing its shared SSH connection.
|
||||
if (server) server.kill("SIGTERM");
|
||||
}
|
||||
|
||||
function errorPage(message) {
|
||||
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
|
||||
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
|
||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
|
||||
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
|
||||
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
|
||||
}
|
||||
|
||||
function serverDied(why) {
|
||||
url = null;
|
||||
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
const old = server;
|
||||
server = null;
|
||||
url = null;
|
||||
if (old) old.kill("SIGTERM");
|
||||
await load();
|
||||
}
|
||||
|
||||
// The page's sticky header becomes the title bar, clear of the traffic lights.
|
||||
const CHROME_CSS = `
|
||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
||||
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
||||
`;
|
||||
|
||||
// Restart Server can start a new load while an older one is still waiting for
|
||||
// its server; only the newest load may touch the window.
|
||||
let loadGen = 0;
|
||||
async function load() {
|
||||
const gen = ++loadGen;
|
||||
try {
|
||||
if (!url) await startServer();
|
||||
if (gen === loadGen && win) await win.loadURL(url);
|
||||
} catch (e) {
|
||||
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
// `ssh -G` prints the effective config. An alias nobody configured keeps its
|
||||
// own name as HostName and the Mac user as User; connect.sh sets both.
|
||||
function aliasConfigured(env) {
|
||||
try {
|
||||
const out = execFileSync("ssh", ["-G", FRAME], { encoding: "utf8", timeout: 5000, env,
|
||||
stdio: ["ignore", "pipe", "ignore"] });
|
||||
const get = (k) => (out.match(new RegExp(`^${k} (.*)$`, "m")) || [])[1];
|
||||
return get("hostname") !== FRAME || get("user") !== os.userInfo().username;
|
||||
} catch {
|
||||
return true; // can't tell; don't nag
|
||||
}
|
||||
}
|
||||
|
||||
async function firstRunCheck() {
|
||||
if (aliasConfigured({ ...process.env, PATH: loginPath() })) return;
|
||||
const { response } = await dialog.showMessageBox(win, {
|
||||
type: "info",
|
||||
message: "Connect to your Steam Frame",
|
||||
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Developer Mode and set `
|
||||
+ "a user password (Steam Settings → System). Then run the setup script: it finds the "
|
||||
+ "headset, creates a key, and asks for that password once in Terminal.",
|
||||
buttons: ["Set Up Connection…", "Later"],
|
||||
defaultId: 0, cancelId: 1,
|
||||
});
|
||||
if (response === 0) setUpConnection();
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||
title: "Frame Control", backgroundColor: BG, show: false,
|
||||
titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 },
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||
});
|
||||
win.once("ready-to-show", () => { win.show(); firstRunCheck(); });
|
||||
win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
|
||||
// External links open in the default browser; the app never navigates away.
|
||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||
if (/^https?:\/\//.test(target)) shell.openExternal(target);
|
||||
return { action: "deny" };
|
||||
});
|
||||
win.webContents.on("will-navigate", (e, target) => {
|
||||
if (!url || new URL(target).origin !== new URL(url).origin) e.preventDefault();
|
||||
});
|
||||
win.on("closed", () => { win = null; });
|
||||
load();
|
||||
}
|
||||
|
||||
// Runs in Terminal because ssh-copy-id asks for the Developer Mode password.
|
||||
function runInTerminal(command) {
|
||||
const quoted = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
execFile("osascript", ["-e", 'tell application "Terminal"', "-e", `do script "${quoted}"`,
|
||||
"-e", "activate", "-e", "end tell"], (err) => {
|
||||
if (err) dialog.showErrorBox("Couldn't open Terminal", String(err.message || err));
|
||||
});
|
||||
}
|
||||
|
||||
const sh = (s) => `'${s.replace(/'/g, "'\\''")}'`;
|
||||
|
||||
function setUpConnection() {
|
||||
runInTerminal(`FRAME_ALIAS=${sh(FRAME)} ${sh(path.join(SCRIPTS, "connect.sh"))}`);
|
||||
}
|
||||
|
||||
function buildMenu() {
|
||||
const template = [
|
||||
{ role: "appMenu" },
|
||||
{ role: "fileMenu" },
|
||||
{ role: "editMenu" },
|
||||
{
|
||||
label: "Frame",
|
||||
submenu: [
|
||||
{ label: "Set Up Connection…", click: setUpConnection },
|
||||
{ label: "Open SSH in Terminal", click: () => runInTerminal(`ssh ${sh(FRAME)}`) },
|
||||
{ type: "separator" },
|
||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||
{ label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) },
|
||||
{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" },
|
||||
{ type: "separator" }, { role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
{ role: "windowMenu" },
|
||||
{
|
||||
role: "help",
|
||||
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
|
||||
},
|
||||
];
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
|
||||
});
|
||||
app.whenReady().then(() => {
|
||||
buildMenu();
|
||||
createWindow();
|
||||
});
|
||||
app.on("activate", () => { if (!win) createWindow(); });
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
app.on("before-quit", () => { quitting = true; stopServer(); });
|
||||
process.on("exit", stopServer);
|
||||
}
|
||||
Generated
+3591
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.1.0",
|
||||
"description": "Mac app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||
"dist": "electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "electron-builder --mac --arm64 --dir"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
"electron-builder": "^26.15.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.saphid.frame-control",
|
||||
"productName": "Frame Control",
|
||||
"directories": {
|
||||
"output": "dist",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../ui",
|
||||
"to": "ui",
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../scripts",
|
||||
"to": "scripts",
|
||||
"filter": [
|
||||
"*.sh"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../frame/android",
|
||||
"to": "frame/android",
|
||||
"filter": [
|
||||
"*.sh",
|
||||
"*.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../apk-catalog",
|
||||
"to": "apk-catalog",
|
||||
"filter": [
|
||||
"*.py",
|
||||
"pins.json",
|
||||
"site/apps.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.utilities",
|
||||
"icon": "build/icon.icns",
|
||||
"identity": "-",
|
||||
"hardenedRuntime": false,
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"extendInfo": {
|
||||
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
|
||||
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
|
||||
}
|
||||
},
|
||||
"dmg": {
|
||||
"title": "Frame Control ${version}"
|
||||
},
|
||||
"electronFuses": {
|
||||
"runAsNode": false,
|
||||
"enableNodeOptionsEnvironmentVariable": false,
|
||||
"enableNodeCliInspectArguments": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.lakebed/
|
||||
.env.lakebed.server
|
||||
@@ -0,0 +1,88 @@
|
||||
# Lakebed app instructions
|
||||
|
||||
Treat this capsule directory as the whole app. Use Lakebed's built-in APIs and CLI.
|
||||
|
||||
## Limits to check first
|
||||
|
||||
- The public alpha is not production-ready.
|
||||
- App code cannot use arbitrary npm packages or Node built-ins. Do not install app dependencies.
|
||||
- Database fields support `string()`, `boolean()`, `number()`, `id(...)`, and `userId()`. Chain `.optional()` or `.default(value)` on any field.
|
||||
- Local database data and uploaded files reset when the dev server restarts.
|
||||
- Hosted server secrets and outbound server-side `fetch` require a claimed deploy.
|
||||
- Unclaimed deploys expire. Use the expiry printed by the CLI. Claimed deploys do not expire.
|
||||
|
||||
## App structure and APIs
|
||||
|
||||
- `server/index.ts` exports the default `capsule()` definition. Put server code in `server/`. Import from `lakebed/server` or relative server and shared files.
|
||||
- `client/index.tsx` exports `App`. Put client code in `client/`. Import from `lakebed/client`, `preact`, `preact/hooks`, `preact/jsx-runtime`, `preact/jsx-dev-runtime`, or relative client and shared files.
|
||||
- Keep `shared/` pure TypeScript. Do not import DOM APIs, Node built-ins, env values, or Lakebed runtimes there.
|
||||
- In client code, use `import type app from "../server/index"` and `createClient<typeof app>()` for typed queries, mutations, and actions. Query hooks return `undefined` until the first result arrives.
|
||||
- Database calls are async. Await or return every database operation. Declare indexes with `.index(name, fields)` and query with `withIndex`. Use `by_creation` for unfiltered creation-order queries. Do not use legacy `where`, `orderBy`, `limit`, or `all`.
|
||||
- Queries and actions cannot write to the database. Use mutations or endpoints for writes. Filter user-owned data by the caller's `userId` and check ownership again before updates or deletes.
|
||||
- Guests get protected browser sessions without setup. Use `ctx.auth` on the server and `useAuth()` on the client. A user ID is not a credential. Do not invent guest IDs or check their prefixes.
|
||||
- Use `ctx.auth.requireIdentity()` for data that belongs to a guest or signed-in user. Use `ctx.auth.requireSignedIn()` for account-only operations. `isGuest` and `isSignedIn` are separate checks. Neither is true without a session.
|
||||
- Set `auth: { requireSignIn: true }` in `capsule()` to block all app data operations until sign-in. Client UI checks alone do not protect data. On the client, gate data components on `canAccessApp()` from `lakebed/client`.
|
||||
- If `auth.error` blocks access, show `retryAuth()` and Google sign-in. Retry cannot renew an expired or revoked token for a pending guest upgrade. Keep data components unmounted until auth recovers.
|
||||
- Declare Lakebed user fields with `userId()` from `lakebed/server`, never `string()`. When a guest signs in, declared `userId()` fields follow them to their account. `userId()` does not grant access. Keep owner filters and ownership checks. Make shared data intentional with a shared query, not a fake global user.
|
||||
- Use `auth.onGuestUpgrade` only for app-specific merge rules. It runs before automatic reference transfer in the same transaction. Plain strings, profile text, and external data do not transfer automatically.
|
||||
- Add Google sign-in with `SignInWithGoogle` or `signInWithGoogle()` from `lakebed/client`. For custom endpoints, send the identity token from `getIdentity().token` in the `X-Lakebed-Token` header. `Authorization` belongs to the app. Same-origin guest cookies work without that header.
|
||||
- Read server secrets through `ctx.env`, with values in `.env.lakebed.server`. They are not available at build time. Never put secrets in client or shared code. Deploy sync replaces hosted env with the file contents after the deploy is claimed.
|
||||
- Use complete Tailwind class names in JSX. Lakebed compiles CSS automatically from client files and their imports. Use inline styles for values loaded at runtime. Do not add CSS files, CSS modules, PostCSS, or a separate Tailwind build step.
|
||||
- Use the router from `lakebed/client` for pages. There is no file-based routing. Use `endpoint({ method, path }, handler)` from `lakebed/server` for webhooks and external HTTP clients. Request helpers include `headers.get(name)`, `query`, `json()`, `text()`, and `bytes()`.
|
||||
- Static capsule assets are limited to the favicon. Use `favicon.svg`, `favicon.ico`, or the `favicon` option in `capsule()`. Use `client.storage` for user uploads.
|
||||
|
||||
## External data and dashboards
|
||||
|
||||
Use global `fetch(url, options)` inside a handler, not `ctx.fetch`. Queries, mutations, actions, and endpoints can fetch locally and on claimed deploys. A mutation or writable endpoint can fetch external data and write rows in the same call. Data does not need to pass through the browser. Fetch shares the handler time budget and can hold up other writes, so ingest one small batch per call.
|
||||
|
||||
Lakebed has no built-in scheduler or durable continuation queue yet. For periodic ingest, use an external scheduler to call a protected `POST` endpoint. Return a cursor for the caller to advance across separate requests. Keep `auth.requireSignIn` off for public reads and check an app secret in the ingest endpoint. CLI deploy tokens do not authenticate app endpoint callers.
|
||||
|
||||
Database read budgets apply to the whole handler. A loop over `paginate()` does not bypass them. For totals larger than one handler can read, maintain summary rows during ingest. Store timestamps with `number()` as epoch milliseconds. See the [handler capability table](https://docs.lakebed.dev/capsule-api/index.md#handler-capabilities), [dashboard ingest example](https://docs.lakebed.dev/database/index.md#dashboard-counts), and [resource limits](https://docs.lakebed.dev/limits/index.md) before planning a backfill.
|
||||
|
||||
## Run and verify
|
||||
|
||||
Run commands from this capsule directory with `npx lakebed`.
|
||||
|
||||
Start dev in a terminal session that can stay open:
|
||||
|
||||
```sh
|
||||
npx lakebed dev
|
||||
```
|
||||
|
||||
Keep that process running. Edit the starter to build the requested app, then test its behavior at the URL printed by dev. Use another terminal to inspect logs and data:
|
||||
|
||||
```sh
|
||||
npx lakebed logs --port 3000
|
||||
npx lakebed db dump --port 3000
|
||||
```
|
||||
|
||||
Use the dev server's port if it differs from 3000. Fix compile errors and runtime errors before deploying. Check user-owned data with separate browser profiles or the `?lakebed_guest=<name>` local test override when the app stores private data. Named overrides are local test identities and cannot upgrade to an account.
|
||||
|
||||
## Deploy and verify
|
||||
|
||||
After local checks pass, deploy from another terminal:
|
||||
|
||||
```sh
|
||||
npx lakebed deploy
|
||||
```
|
||||
|
||||
If the CLI requires a claim for server secrets or outbound fetch, follow its claim instructions and deploy again. A claim-required preview is not a working app.
|
||||
|
||||
Open the returned URL and test the requested behavior. Inspect the deployed app from this capsule directory, using its returned ID or URL:
|
||||
|
||||
```sh
|
||||
npx lakebed inspect <deploy-id-or-url>
|
||||
npx lakebed logs <deploy-id-or-url>
|
||||
```
|
||||
|
||||
Hosted inspection is private by default. The CLI uses saved credentials. Report the working URL, the checks you ran, and the expiry if the deploy is unclaimed. Default app URLs use `lakebed.app` subdomains.
|
||||
|
||||
## Read when needed
|
||||
|
||||
- For server and client API details, read the [capsule API](https://docs.lakebed.dev/capsule-api/index.md).
|
||||
- For indexes and queries, read the [database guide](https://docs.lakebed.dev/database/index.md).
|
||||
- For Google sign-in and identity, read the [auth guide](https://docs.lakebed.dev/auth/index.md).
|
||||
- For user uploads, read the [storage guide](https://docs.lakebed.dev/storage/index.md).
|
||||
- For claiming, domains, and other CLI commands, read the [reference](https://docs.lakebed.dev/reference/index.md).
|
||||
- For an older capsule using synchronous database calls, read the [migration guide](https://docs.lakebed.dev/database-migration/index.md).
|
||||
- For anything else, read the [docs index](https://docs.lakebed.dev/llms.txt). It lists every page and section so you can fetch only the one you need.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,58 @@
|
||||
# compat-db: Frame Control's compatibility database
|
||||
|
||||
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
|
||||
reports for Android apps on the Steam Frame. Only Frame Control can read or
|
||||
write it.
|
||||
|
||||
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
|
||||
owned by `saphid`, doesn't expire). The browser page only says it's private.
|
||||
- Access: `GET /v1/reports?since=<createdAt>` and `POST /v1/reports` with
|
||||
`{"reports": [...]}`. Both need the `x-frame-control-key` header. There are
|
||||
no Lakebed queries or mutations, so nothing else can reach the rows.
|
||||
- Key: `FRAME_CONTROL_KEY` in `.env.lakebed.server` (git-ignored, synced on
|
||||
deploy) and in the Mac's login Keychain (service `frame-control-compat-db`,
|
||||
account `app-key`), where `ui/frame_compat_db.py` reads it.
|
||||
- Duplicates: each report carries a `clientId`, and a report already stored is
|
||||
skipped, so retries and restores are safe to repeat.
|
||||
- Free-plan limits: 1 MiB of data and 16,384 rows per deploy, 1,000 writes a
|
||||
day. A report is about 300 bytes, so roughly 3,000 reports fit.
|
||||
|
||||
## Backups
|
||||
|
||||
`scripts/compat-db-backup.sh` exports every report through the app key and
|
||||
keeps dated copies in
|
||||
`~/Library/Application Support/Frame Control/compat-db/backups` (newest 60).
|
||||
When the data has changed, it also uploads them to Google Drive
|
||||
(**the backup folder**, folder
|
||||
`<drive-folder-id>`) with `gog`. The LaunchAgent
|
||||
`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
|
||||
the last good backup (`backups/.last-good`), it's kept as `refused-*.json`,
|
||||
nothing is uploaded, and every later run refuses too until you rerun with
|
||||
`--accept-shrink`.
|
||||
|
||||
Reports that can't be sent (unreadable outbox lines, or ones the server
|
||||
rejects, which it lists by `clientId`) are never dropped: they move to
|
||||
`~/Library/Application Support/Frame Control/compat-db/compat-outbox.jsonl.rejected`,
|
||||
with the reason.
|
||||
|
||||
Restore (to this deploy or a new one):
|
||||
|
||||
```sh
|
||||
python3 ui/frame_compat_db.py import BACKUP.json # duplicates are skipped
|
||||
python3 ui/frame_compat_db.py count
|
||||
```
|
||||
|
||||
`npx lakebed db export dep_dDmcsosVSiFirpW6 --out full.json` is a second,
|
||||
owner-only export path through the Lakebed CLI.
|
||||
|
||||
## Change and deploy
|
||||
|
||||
```sh
|
||||
cd compat-db
|
||||
npx lakebed dev --port 3917 # local; data resets on restart
|
||||
npx lakebed deploy # updates frame-compat.lakebed.app
|
||||
```
|
||||
|
||||
To rotate the key: generate a new one, update the Keychain item and
|
||||
`.env.lakebed.server`, then deploy.
|
||||
@@ -0,0 +1,9 @@
|
||||
// No browser access to the data: Frame Control reads and writes it through the
|
||||
// key-protected /v1 endpoints only.
|
||||
export function App() {
|
||||
return (
|
||||
<main className="min-h-screen grid place-items-center bg-slate-900 text-slate-300 p-8">
|
||||
<p>Frame compatibility database. Private: only Frame Control can use it.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="lakebed-favicon-gradient" x1="12" y1="8" x2="52" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="hsl(157 84% 58%)" />
|
||||
<stop offset="1" stop-color="hsl(193 82% 44%)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="url(#lakebed-favicon-gradient)" />
|
||||
<circle cx="48" cy="16" r="18" fill="#fff" opacity=".16" />
|
||||
<text x="32" y="39" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="32" font-weight="800" fill="#fff">C</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 658 B |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"deployId": "dep_dDmcsosVSiFirpW6"
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { capsule, endpoint, json, string, table, text } from "lakebed/server";
|
||||
|
||||
// Compatibility reports for Android apps on the Steam Frame, written and read
|
||||
// only by Frame Control. There are no queries or mutations, so browsers and
|
||||
// Lakebed clients can't reach the data; the two endpoints require the app key
|
||||
// (FRAME_CONTROL_KEY in .env.lakebed.server, kept in the Mac's Keychain).
|
||||
|
||||
const RESULTS = ["runs", "crashes", "install_failed", "instance_failed"];
|
||||
const RATINGS = ["works", "issues", "broken"];
|
||||
const PAGE = 500;
|
||||
|
||||
type Incoming = Record<string, unknown>;
|
||||
|
||||
function field(r: Incoming, key: string, max = 200): string | undefined {
|
||||
const v = r[key];
|
||||
if (v === undefined || v === null || v === "") return undefined;
|
||||
return String(v).slice(0, max);
|
||||
}
|
||||
|
||||
function authorised(ctx: { env: Record<string, string | undefined> }, key: string | null): boolean {
|
||||
const expected = ctx.env.FRAME_CONTROL_KEY;
|
||||
if (!expected || !key) return false;
|
||||
// Compare every position of the longer string so timing doesn't reveal the key length.
|
||||
const n = Math.max(key.length, expected.length);
|
||||
let diff = key.length ^ expected.length;
|
||||
for (let i = 0; i < n; i++) diff |= (key.charCodeAt(i) || 0) ^ (expected.charCodeAt(i) || 0);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export default capsule({
|
||||
name: "frame-compat",
|
||||
|
||||
auth: { requireSignIn: false },
|
||||
|
||||
schema: {
|
||||
reports: table({
|
||||
package: string(),
|
||||
version: string().optional(),
|
||||
result: string().optional(),
|
||||
rating: string().optional(),
|
||||
notes: string().optional(),
|
||||
via: string().optional(),
|
||||
reportedAt: string(),
|
||||
steamos: string().optional(),
|
||||
lepton: string().optional(),
|
||||
runtime: string().optional(),
|
||||
label: string().optional(),
|
||||
source: string().optional(),
|
||||
clientId: string()
|
||||
}).index("by_package", ["package"]).index("by_client", ["clientId"])
|
||||
},
|
||||
|
||||
endpoints: {
|
||||
// GET /v1/reports?since=<createdAt> -> { reports: [...], next: <createdAt> | null }
|
||||
// Pass `next` back as `since` until it's null; rows at the boundary repeat, so dedupe by id.
|
||||
list: endpoint({ method: "GET", path: "/v1/reports" }, async (ctx, req) => {
|
||||
if (!authorised(ctx, req.headers.get("x-frame-control-key"))) return text("unauthorized", { status: 401 });
|
||||
const since = req.query.get("since") ?? "";
|
||||
const rows = await ctx.db.reports
|
||||
.withIndex("by_creation", (q) => q.gte("createdAt", since))
|
||||
.take(PAGE);
|
||||
return json({ reports: rows, next: rows.length === PAGE ? rows[rows.length - 1].createdAt : null });
|
||||
}),
|
||||
|
||||
// POST /v1/reports body: { reports: [ {...}, ... ] } (max 100 per call)
|
||||
// clientId makes retries idempotent: a report already stored is skipped.
|
||||
// Invalid reports are listed in `rejected` (by clientId) so the app can keep them.
|
||||
add: endpoint({ method: "POST", path: "/v1/reports" }, async (ctx, req) => {
|
||||
if (!authorised(ctx, req.headers.get("x-frame-control-key"))) return text("unauthorized", { status: 401 });
|
||||
const body = await req.json<{ reports?: Incoming[] }>();
|
||||
const incoming = Array.isArray(body?.reports) ? body.reports.slice(0, 100) : [];
|
||||
let inserted = 0;
|
||||
const rejected: string[] = [];
|
||||
for (const r of incoming) {
|
||||
const pkg = field(r, "package");
|
||||
const clientId = field(r, "clientId", 80);
|
||||
const reportedAt = field(r, "date", 40);
|
||||
const result = field(r, "result");
|
||||
const rating = field(r, "rating");
|
||||
if (!pkg || !clientId || !reportedAt || (result && !RESULTS.includes(result)) ||
|
||||
(rating && !RATINGS.includes(rating))) {
|
||||
if (clientId) rejected.push(clientId);
|
||||
continue;
|
||||
}
|
||||
const dup = await ctx.db.reports.withIndex("by_client", (q) => q.eq("clientId", clientId)).first();
|
||||
if (dup) continue;
|
||||
await ctx.db.reports.insert({
|
||||
package: pkg, version: field(r, "version", 80), result, rating,
|
||||
notes: field(r, "notes", 1000), via: field(r, "via", 20), reportedAt,
|
||||
steamos: field(r, "steamos", 40), lepton: field(r, "lepton", 40),
|
||||
runtime: field(r, "runtime", 20), label: field(r, "label", 120),
|
||||
source: field(r, "source", 300), clientId
|
||||
});
|
||||
inserted++;
|
||||
}
|
||||
return json({ inserted, rejected, received: incoming.length });
|
||||
}),
|
||||
|
||||
status: endpoint({ method: "GET", path: "/v1/status" }, () => text("ok"))
|
||||
}
|
||||
});
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
# Installing APKs (Lepton)
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md). Android apps run
|
||||
in **Lepton**, Valve's Waydroid-based container. Lepton is built for games,
|
||||
not general Android use
|
||||
([GamingOnLinux](https://www.gamingonlinux.com/2026/09/lepton-from-valve-to-run-android-games-on-linux-is-now-open-source/)).
|
||||
|
||||
## Install from the Mac: one app, one Lepton instance (verified 2026-09-25)
|
||||
|
||||
Use Frame Control's **Android apps** section (search, Install, Test, Report), drop
|
||||
an `.apk` on **Send to Frame**, or:
|
||||
|
||||
```sh
|
||||
./scripts/install-apk.sh some-app.apk # own instance, Steam shortcut
|
||||
python3 ui/frame_android.py list|launch|stop|remove|probe <package>
|
||||
```
|
||||
|
||||
Each APK becomes its own app, the way T3 Code is set up (see the instance
|
||||
section below), instead of going into Lepton Development:
|
||||
|
||||
1. `aapt2` reads the package, label, version, ABIs and icon. APKs that need
|
||||
API > 30 or have no `arm64-v8a` build are refused.
|
||||
2. The APK, `frame/android/lepton-app.sh` (as `launch.sh`), `instance.id`,
|
||||
`meta.json`, the icon and the `lepton-show-flatscreen` marker go to
|
||||
`~/Applications/Android/<package>/` on the Frame.
|
||||
3. A non-Steam shortcut is added through Steam's CEF debug port
|
||||
(`frame/android/steam_shortcuts.py`), with no Steam restart.
|
||||
4. Launching the shortcut runs Lepton directly with `SteamAppId` set to the
|
||||
instance id (`2800000000 + crc32(package) % 70000000`). That's a
|
||||
"steamlaunch" context, so app data in `compatdata/<id>/internal` survives
|
||||
restarts and updates, and each app gets its own SteamVR panel. Several can
|
||||
run at once alongside Lepton Development, each in its own container
|
||||
(`lepton-steamlaunch-<id>`, ADB on 5556, 5557, …).
|
||||
|
||||
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
|
||||
from the shortcut, stopped, and relaunched with their data intact. ADB and
|
||||
Lepton Development aren't involved.
|
||||
|
||||
`--dev` keeps the old path: ADB into Lepton Development over an SSH tunnel
|
||||
(first free Mac port from 15555), which starts Lepton Development if needed.
|
||||
Apps installed that way are deleted when it exits (see below). No pairing or
|
||||
"Allow debugging?" prompt is needed for either path.
|
||||
|
||||
Lepton Development must be installed once. Over SSH,
|
||||
`ssh frame 'steam steam://install/3056000'` queues it, but the install still
|
||||
needs to be confirmed or started in the headset.
|
||||
|
||||
## Installed apps disappear when Lepton Development closes (verified 2026-09-25)
|
||||
|
||||
Lepton Development runs in a throwaway "dev" context. When it exits for any
|
||||
reason (you close it, or it crashes), the launcher script
|
||||
`~/.local/share/Steam/steamapps/common/Lepton/lepton` calls
|
||||
`clear_baked_app_data "non steamlaunch container"` and **deletes every app
|
||||
installed over ADB**. The journal shows `Clearing baked app data due to non
|
||||
steamlaunch container`, and `pm list packages -3` is empty afterwards.
|
||||
|
||||
The script skips the wipe when `LEPTON_NO_CLEANUP` is set
|
||||
(`liblepton/liblepton.sh`, `clear_baked_app_data`). To keep your apps, set
|
||||
Lepton Development's Steam launch options to:
|
||||
|
||||
```
|
||||
LEPTON_NO_CLEANUP=1 %command%
|
||||
```
|
||||
|
||||
(Steam → Library → Lepton Development → Properties → Launch Options.) Inferred
|
||||
from the script, not yet tested across a restart.
|
||||
|
||||
## Which APKs work (verified 2026-09-25, SteamOS build 20260922.6101926)
|
||||
|
||||
Lepton is LineageOS 18.1 (`lepton_arm64_only`): Android 11, API 30,
|
||||
`abilist=arm64-v8a` only, Mesa (Turnip, Adreno 750) with GLES 3.2 and
|
||||
Vulkan 1.4. About 30 F-Droid apps were installed and opened on the Frame to
|
||||
check each rule. The results are in the compatibility database (see compat-db/README.md).
|
||||
|
||||
**Won't install** (the installer refuses):
|
||||
|
||||
| Rule | Seen on device |
|
||||
|---|---|
|
||||
| `minSdkVersion` > 30 | `INSTALL_FAILED_OLDER_SDK: Requires newer sdk version #33 (current version is #30)` |
|
||||
| Native code without `arm64-v8a` (32-bit ARM or x86 only) | `INSTALL_FAILED_NO_MATCHING_ABIS` |
|
||||
|
||||
**Crash on launch.** Lepton has no `clipboard` system service, so
|
||||
`getSystemService(CLIPBOARD_SERVICE)` returns null:
|
||||
|
||||
| What | Result |
|
||||
|---|---|
|
||||
| **Jetpack Compose UI < 1.11** | Crashes as soon as a Compose screen appears: `null cannot be cast to non-null type android.content.ClipboardManager` in `AndroidComposeView`. Seen with 1.5, 1.6, 1.7, 1.8 and 1.10 apps. |
|
||||
| Jetpack Compose UI 1.11, 1.12, 1.13 | **Works.** Six apps opened fine, including Aurora Store and NewPipe. |
|
||||
| Old Compose, but the first screen uses classic Views | Opens (FoCal, Compose 1.3), and crashes only on Compose screens |
|
||||
| SDL2 apps, including Kivy | Crash: SDL calls `ClipboardManager.addPrimaryClipChangedListener` at start-up |
|
||||
| Godot 4.3 | Crashes (clipboard cast). Godot 4.6.1 works. |
|
||||
|
||||
**Works:** classic Android Views apps, Flutter (2 of 2), libGDX (2 of 2),
|
||||
Compose 1.11+, Firebase-using apps. React Native: 2 of 3 opened; one
|
||||
(controlloid) died with SIGSEGV on the Hermes JS thread. A Qt 6 app
|
||||
(AusweisApp) failed on a missing libc++ symbol.
|
||||
|
||||
**Missing pieces:** an app may open but fail when you use one of these:
|
||||
|
||||
- No Google Play Services.
|
||||
- No activity for `VIEW` of web links, `OPEN_DOCUMENT`/`GET_CONTENT` (no file
|
||||
picker), `IMAGE_CAPTURE`, or text-to-speech. WebView (Chromium 152) is there.
|
||||
- No Downloads or Contacts providers.
|
||||
- Missing system services also include `accessibility`, `vibrator`, `phone`,
|
||||
`print`, `usb`, `nfc` and `autofill`. The declared features lack
|
||||
`touchscreen.multitouch`, `bluetooth_le` and `telephony`.
|
||||
- No on-screen keyboard (IME) is installed. How text entry reaches Android
|
||||
apps in the headset hasn't been checked.
|
||||
|
||||
**Lepton itself can crash.** Three times during testing, the graphics HAL
|
||||
(`android.hardware.graphics.composer@2.1-service`) aborted right after an app
|
||||
crashed. SurfaceFlinger died, the whole `lepton-dev` container exited, and the
|
||||
installed apps were wiped (see above). A retry of the same app worked, so it's
|
||||
intermittent rather than app-specific.
|
||||
|
||||
**How good are the predictions?** In a random sample of 12 apps rated "Should
|
||||
work", all 12 installed, opened and were still running 12 s later (two needed
|
||||
a retry because Lepton crashed mid-install). "Opened" isn't the same as fully
|
||||
working: see the missing pieces above.
|
||||
|
||||
## Catalogue and compatibility reports
|
||||
|
||||
Frame Control's **Android apps** section lists every F-Droid app with a
|
||||
verdict: Works on Frame, Should work, Might work, Probably crashes, or Won't
|
||||
work, with the reasons. As of 2026-09-25 that's 30 working, 3,323 should work,
|
||||
223 might work, 723 probably crash (mostly Compose < 1.11) and 156 won't
|
||||
install. Each app is rated on its newest version that Lepton can install,
|
||||
because F-Droid often publishes separate per-ABI builds and the newest is
|
||||
frequently x86_64. **Install** downloads the APK (SHA-256 checked against the
|
||||
F-Droid index) and sets it up as its own instance.
|
||||
|
||||
No ProtonDB-style database for sideloaded Android apps on the Frame existed
|
||||
as of 2026-09-25. [Steam Frame Hub](https://verified.steamframehub.com/)
|
||||
collects community reports for Steam games only and has no public API, and
|
||||
Valve's "Great on Frame" badges and each Steam app's `recommended_runtime`
|
||||
(for example `lepton-stable`) also cover Steam games only
|
||||
([VR.org](https://vr.org/articles/steam-frame-lepton-android-runtime-52-of-130-certified-2026)).
|
||||
So we keep our own, in a private Lakebed database
|
||||
(`https://frame-compat.lakebed.app`) that only Frame Control can read or write.
|
||||
**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.
|
||||
A daily job backs it up locally and to Google Drive. See
|
||||
[compat-db/README.md](../compat-db/README.md) and
|
||||
[apk-catalog/README.md](../apk-catalog/README.md).
|
||||
|
||||
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
|
||||
|
||||
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
|
||||
newest archived build without Compose, runs, loads the full catalogue (the
|
||||
first repo update takes about 90s), and can install apps. F-Droid 2.0 uses
|
||||
Compose 1.12, so it should work, but it hasn't been tried. The catalogue's
|
||||
Install button for F-Droid installs 1.17.2. To let F-Droid install apps without
|
||||
a settings prompt, with the tunnel open:
|
||||
|
||||
```sh
|
||||
adb -s $S shell appops set org.fdroid.fdroid REQUEST_INSTALL_PACKAGES allow
|
||||
```
|
||||
|
||||
## Handy commands
|
||||
|
||||
Open a tunnel by hand (use any free local port):
|
||||
|
||||
```sh
|
||||
ssh -f -N -M -S /tmp/frame-adb.sock -L 127.0.0.1:15555:127.0.0.1:5555 frame
|
||||
adb connect 127.0.0.1:15555
|
||||
S=127.0.0.1:15555
|
||||
# when done: adb disconnect $S; ssh -S /tmp/frame-adb.sock -O exit frame
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
adb -s $S shell pm list packages -3 # installed third-party apps
|
||||
adb -s $S shell monkey -p <pkg> -c android.intent.category.LAUNCHER 1
|
||||
# If monkey exits with -5 (it did for T3 Code), start the activity directly:
|
||||
adb -s $S shell am start -W -n "$(adb -s $S shell cmd package resolve-activity --brief -c android.intent.category.LAUNCHER <pkg> | tail -n 1)"
|
||||
adb -s $S logcat -d -b crash # why an app died
|
||||
adb -s $S uninstall <pkg>
|
||||
adb -s $S exec-out screencap -p > shot.png # the Lepton window
|
||||
```
|
||||
|
||||
## Reaching a Mac service from Lepton (T3 Code v2, verified 2026-09-25)
|
||||
|
||||
Lepton runs in podman with `pasta` networking. It has **its own loopback**, so
|
||||
a port on the Frame's `127.0.0.1` isn't visible as `127.0.0.1` inside Android.
|
||||
But pasta runs with `--map-gw`, so the **gateway address inside Lepton
|
||||
(`192.168.1.1` on the home network) maps to the Frame host's loopback**.
|
||||
|
||||
T3 Code v2 on the Mac listens only on `127.0.0.1:3873`. To reach it:
|
||||
|
||||
1. The LaunchAgent `~/Library/LaunchAgents/frame-t3-tunnel.plist`
|
||||
keeps `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running. launchd
|
||||
restarts it if it drops. Log: `~/Library/Logs/frame-t3-tunnel.log`.
|
||||
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
|
||||
`d0c468e3`) with `expo prebuild` and `gradlew assembleRelease
|
||||
-PreactNativeArchitectures=arm64-v8a`, using Homebrew `openjdk@17` and the
|
||||
`android-commandlinetools` SDK. It's signed with the debug key.
|
||||
|
||||
To pair again, issue a one-time code on the Mac and type it into
|
||||
**Add environment**:
|
||||
|
||||
```sh
|
||||
A="/Applications/T3 Code (V2 Preview).app"
|
||||
ELECTRON_RUN_AS_NODE=1 "$A/Contents/MacOS/T3 Code (Alpha)" \
|
||||
"$A/Contents/Resources/app.asar/apps/server/dist/bin.mjs" \
|
||||
auth pairing create --base-dir "$HOME/.t3-v2" --ttl 15m --label "Steam Frame"
|
||||
```
|
||||
|
||||
The app is deleted whenever Lepton Development closes (see above), so reinstall
|
||||
it afterwards or set `LEPTON_NO_CLEANUP=1`. The gateway address comes from the Frame's network when Lepton starts. On a
|
||||
different network, check it with `adb shell ip route` and edit the host.
|
||||
|
||||
## Lepton Development forgets apps; give an app its own instance (verified 2026-09-25)
|
||||
|
||||
**Lepton Development wipes every installed app when it exits.** Its launcher
|
||||
logs `Clearing baked app data due to non steamlaunch container`, unless
|
||||
`LEPTON_NO_CLEANUP` is set. A Steam-style launch (with `SteamAppId` set) is a
|
||||
"steamlaunch" context and keeps its data:
|
||||
|
||||
- App data lives in `STEAM_COMPAT_DATA_PATH/internal/<package>` (symlinked to
|
||||
`/data/data/<package>`) and survives everything, including APK updates.
|
||||
- `STEAM_COMPAT_DATA_PATH/baked` is Lepton's Android snapshot. It's rebuilt when
|
||||
the APK changes, or when the app exits within 30 seconds of starting.
|
||||
- `STEAM_COMPAT_DATA_PATH` must be under `~/.local/share/Steam` (use
|
||||
`steamapps/compatdata/<id>`). Only that tree is mounted in the container. Put
|
||||
it anywhere else and the symlinks dangle, so the app crashes with
|
||||
`ENOENT` on its first file write.
|
||||
- Lepton runs apps headless unless an empty `lepton-show-flatscreen` file sits
|
||||
next to the APK (`STEAM_COMPAT_INSTALL_PATH`).
|
||||
- Several instances can run at once. Each gets ADB on `5555 + offset`
|
||||
(`podman ps --format "{{.Names}} {{.Labels.adb_port}}"`).
|
||||
- Outside Steam, Lepton's `setpgid --foreground` re-exec fails with no
|
||||
terminal. Set `IS_PARENT=true` and start it with `setsid --wait`.
|
||||
|
||||
[`frame/t3code/launch.sh`](../frame/t3code/launch.sh) does all this for T3
|
||||
Code (context `steamlaunch-2873873873`; ADB is the first free `5555 + n`, e.g. 5557). It needs the Steam client
|
||||
running (it mounts `~/.steam/steam.pipe`).
|
||||
|
||||
**T3 Code in the Steam library (verified 2026-09-25).** The wrapper lives on
|
||||
the Frame at `~/Applications/T3Code/launch.sh`, with `t3code.apk` and the
|
||||
flatscreen marker next to it. It's a non-Steam shortcut called "T3 Code"
|
||||
(shortcut app id `3130509679`). Launching it from Steam gets its own SteamVR
|
||||
panel, `valve.steam.desktopgame.3130509679`, and opens already paired.
|
||||
|
||||
- The shortcut was added without restarting Steam, through Steam's CEF debug
|
||||
port (`127.0.0.1:8080` on the Frame, target `SharedJSContext`):
|
||||
`SteamClient.Apps.AddShortcut(name, exe, "", "")`, then `SetShortcutName`
|
||||
and `SetShortcutStartDir`. `steam steam://addnonsteamgame/<path>` only logged
|
||||
the URL and added nothing.
|
||||
- To launch it over SSH: `steam steam://rungameid/13445436691150012416`, which
|
||||
is `(3130509679 << 32) | 0x02000000`.
|
||||
- Steam sets `STEAM_FOSSILIZE_DUMP_PATH` for shortcut launches but not
|
||||
`STEAM_COMPAT_SHADER_PATH`. Lepton then dies with "unbound variable", so the
|
||||
wrapper sets both.
|
||||
- To update T3, replace `t3code.apk`. Lepton rebuilds its snapshot on the next
|
||||
launch, and the pairing survives.
|
||||
|
||||
## Crashing apps can take down the headset session (verified 2026-09-25)
|
||||
|
||||
Some apps crash Android's graphics composer HAL, which kills the Lepton
|
||||
container. On 2026-09-25 a batch crash-test also coincided with `steamvr.service`
|
||||
restarting "on client request", which stops and SIGKILLs `gamescope-session`.
|
||||
After one of those kills, gamescope crash-looped about once a second on
|
||||
`rendervulkan.cpp:2181 ... Assertion '!modifiers.empty()'` because it kept
|
||||
attaching to the SteamVR processes orphaned from the dead session. The fix
|
||||
without sudo was to `for p in vrdashboard vrcompositor vrserver; do pkill -TERM -x $p; done` (pkill takes one pattern). The
|
||||
next session then started SteamVR fresh and recovered within a minute.
|
||||
|
||||
## Android display: resolution, UI scale, text size (verified 2026-09-25, SteamOS 0.3.0, build 20260922.6101926)
|
||||
|
||||
Each running Lepton instance has its own ADB port on the Frame, assigned at
|
||||
launch: 5555 is Lepton Development, and own-instance apps take the next free
|
||||
port (T3 Code was on 5557). Find them with `ss -ltn` (5555–5599) and identify
|
||||
each with `pm list packages -3`. Both instances reported `Physical size:
|
||||
1920x1080`. Their densities were 180 dpi (Lepton Development) and 213 dpi
|
||||
(T3 Code), and `settings get system font_scale` returned `null` (1.0).
|
||||
|
||||
These all apply immediately and read back as set. Tested on Lepton Development
|
||||
only:
|
||||
|
||||
```sh
|
||||
adb -s $S shell wm size 2560x1440 # or: wm size reset
|
||||
adb -s $S shell wm density 240 # or: wm density reset
|
||||
adb -s $S shell settings put system font_scale 1.15
|
||||
adb -s $S shell settings delete system font_scale
|
||||
```
|
||||
|
||||
After a `wm` reset, Android writes `font_scale=1.0` back asynchronously, so a
|
||||
single delete that follows one reads back `1.0`. A second delete a second later
|
||||
leaves it `null`. Frame Control's **Android display** card does this for you
|
||||
(`/api/android/display`).
|
||||
|
||||
**Inferred, not yet checked in the headset:** a bigger Android resolution with
|
||||
density scaled to match (2560×1440 at 4/3 of the density) gives sharper text,
|
||||
because gamescope scales Lepton's surface to fit the same panel. Also unverified:
|
||||
whether the settings survive the app or its Lepton instance relaunching.
|
||||
Lepton Development rebuilds its Android data on exit, so there they probably
|
||||
don't.
|
||||
@@ -0,0 +1,73 @@
|
||||
# How the Frame is put together (field notes)
|
||||
|
||||
What we learnt by poking at a real Frame over SSH. Unless a line says
|
||||
otherwise, it was **verified 2026-09-25** on SteamOS 0.3.0 (`VARIANT_ID=vr`,
|
||||
build 20260922.6101926, kernel 6.18, aarch64). Topic docs go deeper. This page
|
||||
is the map.
|
||||
|
||||
## The layer cake
|
||||
|
||||
```
|
||||
SteamVR (vrserver, vrcompositor, vrdashboard) ← renders the room + panels
|
||||
└─ gamescope --backend openvr ← one SteamVR overlay per app id
|
||||
├─ Xwayland :0 (Steam UI, games, tagged apps) ← STEAM_GAME property = app id
|
||||
├─ Xwayland :1 (STEAM_GAME_DISPLAY_0)
|
||||
├─ Wayland socket gamescope-0
|
||||
└─ steamos-nested-desktop ← "the Linux desktop" panel
|
||||
└─ dbus-run-session startplasma-wayland
|
||||
└─ kwin_wayland 1280×800, Wayland wayland-0, Xwayland :2
|
||||
└─ plasmashell, Konsole, Dolphin, Flatpaks you open there
|
||||
Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 3056000
|
||||
```
|
||||
|
||||
## Facts worth knowing
|
||||
|
||||
| Fact | Where it matters |
|
||||
|---|---|
|
||||
| The desktop is a **nested** Plasma session: runtime dir `/run/user/1000/nested_plasma`, its own D-Bus bus, `WAYLAND_DISPLAY=wayland-0`, `DISPLAY=:2`. A plain `ssh frame app` can't find it. Copy the env from `plasmashell`'s `/proc/<pid>/environ`. | `run-on-frame.sh`, `paste-to-frame.sh` |
|
||||
| The desktop size is hard-coded to 1280×800 in `/usr/bin/steamos-nested-desktop` (read-only rootfs). | [panels.md](panels.md) |
|
||||
| gamescope runs with `--virtual-connector-strategy PerAppId`. Each app id becomes a SteamVR overlay `valve.steam.desktopgame.<id>`, which is a panel you can float. Setting `STEAM_GAME` on an X11 window on `:0` makes a new panel. | `panel-on-frame.sh`, [panels.md](panels.md) |
|
||||
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
|
||||
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
|
||||
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
|
||||
| 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 |
|
||||
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
|
||||
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
|
||||
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
|
||||
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb`, `wayvnc`. | Script design |
|
||||
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` |
|
||||
| `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things |
|
||||
| Clipboard: Klipper over the nested D-Bus bus (`qdbus6 org.kde.klipper …`). | `paste-to-frame.sh` |
|
||||
| Lepton listens for ADB on the Frame's loopback `5555`, so tunnel it over SSH. It's Android 11 (API 30), 64-bit ARM only, with no `clipboard` service: Compose < 1.11, SDL/Kivy and Godot 4.3 apps crash on launch. | [apks.md](apks.md), `apk-catalog/` |
|
||||
| Lepton Development deletes every ADB-installed app when it exits (`clear_baked_app_data "non steamlaunch container"` in `…/common/Lepton/lepton`) unless `LEPTON_NO_CLEANUP` is set. | [apks.md](apks.md) |
|
||||
| Any APK can run as its own Lepton instance: run `…/common/Lepton/lepton waitforexitandrun -- app.apk` with `SteamAppId` set and `STEAM_COMPAT_DATA_PATH` under `~/.local/share/Steam`. Data persists and each gets its own container and panel. `frame/android/lepton-app.sh`, `ui/frame_android.py`. | [apks.md](apks.md) |
|
||||
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
|
||||
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `xrCreateInstance`, no loader), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. To watch in 3D, use a native player. 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) |
|
||||
| Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons |
|
||||
|
||||
## Debug recipes
|
||||
|
||||
```sh
|
||||
# Which panels (app ids) exist right now?
|
||||
ssh frame 'DISPLAY=:0 xprop -root GAMESCOPE_FOCUSABLE_APPS GAMESCOPE_FOCUSED_APP'
|
||||
|
||||
# Watch panels being created
|
||||
ssh frame 'journalctl --user -f | grep --line-buffered "\[Overlays\]"'
|
||||
|
||||
# gamescope's full flags (in case Valve changes them)
|
||||
ssh frame 'tr "\0" " " < /proc/$(pgrep -x gamescope | head -n 1)/cmdline'
|
||||
|
||||
# Everything the SteamVR dashboard can say (find hidden features)
|
||||
ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashboard_english.json'
|
||||
```
|
||||
|
||||
## Where the rest lives
|
||||
|
||||
- Access and SSH: [ssh.md](ssh.md)
|
||||
- Seeing the Frame from the Mac, and the Mac from the Frame: [streaming.md](streaming.md)
|
||||
- Files and clipboard: [file-transfer.md](file-transfer.md)
|
||||
- Android apps: [apks.md](apks.md)
|
||||
- Installing and buying Steam games: [steam-games.md](steam-games.md)
|
||||
- Floating windows in space: [panels.md](panels.md)
|
||||
- What's still unverified: [open-questions.md](open-questions.md)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 892 KiB |
+16
-1
@@ -36,7 +36,11 @@ build 20260922.6101926, kernel 6.18, aarch64):
|
||||
The Frame can reach the Mac's Screen Sharing port (5900). The Remmina
|
||||
connection itself hasn't been tried in the headset yet (part of 11).
|
||||
|
||||
Still open: 4, 6, 7, 11 (in-headset connect), 12–16.
|
||||
- **Panels.** An X11 window on gamescope's `:0` with its own `STEAM_GAME` id
|
||||
gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were
|
||||
created side by side with `panel-on-frame.sh`. See [panels.md](panels.md).
|
||||
|
||||
Still open: 4, 6, 7, 11 (in-headset connect), 12–17.
|
||||
|
||||
## Check on the headset (in order)
|
||||
|
||||
@@ -80,6 +84,17 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–16.
|
||||
reach the Linux side? Does USB power from the Mac cope?
|
||||
16. **Tailscale**: can it be installed persistently (Flatpak? a
|
||||
userspace `tailscaled` in `~`?) for access off the home LAN?
|
||||
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the
|
||||
panels from `panel-on-frame.sh` show up, take input, and offer **Float in
|
||||
World** / **Move** / **Size**? Do floating positions survive closing and
|
||||
reopening the app, or a reboot?
|
||||
18. **`LEPTON_NO_CLEANUP=1 %command%`** as Lepton Development's launch
|
||||
option: do ADB-installed apps survive closing and reopening it?
|
||||
19. **Typing in Android apps:** Lepton has no IME installed. Does the SteamVR
|
||||
keyboard or a Bluetooth keyboard reach Android text fields, or does an
|
||||
F-Droid keyboard (installed and enabled with `ime enable`/`ime set`) work?
|
||||
20. **F-Droid 2.0** (Compose 1.12): does it run? If so, the catalogue can
|
||||
install it instead of 1.17.2.
|
||||
|
||||
## Unconfirmed claims made in these docs
|
||||
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# Arranging windows in space
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
## The short version
|
||||
|
||||
- The in-headset **Linux desktop is one flat panel**: a nested Plasma session,
|
||||
fixed at 1280×800, drawn into a single SteamVR overlay. Windows *inside* it
|
||||
are arranged by KWin inside that rectangle. They can't leave it.
|
||||
- Every **Steam app gets its own panel**. gamescope runs with
|
||||
`--virtual-connector-strategy PerAppId`, so each distinct app id becomes a
|
||||
separate SteamVR overlay named `valve.steam.desktopgame.<appid>`.
|
||||
- To float a Linux app on its own, run it on gamescope's X display (`:0`)
|
||||
instead of in Plasma, and tag its window with an app id of its own.
|
||||
`scripts/panel-on-frame.sh` does this:
|
||||
|
||||
```sh
|
||||
./scripts/panel-on-frame.sh konsole # a terminal, as its own panel
|
||||
./scripts/panel-on-frame.sh --name notes -- kate '~/notes.md' # quote ~ so the Frame expands it
|
||||
./scripts/panel-on-frame.sh org.mozilla.firefox # a Flatpak
|
||||
./scripts/panel-on-frame.sh mac-screen # the Mac's screen (Remmina/VNC)
|
||||
```
|
||||
|
||||
- Then **place each panel with the SteamVR dashboard's docking controls**:
|
||||
**Float in World**, **Move**, **Size**, **Toggle Curvature**, dock on the
|
||||
left or right controller, **View in Theater**, and **Multitasking View**.
|
||||
|
||||
## How a panel is born (verified 2026-09-25)
|
||||
|
||||
gamescope's command line on the Frame includes:
|
||||
|
||||
```
|
||||
--backend openvr --xwayland-count 2 --virtual-connector-strategy PerAppId
|
||||
--vr-overlay-key valve.steam.gamepadui.fallback
|
||||
--vr-app-overlay-key valve.steam.desktopgame
|
||||
--vr-overlay-physical-width 2.67 --vr-overlay-enable-control-bar
|
||||
--nested-width 1280 --nested-height 720
|
||||
```
|
||||
|
||||
gamescope reads each X11 window's `STEAM_GAME` property as its app id. That's
|
||||
the same property Steam sets on games it launches. On a new id, Steam's
|
||||
SteamVR system UI logs:
|
||||
|
||||
```
|
||||
[Overlays] Created: valve.steam.desktopgame.7777777
|
||||
[Overlays] Created: valve.steam.desktopgame.7777777.layer1 … layer7
|
||||
```
|
||||
|
||||
The test: an `xterm` on `DISPLAY=:0`, tagged with
|
||||
`xprop -id <win> -f STEAM_GAME 32c -set STEAM_GAME 7777777`, produced the
|
||||
overlay above. Two more apps with different ids (`konsole`, `xterm`) produced
|
||||
two more overlays, and all three were listed together in the root property
|
||||
`GAMESCOPE_FOCUSABLE_APPS`. **Not yet checked by eye:** how the new panels
|
||||
look in the headset and how they handle input.
|
||||
|
||||
Untagged windows on `:0` get app id 0 and share the default panel. Plasma
|
||||
itself (`kwin_wayland`, pid in `GAMESCOPE_FOCUSABLE_WINDOWS`) is one of those.
|
||||
|
||||
### What `panel-on-frame.sh` does
|
||||
|
||||
1. Sets `DISPLAY=:0`, unsets `WAYLAND_DISPLAY`, and forces X11 in the
|
||||
toolkits (`QT_QPA_PLATFORM=xcb`, `GDK_BACKEND=x11`, `SDL_VIDEODRIVER=x11`,
|
||||
`MOZ_ENABLE_WAYLAND=0`). A Wayland-only app would connect to gamescope's
|
||||
own Wayland socket and not get tagged.
|
||||
2. Starts the app detached (`setsid nohup`), so it outlives SSH.
|
||||
3. Diffs the root window's children before and after, and sets `STEAM_GAME`
|
||||
on each new mapped top-level window. It keeps watching about 3s after the
|
||||
first window (for splash screens), up to 20s in total (for slow Flatpaks).
|
||||
It gives up early if the app exits before showing a window.
|
||||
4. The id comes from `--id`, or is derived from `--name`/the command in the
|
||||
range 2,000,000,000–2,000,999,999, far above real Steam app ids. The same
|
||||
label always gives the same id.
|
||||
|
||||
Limits:
|
||||
|
||||
- **Single-instance apps** (Remmina, most KDE apps with a running copy in
|
||||
Plasma) hand the request to the existing process, so the window opens
|
||||
wherever that process lives. Close the app in Plasma first.
|
||||
- A window the app opens later (a dialog, a second window) isn't tagged, so it
|
||||
lands on the default panel. Tag it by hand:
|
||||
`ssh frame 'DISPLAY=:0 xprop -id <win> -f STEAM_GAME 32c -set STEAM_GAME <id>'`
|
||||
(find `<win>` with `DISPLAY=:0 xwininfo -root -children`).
|
||||
- The script tags *any* new window on `:0` during its watch window, so a
|
||||
Steam popup that opens in those few seconds would join the panel too. For
|
||||
the same reason, run one `panel-on-frame.sh` at a time. If a stray window
|
||||
is tagged first, the script can report success while the app's own window
|
||||
stays on the default panel; check in the headset.
|
||||
- Each panel renders at gamescope's nested size (1280×720), not the Plasma
|
||||
desktop's 1280×800.
|
||||
- Steam treats the tagged id as "the current game": it applies a generic
|
||||
controller config and logs `Failed to get app info` for the made-up id. So
|
||||
far this hasn't caused anything worse.
|
||||
|
||||
## Placing panels: the SteamVR dashboard (inferred from SteamVR's UI code)
|
||||
|
||||
The Frame's SteamVR dashboard
|
||||
(`/opt/steamvr/resources/webinterface/dashboard/`) wraps each overlay in a
|
||||
frame with a **dock location**: `Dashboard`, `World`, `Theater`,
|
||||
`LeftController`, `RightController`. The strings and handlers are there
|
||||
(`dashboard_english.json`, `systemui.js`):
|
||||
|
||||
| Control | What it does |
|
||||
|---|---|
|
||||
| **Float in World** | Only shown while the panel is docked on the dashboard. Detaches it into the room, where it stays after the dashboard closes. |
|
||||
| **Move** / grab handle | Push, pull and drag the panel. *Grab Handle Acceleration* in SteamVR settings speeds up push and pull. |
|
||||
| **Size** | Resize the floating panel. |
|
||||
| **Toggle Curvature** | Flat vs curved. |
|
||||
| **Dock on Left/Right Controller** | Attach to a controller, like a wrist screen. |
|
||||
| **Dock on Dashboard / Return to Dashboard** | Put it back. |
|
||||
| **View in Theater** / Show/Hide Theater Screen | Shows the panel as a large theater screen. |
|
||||
| **Multitasking View** | Shows every open panel together (only if `VRHTML.BSupportsMultitaskingView()`). |
|
||||
| **More Options** (…) | Where the less common docking actions live. |
|
||||
|
||||
**Still to check in the headset:** where exactly each control appears, whether
|
||||
floating positions survive a panel closing and reopening, and whether there's
|
||||
a limit on the number of floating panels.
|
||||
|
||||
## Other routes
|
||||
|
||||
- **Just the desktop somewhere else**: float the Plasma panel itself. No
|
||||
script needed.
|
||||
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
||||
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
||||
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
||||
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Installing and buying Steam games from the Mac
|
||||
|
||||
Frame Control's **Get games** section lists the games you own with each one's
|
||||
Steam Frame rating, installs them on the Frame, and searches the Steam store.
|
||||
This page covers how it works underneath, so you can do the same from a shell.
|
||||
|
||||
## How it works
|
||||
|
||||
The Frame's Steam client runs with `-cef-enable-debugging`. So its UI, a
|
||||
Chromium page, answers the Chrome DevTools protocol on the Frame's loopback,
|
||||
`127.0.0.1:8080`. The page titled **SharedJSContext** holds the client's own
|
||||
state and API:
|
||||
|
||||
| Object | What it gives you |
|
||||
|---|---|
|
||||
| `appStore.allApps` | Every app the account owns (868 games here), with `local_per_client_data.installed`, playtime, `vr_supported`/`vr_only` and `steam_hw_compat_category_packed` |
|
||||
| `downloadsStore.m_DownloadOverview` | A Map keyed by client ID. `"0"` is this machine: current app, percent, ETA, bytes/s |
|
||||
| `SteamClient.Installs.*` | The install wizard: `GetInstallManagerInfo`, `ContinueInstall`, `CancelInstall`, `OpenInstallWizard` |
|
||||
| `SteamClient.User.GetIPCountry()` | The store country (`AU` here), which store search needs |
|
||||
|
||||
`ui/frame_steam.py` is a stdlib-only WebSocket client for this page. It's piped
|
||||
over SSH like the other helpers:
|
||||
|
||||
```sh
|
||||
ssh frame 'python3 - owned' < ui/frame_steam.py # owned games + download
|
||||
ssh frame 'python3 - install 274190' < ui/frame_steam.py # install Broforce
|
||||
ssh frame 'python3 - store 1145360' < ui/frame_steam.py # store page in the headset
|
||||
```
|
||||
|
||||
The debugger port only listens on the Frame's loopback, so it's reachable over
|
||||
SSH and not from the network.
|
||||
|
||||
## Installing a game you own
|
||||
|
||||
`steam steam://install/<appid>`, run over SSH, hands the URL to the running
|
||||
client, which opens its install wizard. The wizard's state
|
||||
(`GetInstallManagerInfo().eInstallState`) then tells you what happens next:
|
||||
|
||||
| State | Meaning | What `frame_steam.py` does |
|
||||
|---|---|---|
|
||||
| 14 complete | Steam skipped the options dialog and queued the download | Reports "queued" |
|
||||
| 7 config | The options dialog is showing in the headset (library folder, compatibility note) | Calls `ContinueInstall()` when the game fits on disk, as the headset's Install button does |
|
||||
| 3, 4, 6, 8, 13 | Free license, CD key, password, EULA, signup | Leaves them for you to answer in the headset |
|
||||
| 15 failed | Error | Reports `errorDetail` |
|
||||
|
||||
**Verified 2026-09-25 (SteamOS 0.3.0, build 20260922.6101926):**
|
||||
|
||||
- Balatro (2379780, 67 MB) went straight to state 14 and installed in about 7 s,
|
||||
with nothing to answer in the headset.
|
||||
- Broforce (274190, 0.6 GB) stopped at state 7. Calling `ContinueInstall()` over
|
||||
DevTools queued the download, and the game installed.
|
||||
- Calling `SteamClient.Installs.OpenInstallWizard([appid])` directly did nothing:
|
||||
the state stayed at 0. Go through the `steam://install` URL instead.
|
||||
|
||||
**Inferred** from the client's JS: Steam skips the options dialog when there's
|
||||
one library folder, the game fits, and there's no compatibility note to show.
|
||||
Broforce is Deck "Playable", which probably explains why it stopped.
|
||||
|
||||
## Frame ratings
|
||||
|
||||
`steam_hw_compat_category_packed` holds two bits per device. The client decodes
|
||||
it like this (from `steamui/chunk~2dcc5aaf7.js`):
|
||||
|
||||
| Device | Bits |
|
||||
|---|---|
|
||||
| Steam Deck | `packed & 3` |
|
||||
| SteamOS | `packed >> 4 & 3` |
|
||||
| Steam Machine | `packed >> 6 & 3` |
|
||||
| **Steam Frame** | `packed >> 8 & 3` |
|
||||
|
||||
The values are 0 unknown, 1 unsupported, 2 playable and 3 verified. On
|
||||
2026-09-25 this library had 12 Frame Verified, 2 Playable, 6 Unsupported and 848
|
||||
Unknown games.
|
||||
|
||||
For games you don't own, the store's public
|
||||
`saleaction/ajaxgetdeckappcompatibilityreport?nAppID=<id>` returns
|
||||
`frame_resolved_category` on the same scale, along with `resolved_category`
|
||||
(Deck), `steamos_resolved_category` and `machine_resolved_category`. No key or
|
||||
login is needed.
|
||||
|
||||
## Buying
|
||||
|
||||
Frame Control doesn't buy anything. Purchases happen on Steam's own store page,
|
||||
signed in as you:
|
||||
|
||||
- **Buy on Steam ↗** opens `store.steampowered.com/app/<id>/` in the Mac's
|
||||
browser (the Electron app sends `target=_blank` links there).
|
||||
- **Store on Frame** runs `steam steam://store/<id>`, which opens the page in the
|
||||
Steam client on the headset. **Verified 2026-09-25:** a "Hades on Steam" page
|
||||
appeared in the DevTools page list. It wasn't visible in the headset capture
|
||||
because an app was in the foreground; it opens in Steam's dashboard.
|
||||
|
||||
After buying, press **Refresh** in Get games. The game shows up as owned, and
|
||||
**Install on Frame** installs it.
|
||||
|
||||
Store search uses `store.steampowered.com/api/storesearch/?term=…&cc=…`. It
|
||||
returns nothing without `cc`, so Frame Control takes the country from
|
||||
`SteamClient.User.GetIPCountry()` on the Frame.
|
||||
|
||||
## Not yet checked
|
||||
|
||||
- Free-to-play games: `steam://install` should stop at state 3 (free license)
|
||||
for you to accept in the headset. Not tried, because it adds a license to the
|
||||
account.
|
||||
- Games with a EULA (state 8).
|
||||
- Installing when there's more than one library folder, such as a microSD card.
|
||||
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
|
||||
headset.
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Frame-side: run one Android app in its own persistent Lepton instance.
|
||||
# Copied into ~/Applications/Android/<package>/launch.sh by the Mac-side
|
||||
# installer, next to app.apk, instance.id and (for 2D apps) the empty
|
||||
# lepton-show-flatscreen marker. A non-Steam shortcut points at this file.
|
||||
#
|
||||
# Why not Lepton Development: it wipes every app it installed when it exits.
|
||||
# A "steamlaunch" context (SteamAppId set) keeps app data in
|
||||
# compatdata/<id>/internal across restarts and APK updates. Pattern from
|
||||
# frame/t3code/launch.sh; see docs/apks.md.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LEPTON="$HOME/.local/share/Steam/steamapps/common/Lepton/lepton"
|
||||
[[ -x "$LEPTON" ]] || { echo "Lepton isn't installed (Steam app 3056000)" >&2; exit 1; }
|
||||
for need in "$DIR/app.apk" "$DIR/instance.id"; do
|
||||
[[ -f "$need" ]] || { echo "launch.sh: missing $need" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# A number that isn't a real Steam app; it names this app's Lepton context.
|
||||
export SteamAppId="$(cat "$DIR/instance.id")"
|
||||
export STEAM_COMPAT_INSTALL_PATH="$DIR"
|
||||
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
|
||||
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
|
||||
export STEAM_COMPAT_SHADER_PATH="$HOME/.local/share/Steam/steamapps/shadercache/$SteamAppId"
|
||||
export STEAM_FOSSILIZE_DUMP_PATH="$STEAM_COMPAT_SHADER_PATH/fozpipelinesv6/steamapp_pipeline_cache"
|
||||
mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
|
||||
|
||||
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
|
||||
# and SSH don't have; give it its own session instead.
|
||||
export IS_PARENT=true
|
||||
exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk"
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame-side: manage non-Steam shortcuts through the Steam client's CEF debug
|
||||
port (127.0.0.1:8080, target SharedJSContext), without restarting Steam.
|
||||
Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
|
||||
|
||||
steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id
|
||||
steam_shortcuts.py list -> JSON [{appid, name, exe}]
|
||||
steam_shortcuts.py remove APPID
|
||||
"""
|
||||
import base64, json, os, socket, struct, sys, urllib.request
|
||||
|
||||
DEVTOOLS = 'http://127.0.0.1:8080/json'
|
||||
|
||||
|
||||
def target_ws():
|
||||
for t in json.load(urllib.request.urlopen(DEVTOOLS, timeout=5)):
|
||||
if t.get('title') == 'SharedJSContext':
|
||||
return t['webSocketDebuggerUrl']
|
||||
sys.exit('SharedJSContext not found: is the Steam client running?')
|
||||
|
||||
|
||||
class WS:
|
||||
"""Just enough RFC 6455 for one CDP request/response on loopback."""
|
||||
|
||||
def __init__(self, url):
|
||||
host_port, path = url[len('ws://'):].split('/', 1)
|
||||
host, port = host_port.split(':')
|
||||
self.s = socket.create_connection((host, int(port)), timeout=20)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n'
|
||||
f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n'
|
||||
'Sec-WebSocket-Version: 13\r\n\r\n').encode())
|
||||
buf = b''
|
||||
while b'\r\n\r\n' not in buf:
|
||||
buf += self.s.recv(4096)
|
||||
if b' 101 ' not in buf.split(b'\r\n', 1)[0]:
|
||||
sys.exit('websocket handshake failed')
|
||||
self.rest = buf.split(b'\r\n\r\n', 1)[1]
|
||||
|
||||
def _read(self, n):
|
||||
while len(self.rest) < n:
|
||||
chunk = self.s.recv(65536)
|
||||
if not chunk:
|
||||
raise EOFError
|
||||
self.rest += chunk
|
||||
out, self.rest = self.rest[:n], self.rest[n:]
|
||||
return out
|
||||
|
||||
def send(self, text):
|
||||
data = text.encode()
|
||||
mask = os.urandom(4)
|
||||
n = len(data)
|
||||
head = bytes([0x81]) + (bytes([0x80 | n]) if n < 126 else
|
||||
bytes([0x80 | 126]) + struct.pack('>H', n) if n < 65536 else
|
||||
bytes([0x80 | 127]) + struct.pack('>Q', n))
|
||||
self.s.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
|
||||
|
||||
def recv(self):
|
||||
msg = b''
|
||||
while True:
|
||||
b0, b1 = self._read(2)
|
||||
n = b1 & 0x7f
|
||||
if n == 126:
|
||||
n = struct.unpack('>H', self._read(2))[0]
|
||||
elif n == 127:
|
||||
n = struct.unpack('>Q', self._read(8))[0]
|
||||
msg += self._read(n)
|
||||
if b0 & 0x80:
|
||||
return msg.decode()
|
||||
|
||||
|
||||
def evaluate(js):
|
||||
ws = WS(target_ws())
|
||||
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
|
||||
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
|
||||
while True:
|
||||
r = json.loads(ws.recv())
|
||||
if r.get('id') == 1:
|
||||
break
|
||||
res = r.get('result', {})
|
||||
if 'exceptionDetails' in res:
|
||||
sys.exit('JS error: ' + json.dumps(res['exceptionDetails'])[:500])
|
||||
return res.get('result', {}).get('value')
|
||||
|
||||
|
||||
def main():
|
||||
cmd, args = sys.argv[1], sys.argv[2:]
|
||||
if cmd == 'add':
|
||||
name, exe, start_dir = args[:3]
|
||||
icon = args[3] if len(args) > 3 else ''
|
||||
js = f'''(async () => {{
|
||||
const id = await SteamClient.Apps.AddShortcut({json.dumps(name)}, {json.dumps(exe)}, "", "");
|
||||
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
|
||||
SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
|
||||
if ({json.dumps(icon)}) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
|
||||
return id;
|
||||
}})()'''
|
||||
print(evaluate(js))
|
||||
elif cmd == 'list':
|
||||
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
|
||||
.map(a => ({appid: a.appid, name: a.display_name})))()'''
|
||||
print(json.dumps(evaluate(js)))
|
||||
elif cmd == 'remove':
|
||||
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
|
||||
print('removed')
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# Frame-side: run T3 Code in its own Lepton (Android) instance whose data
|
||||
# survives restarts. Lepton Development wipes its apps when it exits; a
|
||||
# "steamlaunch" context (SteamAppId set) keeps them.
|
||||
#
|
||||
# Lives in ~/Applications/T3Code next to t3code.apk and the empty
|
||||
# lepton-show-flatscreen marker (without it Lepton runs the app headless).
|
||||
#
|
||||
# App data (T3's pairing) lives in compatdata/<id>/internal and survives
|
||||
# everything. Lepton rebuilds its Android system snapshot (compatdata/<id>/baked)
|
||||
# when the APK changes or the app exits within 30 seconds of starting.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LEPTON="$HOME/.local/share/Steam/steamapps/common/Lepton/lepton"
|
||||
|
||||
# Without the marker Lepton runs T3 headless: Steam says "running" but no panel
|
||||
# appears. Fail loudly instead.
|
||||
for need in "$DIR/t3code.apk" "$DIR/lepton-show-flatscreen"; do
|
||||
[[ -f "$need" ]] || { echo "launch.sh: missing $need" >&2; exit 1; }
|
||||
done
|
||||
[[ -x "$LEPTON" ]] || { echo "launch.sh: Lepton not installed at $LEPTON" >&2; exit 1; }
|
||||
|
||||
# Any fixed number that isn't a real Steam app: it names the Lepton context.
|
||||
export SteamAppId=2873873873
|
||||
export STEAM_COMPAT_INSTALL_PATH="$DIR"
|
||||
# Must sit under ~/.local/share/Steam: Lepton symlinks /data/data/<app> and
|
||||
# /data/media/0 to host paths here, and only the Steam dir is mounted inside.
|
||||
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
|
||||
# A Steam shortcut launch sets STEAM_FOSSILIZE_DUMP_PATH but not the shader
|
||||
# path Lepton derives it from (unbound under `set -u`), so set both.
|
||||
export STEAM_COMPAT_SHADER_PATH="$HOME/.local/share/Steam/steamapps/shadercache/$SteamAppId"
|
||||
export STEAM_FOSSILIZE_DUMP_PATH="$STEAM_COMPAT_SHADER_PATH/fozpipelinesv6/steamapp_pipeline_cache"
|
||||
mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
|
||||
|
||||
# Lepton re-execs itself through `setpgid --foreground`, which needs a
|
||||
# controlling terminal that Steam shortcuts and systemd don't have. Skip that
|
||||
# step and give Lepton its own session instead: its teardown kills its whole
|
||||
# process group. --wait keeps this script alive so Steam sees the app running.
|
||||
export IS_PARENT=true
|
||||
exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/t3code.apk"
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: refresh the Android app catalogue that Frame Control shows
|
||||
# (apk-catalog/). Fetches the latest F-Droid index, scans new or updated APKs
|
||||
# with HTTP range requests, and rebuilds apk-catalog/site/apps.js.
|
||||
# Frame Control picks up the new data on its next page load.
|
||||
#
|
||||
# Usage: scripts/apk-catalog.sh (the first full scan takes about an hour;
|
||||
# later runs only scan what changed)
|
||||
set -euo pipefail
|
||||
|
||||
CAT="${0:A:h}/../apk-catalog"
|
||||
[[ "${1:-}" == -h || "${1:-}" == --help ]] && { sed -n '2,8p' "$0"; exit 0; }
|
||||
|
||||
print "==> Fetching the F-Droid index"
|
||||
curl -fL --progress-bar -o "$CAT/data/index-v2.json.part" https://f-droid.org/repo/index-v2.json
|
||||
mv "$CAT/data/index-v2.json.part" "$CAT/data/index-v2.json"
|
||||
print "==> Scanning new or updated APKs"
|
||||
WORKERS=40 python3 "$CAT/scan.py"
|
||||
WORKERS=40 python3 "$CAT/scan2.py"
|
||||
print "==> Rebuilding"
|
||||
python3 "$CAT/build.py"
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: back up Frame Control's compatibility database (the private
|
||||
# Lakebed capsule at https://frame-compat.lakebed.app).
|
||||
#
|
||||
# Exports every report through the app's own key (ui/frame_compat_db.py), keeps
|
||||
# dated copies in ~/Library/Application Support/Frame Control/compat-db/backups (newest
|
||||
# 60), and uploads to Google Drive (the backup folder) when
|
||||
# the data changed since the last upload. Run daily by the LaunchAgent
|
||||
# frame-compat-backup (see docs/apks.md).
|
||||
#
|
||||
# Usage: scripts/compat-db-backup.sh [--no-upload] [--force-upload] [--accept-shrink]
|
||||
# Env: DRIVE_FOLDER_ID, GOG_WRAPPER
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${0:A:h}/.."
|
||||
DEST="$HOME/Library/Application Support/Frame Control/compat-db/backups"
|
||||
DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-<drive-folder-id>}
|
||||
GOG_WRAPPER=${GOG_WRAPPER:-$HOME/bin/gog-with-keyring.sh}
|
||||
upload=1 force=0 accept_shrink=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-upload) upload=0 ;;
|
||||
--force-upload) force=1 ;;
|
||||
--accept-shrink) accept_shrink=1 ;;
|
||||
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
|
||||
*) print -u2 "unknown option $arg"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$DEST"
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
out="$DEST/frame-compat-$stamp.json"
|
||||
python3 "$ROOT/ui/frame_compat_db.py" export "$out"
|
||||
|
||||
# A backup that lost data is worse than none: refuse to shrink. Compare with the
|
||||
# last backup that passed this check (.last-good), never with a refused one, so a
|
||||
# loss keeps failing every day until someone looks and passes --accept-shrink.
|
||||
count=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["count"])' "$out")
|
||||
good="$DEST/.last-good"
|
||||
if [[ -f "$good" ]]; then
|
||||
read -r good_count good_file < "$good"
|
||||
if (( count < good_count )) && (( ! accept_shrink )); then
|
||||
mv "$out" "$DEST/refused-${out:t}"
|
||||
print -u2 "!! export has $count reports; the last good backup ($good_file) had $good_count."
|
||||
print -u2 "!! Not uploading. Kept it as refused-${out:t}. If the loss is expected, rerun with --accept-shrink."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
print -r -- "$count ${out:t}" > "$good"
|
||||
|
||||
# Only the reports decide whether anything changed (not the export timestamp).
|
||||
digest=$(python3 -c 'import json,sys,hashlib; r=json.load(open(sys.argv[1]))["reports"]; print(hashlib.sha256(json.dumps(sorted(r, key=lambda x: x["id"]), sort_keys=True).encode()).hexdigest())' "$out")
|
||||
shasum -a 256 "$out" > "$out.sha256"
|
||||
ls -1t "$DEST"/frame-compat-*.json | tail -n +61 | while read -r old; do rm -f "$old" "$old.sha256"; done
|
||||
print "==> $count reports backed up to $out"
|
||||
|
||||
if (( upload )); then
|
||||
last="$DEST/.last-uploaded-digest"
|
||||
if (( ! force )) && [[ -f "$last" && "$(cat "$last")" == "$digest" ]]; then
|
||||
print "==> Unchanged since the last Drive upload; skipped"
|
||||
exit 0
|
||||
fi
|
||||
[[ -x "$GOG_WRAPPER" ]] || { print -u2 "gog wrapper not found at $GOG_WRAPPER"; exit 1; }
|
||||
"$GOG_WRAPPER" drive upload "$out" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
|
||||
"$GOG_WRAPPER" drive upload "$out.sha256" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
|
||||
print -r -- "$digest" > "$last"
|
||||
print "==> Uploaded to Google Drive (the backup folder)"
|
||||
fi
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: start Frame Control (ui/server.py) and open it in its own window.
|
||||
#
|
||||
# Needs scripts/connect.sh to have been run once. Ctrl-C stops the server.
|
||||
#
|
||||
# Usage: scripts/frame-ui.sh [--no-open]
|
||||
# Env: PORT (default 47810), FRAME_ALIAS (default frame).
|
||||
set -euo pipefail
|
||||
|
||||
PORT=${PORT:-47810}
|
||||
here=${0:A:h}
|
||||
url="http://127.0.0.1:$PORT/"
|
||||
open_window=1
|
||||
[[ "${1:-}" == "--no-open" ]] && open_window=0
|
||||
[[ "${1:-}" == -h || "${1:-}" == --help ]] && { sed -n '2,8p' "$0"; exit 0; }
|
||||
|
||||
show() {
|
||||
(( open_window )) || return 0
|
||||
# A Chrome app window looks like a native app; fall back to the default browser.
|
||||
if [[ -d "/Applications/Google Chrome.app" ]]; then
|
||||
open -na "Google Chrome" --args --app="$url" --window-size=1400,950
|
||||
else
|
||||
open "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
# The Server header tells our server apart from anything else on the port.
|
||||
ours() { curl -fsS -D - -o /dev/null "$url" 2>/dev/null | grep -qi '^server: FrameControl'; }
|
||||
|
||||
if ours; then
|
||||
print "Frame Control is already running at $url"
|
||||
show
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 "$here/../ui/server.py" --port "$PORT" &
|
||||
server=$!
|
||||
trap 'kill $server 2>/dev/null' EXIT INT TERM
|
||||
for i in {1..50}; do
|
||||
ours && break
|
||||
kill -0 $server 2>/dev/null || { print -u2 "Server exited (port $PORT in use? try PORT=... $0)"; exit 1; }
|
||||
(( i == 50 )) && { print -u2 "Server didn't start"; exit 1; }
|
||||
sleep 0.1
|
||||
done
|
||||
show
|
||||
wait $server
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: install APKs on the Frame.
|
||||
#
|
||||
# Default: each APK becomes its own app, in its own persistent Lepton
|
||||
# instance with a Steam library shortcut (ui/frame_android.py). Nothing is
|
||||
# lost when it closes.
|
||||
#
|
||||
# --dev: the old way, ADB into Lepton Development over an SSH tunnel. Apps
|
||||
# installed like this are deleted when Lepton Development exits.
|
||||
#
|
||||
# Verified on a Frame 2026-09-25 (--split untested).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/install-apk.sh APP.apk [APP2.apk ...] # own instance each
|
||||
# scripts/install-apk.sh --dev APP.apk [APP2.apk ...] # into Lepton Development
|
||||
# scripts/install-apk.sh --dev --split BASE.apk SPLIT.apk ...
|
||||
#
|
||||
# Env: FRAME_ALIAS (default frame), LOCAL_PORT (default: first free port from 15555).
|
||||
set -euo pipefail
|
||||
|
||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
LEPTON_APPID=3056000
|
||||
if [[ -z "${LOCAL_PORT:-}" ]]; then
|
||||
for LOCAL_PORT in {15555..15575}; do
|
||||
lsof -nP -iTCP:$LOCAL_PORT -sTCP:LISTEN >/dev/null 2>&1 || break
|
||||
done
|
||||
fi
|
||||
SERIAL="127.0.0.1:$LOCAL_PORT"
|
||||
CTL="${TMPDIR:-/tmp}/frame-adb-$$.sock"
|
||||
|
||||
split=0
|
||||
dev=0
|
||||
apks=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--split) split=1 ;;
|
||||
--dev) dev=1 ;;
|
||||
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
|
||||
*) apks+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
(( ${#apks} )) || { sed -n '14,16p' "$0" >&2; exit 2; }
|
||||
if (( ! dev )); then
|
||||
(( split )) && { print -u2 "--split needs --dev for now"; exit 2; }
|
||||
for apk in "${apks[@]}"; do
|
||||
print "==> Installing $apk as its own app"
|
||||
FRAME_ALIAS=$FRAME_ALIAS python3 "${0:A:h}/../ui/frame_android.py" install "$apk"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
command -v adb >/dev/null || { print -u2 "adb missing: brew install android-platform-tools"; exit 1; }
|
||||
|
||||
for apk in "${apks[@]}"; do
|
||||
[[ -f "$apk" ]] || { print -u2 "not a file: $apk"; exit 1; }
|
||||
# The Frame is ARM64: native code must include lib/arm64-v8a/.
|
||||
libs=$(unzip -Z1 "$apk" 2>/dev/null | grep -E '^lib/[^/]+/' | cut -d/ -f2 | sort -u || true)
|
||||
if [[ -n "$libs" && "$libs" != *arm64-v8a* ]]; then
|
||||
print -u2 "!! $apk has native code for ${(j:, :)${(f)libs}} only; the Frame needs arm64-v8a"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
lepton_listening() { ssh "$FRAME_ALIAS" 'ss -ltn | grep -q ":5555 "'; }
|
||||
|
||||
if ! lepton_listening; then
|
||||
print "==> Starting Lepton Development on the Frame"
|
||||
ssh "$FRAME_ALIAS" "steam steam://rungameid/$LEPTON_APPID >/dev/null 2>&1"
|
||||
for i in {1..30}; do
|
||||
lepton_listening && break
|
||||
(( i == 30 )) && { print -u2 "Lepton didn't open port 5555 within 60s. Is Lepton Development installed?"; exit 1; }
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
print "==> Tunnelling ADB over SSH (localhost:$LOCAL_PORT -> $FRAME_ALIAS:5555)"
|
||||
ssh -f -N -M -S "$CTL" -o ExitOnForwardFailure=yes \
|
||||
-L "127.0.0.1:$LOCAL_PORT:127.0.0.1:5555" "$FRAME_ALIAS"
|
||||
cleanup() {
|
||||
adb disconnect "$SERIAL" >/dev/null 2>&1 || true
|
||||
ssh -S "$CTL" -O exit "$FRAME_ALIAS" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
adb connect "$SERIAL" | grep -q "connected to" || { print -u2 "adb connect $SERIAL failed"; exit 1; }
|
||||
# A freshly started Lepton accepts ADB before Android has finished booting.
|
||||
for i in {1..45}; do
|
||||
[[ "$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null)" == 1 ]] && break
|
||||
(( i == 45 )) && { print -u2 "Android in Lepton didn't finish booting within 90s"; exit 1; }
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if (( split )); then
|
||||
print "==> Installing split APK set (${#apks} files)"
|
||||
adb -s "$SERIAL" install-multiple -r "${apks[@]}"
|
||||
else
|
||||
for apk in "${apks[@]}"; do
|
||||
print "==> Installing $apk"
|
||||
adb -s "$SERIAL" install -r "$apk"
|
||||
done
|
||||
fi
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: start a Linux app on the Steam Frame as its OWN floating VR panel,
|
||||
# separate from the Plasma desktop panel, so you can place it anywhere.
|
||||
#
|
||||
# How it works (verified 2026-09-25): gamescope runs with
|
||||
# --virtual-connector-strategy PerAppId, so every distinct Steam app id gets
|
||||
# its own SteamVR overlay (valve.steam.desktopgame.<id>). Steam normally sets
|
||||
# that id on a game's X11 windows through the STEAM_GAME property. This script
|
||||
# starts the app as an X11 client of gamescope (DISPLAY=:0), then tags each new
|
||||
# top-level window with a per-panel id, which makes a new panel appear.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/panel-on-frame.sh [--id N] [--name LABEL] konsole
|
||||
# scripts/panel-on-frame.sh --name notes -- kate '~/notes.md'
|
||||
# scripts/panel-on-frame.sh org.mozilla.firefox # Flatpak app ID
|
||||
# scripts/panel-on-frame.sh mac-screen # Remmina into the Mac
|
||||
#
|
||||
# Apps sharing an id share a panel. The default id is derived from --name (or
|
||||
# the command), so re-running the same app reuses its panel slot.
|
||||
# A leading "~/" in any argument is expanded on the Frame (quote it on the Mac).
|
||||
set -euo pipefail
|
||||
|
||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
|
||||
id="" name=""
|
||||
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||
--id) id=${2:?--id needs a number}; shift 2 ;;
|
||||
--name) name=${2:?--name needs a label}; shift 2 ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "${1:-}" in
|
||||
"") sed -n '2,20p' "$0"; exit 2 ;;
|
||||
remmina) cmd=(flatpak run org.remmina.Remmina) ;;
|
||||
mac-screen) cmd=(flatpak run org.remmina.Remmina -c "$REMMINA_PROFILE") ;;
|
||||
--) shift; (( $# )) || { print -u2 "panel-on-frame: missing command after --"; exit 2; }
|
||||
cmd=("$@") ;;
|
||||
*)
|
||||
if [[ "$1" =~ '^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+){2,}$' ]]; then
|
||||
cmd=(flatpak run "$@")
|
||||
else
|
||||
cmd=("$@")
|
||||
fi ;;
|
||||
esac
|
||||
|
||||
if [[ -z "$id" ]]; then
|
||||
# Stable id per label, well above real Steam app ids (< 5,000,000 today).
|
||||
label=${name:-${cmd[*]}}
|
||||
id=$(( 2000000000 + $(print -rn -- "$label" | cksum | cut -d' ' -f1) % 1000000 ))
|
||||
fi
|
||||
[[ "$id" == <1-4294967295> ]] || { print -u2 "panel-on-frame: --id must be a positive 32-bit number"; exit 2; }
|
||||
|
||||
# Runs on the Frame with the app id as $1 and the command as the rest.
|
||||
remote=$(cat <<'EOF'
|
||||
set -u
|
||||
appid=$1; shift
|
||||
export DISPLAY=:0
|
||||
unset WAYLAND_DISPLAY
|
||||
# Make toolkits pick X11 so the window lands on gamescope's Xwayland.
|
||||
export QT_QPA_PLATFORM=xcb GDK_BACKEND=x11 SDL_VIDEODRIVER=x11 MOZ_ENABLE_WAYLAND=0
|
||||
if ! xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1; then
|
||||
echo "gamescope's X display :0 isn't reachable; is the headset awake?" >&2
|
||||
exit 2
|
||||
fi
|
||||
toplevels() { xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}' | sort; }
|
||||
before=$(toplevels)
|
||||
if [ -z "$before" ]; then
|
||||
echo "couldn't list windows on :0 (is xwininfo installed?)" >&2
|
||||
exit 2
|
||||
fi
|
||||
args=()
|
||||
for a in "$@"; do
|
||||
case "$a" in "~/"*) a="$HOME/${a#\~/}" ;; esac
|
||||
args+=("$a")
|
||||
done
|
||||
log=$(mktemp /tmp/panel-on-frame.XXXXXX)
|
||||
setsid nohup "${args[@]}" > "$log" 2>&1 < /dev/null &
|
||||
child=$!
|
||||
tagged=0 first=0
|
||||
# Tag new mapped windows: keep watching ~3s after the first (splash screens,
|
||||
# secondary windows), up to 20s in total for slow Flatpaks.
|
||||
for i in $(seq 1 40); do
|
||||
sleep 0.5
|
||||
[ "$tagged" -eq 0 ] && ! kill -0 "$child" 2>/dev/null && break
|
||||
for w in $(comm -13 <(printf '%s\n' "$before") <(toplevels)); do
|
||||
xwininfo -id "$w" 2>/dev/null | grep -q 'Map State: IsViewable' || continue
|
||||
xprop -id "$w" STEAM_GAME 2>/dev/null | grep -q '= ' && continue
|
||||
xprop -id "$w" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null && tagged=$((tagged + 1))
|
||||
done
|
||||
[ "$tagged" -gt 0 ] && [ "$first" -eq 0 ] && first=$i
|
||||
[ "$first" -gt 0 ] && [ "$i" -ge $((first + 6)) ] && break
|
||||
done
|
||||
if [ "$tagged" -gt 0 ]; then
|
||||
echo "panel: ${args[*]} -> valve.steam.desktopgame.$appid ($tagged window(s), pid $child, log $log)"
|
||||
elif kill -0 "$child" 2>/dev/null; then
|
||||
echo "started ${args[*]} (pid $child) but no new X11 window appeared." >&2
|
||||
echo "It may be Wayland-only or single-instance (already running elsewhere). Log: $log" >&2
|
||||
exit 1
|
||||
else
|
||||
echo "failed: ${args[*]} exited. Log:" >&2
|
||||
tail -n 20 "$log" >&2
|
||||
exit 1
|
||||
fi
|
||||
EOF
|
||||
)
|
||||
b64=$(print -rn -- "$remote" | base64)
|
||||
|
||||
ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Frame Control server checks that need no headset.
|
||||
|
||||
Starts ui/server.py against an SSH alias that can't resolve, then exercises the
|
||||
request guards and input validation, which all run before any SSH call.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
class ServerGuards(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.port = free_port()
|
||||
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1"}
|
||||
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for _ in range(100):
|
||||
try:
|
||||
if cls.request("GET", "/")[0] == 200:
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.05)
|
||||
cls.proc.kill()
|
||||
raise RuntimeError("server didn't start")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.proc.terminate()
|
||||
cls.proc.wait(timeout=10)
|
||||
|
||||
@classmethod
|
||||
def request(cls, method, path, body=None, headers=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", cls.port, timeout=10)
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
conn.request(method, path, body=data, headers=headers or {})
|
||||
r = conn.getresponse()
|
||||
payload = r.read()
|
||||
conn.close()
|
||||
return r.status, dict(r.getheaders()), payload
|
||||
|
||||
def post(self, path, body):
|
||||
status, _, payload = self.request("POST", path, body, {"X-Frame-UI": "1", "Content-Type": "application/json"})
|
||||
return status, json.loads(payload)
|
||||
|
||||
def test_page_served_with_identifying_and_anti_framing_headers(self):
|
||||
status, headers, payload = self.request("GET", "/")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(headers["Server"].startswith("FrameControl"))
|
||||
self.assertEqual(headers["X-Frame-Options"], "DENY")
|
||||
self.assertIn(b"<html", payload.lower())
|
||||
|
||||
def test_foreign_host_rejected(self):
|
||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||
for path in ("/", "/api/status"):
|
||||
status, _, _ = self.request("GET", path, headers={"Host": f"evil.example:{self.port}", "X-Frame-UI": "1"})
|
||||
self.assertEqual(status, 403, path)
|
||||
|
||||
def test_api_needs_custom_header(self):
|
||||
# <img src> and plain form posts from other sites can't set it.
|
||||
self.assertEqual(self.request("GET", "/api/status")[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
|
||||
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
|
||||
|
||||
def test_input_validation(self):
|
||||
cases = [
|
||||
("/api/launch", {"appid": "620; rm -rf ~"}),
|
||||
("/api/launch", {"appid": ""}),
|
||||
("/api/flatpak", {"id": "org.example.App;id", "action": "install"}),
|
||||
("/api/flatpak", {"id": "org.example.App", "action": "explode"}),
|
||||
("/api/volume", {"level": 1.5}),
|
||||
("/api/clipboard", {"text": ""}),
|
||||
("/api/open", {"what": "anything-else"}),
|
||||
]
|
||||
for path, body in cases:
|
||||
status, payload = self.post(path, body)
|
||||
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
||||
|
||||
def test_bad_bodies(self):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||
conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"})
|
||||
self.assertEqual(conn.getresponse().status, 400)
|
||||
conn.close()
|
||||
status, _ = self.post("/api/launch", ["not", "an", "object"])
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_unknown_routes(self):
|
||||
self.assertEqual(self.request("GET", "/nope")[0], 404)
|
||||
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
||||
|
||||
|
||||
class StatusProbe(unittest.TestCase):
|
||||
def test_runs_off_device_and_prints_one_json_object(self):
|
||||
# The probe runs on the Frame; elsewhere every field must degrade to null/empty.
|
||||
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_status.py")],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
data = json.loads(out.stdout)
|
||||
for key in ("hostname", "battery", "disk", "services", "games", "flatpaks"):
|
||||
self.assertIn(key, data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Get games checks that need no headset or network.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_store # noqa: E402
|
||||
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
|
||||
|
||||
|
||||
class SteamRoutes(test_server.ServerGuards):
|
||||
"""Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH."""
|
||||
|
||||
def test_steam_input_validation(self):
|
||||
for body in ({"action": "install", "appid": "620; reboot"}, {"action": "install", "appid": ""},
|
||||
{"action": "uninstall", "appid": "620"}, {"appid": "620"}):
|
||||
status, payload = self.post("/api/steam", body)
|
||||
self.assertEqual(status, 400, f"{body} -> {payload}")
|
||||
|
||||
def test_search_needs_country(self):
|
||||
for query in ("q=portal", "q=portal&cc=A", "q=portal&cc=AU1", "q=portal&cc=%27x"):
|
||||
status, _, _ = self.request("GET", f"/api/steam/search?{query}", headers={"X-Frame-UI": "1"})
|
||||
self.assertEqual(status, 400, query)
|
||||
|
||||
def test_steam_routes_need_custom_header(self):
|
||||
self.assertEqual(self.request("GET", "/api/steam/owned")[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/steam/search?q=x&cc=AU")[0], 403)
|
||||
self.assertEqual(self.request("POST", "/api/steam", {"action": "install", "appid": "620"})[0], 403)
|
||||
|
||||
|
||||
# Don't rerun the inherited ServerGuards tests under this module.
|
||||
for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]:
|
||||
setattr(SteamRoutes, name, None)
|
||||
|
||||
|
||||
class FrameSteamHelper(unittest.TestCase):
|
||||
def test_bad_usage_prints_json_error(self):
|
||||
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
|
||||
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_steam.py"), *args],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(out.returncode, 1, args)
|
||||
self.assertIn("error", json.loads(out.stdout), args)
|
||||
|
||||
|
||||
class HelperErrors(unittest.TestCase):
|
||||
def test_json_error_found_despite_ssh_stderr(self):
|
||||
import server
|
||||
noise = server.Failure("Warning: Permanently added 'frame' to the list of known hosts.")
|
||||
noise.stdout = '{"error": "Steam\'s UI isn\'t answering"}\n'
|
||||
with mock.patch.object(server, "ssh", side_effect=noise):
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
server.steam_frame("owned")
|
||||
self.assertEqual(str(cm.exception), "Steam's UI isn't answering")
|
||||
|
||||
def test_other_failures_pass_through(self):
|
||||
import server
|
||||
with mock.patch.object(server, "ssh", side_effect=server.Failure("Timed out talking to frame")):
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
server.steam_frame("owned")
|
||||
self.assertEqual(str(cm.exception), "Timed out talking to frame")
|
||||
|
||||
|
||||
class StoreSearch(unittest.TestCase):
|
||||
def setUp(self):
|
||||
frame_store._compat.clear()
|
||||
|
||||
def test_keeps_apps_and_attaches_frame_rating(self):
|
||||
def fake_get(path, params, timeout=10):
|
||||
if path == "api/storesearch":
|
||||
self.assertEqual(params["cc"], "AU")
|
||||
return {"items": [{"type": "app", "id": 620, "name": "Portal 2", "price": {"final": 1450}},
|
||||
{"type": "sub", "id": 7, "name": "Bundle"}]}
|
||||
return {"results": {"frame_resolved_category": 3}}
|
||||
with mock.patch.object(frame_store, "_get", side_effect=fake_get):
|
||||
results = frame_store.search("portal", "AU")
|
||||
self.assertEqual([(r["id"], r["frame"]) for r in results], [(620, 3)])
|
||||
|
||||
def test_rating_failure_is_unknown_and_not_cached(self):
|
||||
with mock.patch.object(frame_store, "_get", side_effect=OSError("offline")):
|
||||
self.assertEqual(frame_store.frame_rating(620), 0)
|
||||
self.assertNotIn(620, frame_store._compat)
|
||||
|
||||
def test_malformed_rating_is_unknown(self):
|
||||
for payload in ({"results": []}, {"results": {"frame_resolved_category": {}}},
|
||||
{"results": {"frame_resolved_category": 9}}):
|
||||
frame_store._compat.clear()
|
||||
with mock.patch.object(frame_store, "_get", return_value=payload):
|
||||
self.assertEqual(frame_store.frame_rating(620), 0, payload)
|
||||
|
||||
def test_blank_query_makes_no_request(self):
|
||||
with mock.patch.object(frame_store, "_get") as get:
|
||||
self.assertEqual(frame_store.search(" ", "AU"), [])
|
||||
get.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Android apps on the Frame, each in its own persistent Lepton instance.
|
||||
|
||||
Every APK gets ~/Applications/Android/<package>/ on the Frame with app.apk,
|
||||
launch.sh (frame/android/lepton-app.sh), instance.id, meta.json and, for 2D
|
||||
apps, the lepton-show-flatscreen marker; plus a non-Steam shortcut, so it shows
|
||||
in the Steam library and gets its own SteamVR panel. Nothing goes through
|
||||
Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
||||
|
||||
Python stdlib only. CLI: python3 ui/frame_android.py {install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
|
||||
"""
|
||||
import glob, json, os, re, shlex, subprocess, sys, threading, time, zipfile, zlib
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
|
||||
APPS_DIR = 'Applications/Android' # relative to the Frame's $HOME
|
||||
COMPAT = '.local/share/Steam/steamapps/compatdata'
|
||||
SHADERS = '.local/share/Steam/steamapps/shadercache'
|
||||
LAUNCHER = os.path.join(ROOT, 'frame', 'android', 'lepton-app.sh')
|
||||
SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py')
|
||||
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
|
||||
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
|
||||
|
||||
|
||||
class FrameError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def ssh(cmd, input=None, timeout=120):
|
||||
try:
|
||||
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], input=input, capture_output=True,
|
||||
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise FrameError(f'timed out talking to {FRAME}')
|
||||
if p.returncode != 0:
|
||||
raise FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
|
||||
return p.stdout
|
||||
|
||||
|
||||
def shortcut_tool(*args, timeout=60):
|
||||
with open(SHORTCUTS) as f:
|
||||
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
|
||||
timeout=timeout).strip()
|
||||
|
||||
|
||||
def instance_id(pkg):
|
||||
# Stable per package, well above real Steam app ids, below 2^32. T3 Code's
|
||||
# hand-picked 2873873873 sits outside this range.
|
||||
return 2800000000 + zlib.crc32(pkg.encode()) % 70000000
|
||||
|
||||
|
||||
def game_id(shortcut_appid):
|
||||
return (int(shortcut_appid) << 32) | 0x02000000
|
||||
|
||||
|
||||
def aapt2():
|
||||
found = sorted(glob.glob(os.path.expanduser('~/.homebrew/share/android-commandlinetools/build-tools/*/aapt2'))
|
||||
+ glob.glob('/opt/homebrew/share/android-commandlinetools/build-tools/*/aapt2')
|
||||
+ glob.glob(os.path.expanduser('~/Library/Android/sdk/build-tools/*/aapt2')))
|
||||
return found[-1] if found else None
|
||||
|
||||
|
||||
def apk_info(path):
|
||||
"""Package, label, version, native ABIs and the best PNG icon inside the APK."""
|
||||
tool = aapt2()
|
||||
if not tool:
|
||||
raise FrameError('aapt2 not found: brew install --cask android-commandlinetools, then '
|
||||
'sdkmanager "build-tools;36.0.0"')
|
||||
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, text=True).stdout
|
||||
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
|
||||
if not m:
|
||||
raise FrameError(f'not a readable APK: {os.path.basename(path)}')
|
||||
label = re.search(r"application-label(?:-en(?:-US)?)?:'([^']*)'", out) or \
|
||||
re.search(r"application: label='([^']*)'", out)
|
||||
icons = re.findall(r"application-icon-(\d+):'([^']+)'", out)
|
||||
abis = re.search(r"native-code: (.*)", out)
|
||||
sdk = re.search(r"(?:minSdkVersion|sdkVersion):'(\d+)'", out)
|
||||
info = {'package': m[1], 'version': m[2], 'label': (label[1] if label else '') or m[1],
|
||||
'abis': re.findall(r"'([^']+)'", abis[1]) if abis else [],
|
||||
'min_sdk': int(sdk[1]) if sdk else None, 'icon_png': None}
|
||||
try:
|
||||
z = zipfile.ZipFile(path)
|
||||
except (zipfile.BadZipFile, OSError) as e:
|
||||
raise FrameError(f'not a readable APK: {e}')
|
||||
with z:
|
||||
names = set(z.namelist())
|
||||
for _, icon in sorted(icons, key=lambda d: -int(d[0])):
|
||||
if icon.endswith('.png') and icon in names:
|
||||
info['icon_png'] = z.read(icon)
|
||||
break
|
||||
else: # adaptive icons are XML; fall back to the largest launcher PNG
|
||||
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
|
||||
key=lambda n: z.getinfo(n).file_size)
|
||||
if pngs:
|
||||
info['icon_png'] = z.read(pngs[-1])
|
||||
return info
|
||||
|
||||
|
||||
def check_installable(info):
|
||||
if info['min_sdk'] and info['min_sdk'] > 30:
|
||||
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
|
||||
if info['abis'] and 'arm64-v8a' not in info['abis']:
|
||||
raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only")
|
||||
|
||||
|
||||
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
|
||||
|
||||
|
||||
def _rsync(src, dest, *extra, timeout=600):
|
||||
try:
|
||||
subprocess.run(['rsync', '-a', *extra, '-e', 'ssh ' + ' '.join(SSH_OPTS), src, f'{FRAME}:{dest}'],
|
||||
check=True, capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise FrameError(f'copying {os.path.basename(src)} to the Frame timed out')
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise FrameError(f'copying {os.path.basename(src)} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
|
||||
|
||||
|
||||
def _shortcut_ids():
|
||||
try:
|
||||
return {int(x['appid']) for x in json.loads(shortcut_tool('list'))}
|
||||
except (ValueError, TypeError, KeyError) as e:
|
||||
raise FrameError(f'could not read the Steam shortcut list: {e}')
|
||||
|
||||
|
||||
def _write_meta(d, meta):
|
||||
# Write then rename, so a dropped connection can't leave torn JSON behind.
|
||||
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
|
||||
|
||||
|
||||
def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None):
|
||||
info = apk_info(apk_path)
|
||||
if icon_png:
|
||||
info['icon_png'] = icon_png
|
||||
check_installable(info)
|
||||
pkg = info['package']
|
||||
if not PKG_RE.match(pkg):
|
||||
raise FrameError(f'unexpected package name {pkg!r}')
|
||||
with _install_lock:
|
||||
return _install(apk_path, info, pkg, flatscreen, name, source)
|
||||
|
||||
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
iid = instance_id(pkg)
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
existing = read_meta(pkg)
|
||||
ok = False
|
||||
try:
|
||||
ssh(f'mkdir -p {d}')
|
||||
_rsync(apk_path, f'{d}/app.apk.part')
|
||||
_rsync(LAUNCHER, f'{d}/launch.sh', '--chmod=u+x', timeout=120)
|
||||
icon = ''
|
||||
if info['icon_png']:
|
||||
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
|
||||
icon = f'$HOME/{d}/icon.png'
|
||||
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
|
||||
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
|
||||
home = ssh('echo $HOME').strip()
|
||||
shortcut = _int((existing or {}).get('shortcut'))
|
||||
if not shortcut or shortcut not in _shortcut_ids():
|
||||
reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}',
|
||||
icon.replace('$HOME', home))
|
||||
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
|
||||
if not shortcut:
|
||||
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
|
||||
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
|
||||
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
|
||||
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'source': source or os.path.basename(apk_path)}
|
||||
_write_meta(d, meta)
|
||||
ok = True
|
||||
return meta
|
||||
finally:
|
||||
if not ok and not existing:
|
||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||
try:
|
||||
ssh(f'rm -rf {d}', timeout=30)
|
||||
except FrameError:
|
||||
pass
|
||||
|
||||
|
||||
def _int(v):
|
||||
try:
|
||||
n = int(v)
|
||||
return n if n > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def read_meta(pkg):
|
||||
try:
|
||||
m = json.loads(ssh(f'cat {APPS_DIR}/{pkg}/meta.json 2>/dev/null || true') or 'null')
|
||||
except (ValueError, FrameError):
|
||||
return None
|
||||
return _clean_meta(m)
|
||||
|
||||
|
||||
def _clean_meta(m):
|
||||
"""A usable meta dict with integer ids, or None if it's missing what we need."""
|
||||
if not isinstance(m, dict) or not PKG_RE.match(str(m.get('package', ''))):
|
||||
return None
|
||||
iid, shortcut = _int(m.get('instance')), _int(m.get('shortcut'))
|
||||
if not iid:
|
||||
return None
|
||||
m.update(instance=iid, shortcut=shortcut, game_id=game_id(shortcut) if shortcut else None,
|
||||
label=str(m.get('label') or m['package']), version=str(m.get('version') or ''))
|
||||
return m
|
||||
|
||||
|
||||
def running_instances():
|
||||
"""Lepton container name -> adb port, for the instances that are running now."""
|
||||
out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}" 2>/dev/null || true')
|
||||
return dict(line.split()[:2] for line in out.splitlines() if len(line.split()) >= 2)
|
||||
|
||||
|
||||
def list_apps():
|
||||
out = ssh(f'for f in {APPS_DIR}/*/meta.json; do [ -f "$f" ] && cat "$f" && echo; echo "@@"; done 2>/dev/null || true')
|
||||
running = running_instances()
|
||||
apps = []
|
||||
for chunk in out.split('@@'):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
try:
|
||||
m = _clean_meta(json.loads(chunk))
|
||||
except ValueError:
|
||||
continue
|
||||
if m:
|
||||
m['running'] = f"lepton-steamlaunch-{m['instance']}" in running
|
||||
apps.append(m)
|
||||
return sorted(apps, key=lambda m: m['label'].lower())
|
||||
|
||||
|
||||
def _meta_or_fail(pkg):
|
||||
if not PKG_RE.match(pkg or ''):
|
||||
raise FrameError(f'bad package name {pkg!r}')
|
||||
m = read_meta(pkg)
|
||||
if not m:
|
||||
raise FrameError(f'{pkg} is not installed')
|
||||
return m
|
||||
|
||||
|
||||
def launch(pkg):
|
||||
m = _meta_or_fail(pkg)
|
||||
if not m['game_id']:
|
||||
raise FrameError(f"{m['label']} has no Steam shortcut; reinstall it")
|
||||
ssh(f"steam steam://rungameid/{int(m['game_id'])} >/dev/null 2>&1 &")
|
||||
return m
|
||||
|
||||
|
||||
def stop(pkg):
|
||||
m = _meta_or_fail(pkg)
|
||||
ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60)
|
||||
return m
|
||||
|
||||
|
||||
def remove(pkg, keep_data=False):
|
||||
m = _meta_or_fail(pkg)
|
||||
stop(pkg)
|
||||
if m['shortcut']:
|
||||
try:
|
||||
shortcut_tool('remove', str(int(m['shortcut'])))
|
||||
except FrameError:
|
||||
pass # already gone from Steam
|
||||
iid = int(m['instance'])
|
||||
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
|
||||
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
|
||||
return m
|
||||
|
||||
|
||||
def probe(pkg, wait=20):
|
||||
"""Launch the app's instance and report whether it stays up (for compat reports)."""
|
||||
m = _meta_or_fail(pkg)
|
||||
ctr = f"lepton-steamlaunch-{int(m['instance'])}"
|
||||
launch(pkg)
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 90 and ctr not in running_instances():
|
||||
time.sleep(3)
|
||||
if ctr not in running_instances():
|
||||
return {'package': pkg, 'version': m['version'], 'result': 'instance_failed',
|
||||
'detail': 'Lepton instance did not start within 90 s'}
|
||||
# Android boots inside the container; then give the app time to crash, or not.
|
||||
time.sleep(wait)
|
||||
sh = f'podman exec {ctr} /system/bin/sh -c'
|
||||
alive = ssh(f"{sh} 'pidof {pkg}' 2>/dev/null || true").strip()
|
||||
crash = ssh(f"{sh} 'logcat -d -b crash' 2>/dev/null | tail -n 60 || true")
|
||||
reason = next((l.split('AndroidRuntime: ', 1)[1] for l in crash.splitlines()
|
||||
if 'AndroidRuntime: ' in l and ('Exception' in l or 'Error' in l)), '')
|
||||
if not reason and 'Fatal signal' in crash:
|
||||
reason = next(l[l.find('Fatal signal'):] for l in crash.splitlines() if 'Fatal signal' in l)
|
||||
if 'ClipboardManager' in reason:
|
||||
reason = 'no clipboard service: ' + reason
|
||||
return {'package': pkg, 'version': m['version'], 'result': 'runs' if alive else 'crashes',
|
||||
'detail': reason[:300], 'seconds': wait,
|
||||
'container_up': ctr in running_instances()}
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['help']
|
||||
try:
|
||||
if cmd == 'install':
|
||||
r = install(args[0], flatscreen='--vr' not in args)
|
||||
elif cmd == 'list':
|
||||
r = list_apps()
|
||||
elif cmd in ('launch', 'stop', 'probe'):
|
||||
r = globals()[cmd](args[0])
|
||||
elif cmd == 'remove':
|
||||
r = remove(args[0], keep_data='--keep-data' in args)
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
except FrameError as e:
|
||||
sys.exit(f'error: {e}')
|
||||
print(json.dumps(r, indent=1))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Frame Control's side of the APK catalogue (../apk-catalog): the rated F-Droid
|
||||
list, verified downloads, installs into per-app Lepton instances, and
|
||||
compatibility reports. Python stdlib only.
|
||||
"""
|
||||
import hashlib, os, shutil, sys, tempfile, threading, time, urllib.error, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CATALOG = os.path.join(ROOT, 'apk-catalog')
|
||||
sys.path.insert(0, CATALOG)
|
||||
import build as catalog_build # noqa: E402
|
||||
import reports # noqa: E402
|
||||
import frame_android # 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
|
||||
else os.path.join(CATALOG, 'data', 'cache'))
|
||||
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
|
||||
_lock = threading.Lock()
|
||||
_cache = {'mtime': None, 'sig': None, 'apps': None, 'by_pkg': None}
|
||||
_env = {}
|
||||
|
||||
|
||||
def catalog():
|
||||
"""Rated apps with the database's reports applied."""
|
||||
path = os.path.join(CATALOG, 'site', 'apps.js')
|
||||
reps = compat_db.load()
|
||||
sig = (len(reps), max((r.get('date') or '' for r in reps), default=''))
|
||||
with _lock:
|
||||
mtime = os.path.getmtime(path)
|
||||
if _cache['mtime'] != mtime:
|
||||
_cache.update(mtime=mtime, sig=None, apps=catalog_build.load_catalog())
|
||||
if _cache['sig'] != sig:
|
||||
by = reports.by_package(reps)
|
||||
for a in _cache['apps']:
|
||||
catalog_build.finalize(a, by.get(a['p']))
|
||||
_cache['apps'].sort(key=lambda a: (catalog_build.RANK[a['r']], a['n'].lower()))
|
||||
_cache.update(sig=sig, by_pkg={a['p']: a for a in _cache['apps']})
|
||||
return _cache['apps']
|
||||
|
||||
|
||||
def app(pkg):
|
||||
catalog()
|
||||
a = _cache['by_pkg'].get(pkg)
|
||||
if not a:
|
||||
raise frame_android.FrameError(f'{pkg} is not in the catalogue')
|
||||
return a
|
||||
|
||||
|
||||
def _sha256(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b''):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def fetch_apk(a):
|
||||
"""Download (or reuse) the APK and verify it against the F-Droid index's SHA-256."""
|
||||
if not a['a'].startswith(APK_HOSTS):
|
||||
raise frame_android.FrameError('unexpected APK URL')
|
||||
if not a.get('h'):
|
||||
raise frame_android.FrameError(f"{a['n']}: the F-Droid index has no SHA-256 for this APK, so it can't be verified")
|
||||
os.makedirs(CACHE, exist_ok=True)
|
||||
path = os.path.join(CACHE, os.path.basename(a['a']))
|
||||
if os.path.exists(path) and _sha256(path) == a['h']:
|
||||
return path
|
||||
# Own temp file per download: concurrent downloads can't clobber or delete each other's.
|
||||
fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + '.', suffix='.part', dir=CACHE)
|
||||
os.close(fd)
|
||||
try:
|
||||
with urllib.request.urlopen(a['a'], timeout=60) as r, open(tmp, 'wb') as f:
|
||||
shutil.copyfileobj(r, f, 1 << 20)
|
||||
if _sha256(tmp) != a['h']:
|
||||
raise frame_android.FrameError('SHA-256 mismatch against the F-Droid index; download discarded')
|
||||
os.replace(tmp, path) # only a verified file ever reaches the cache name
|
||||
return path
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
raise frame_android.FrameError(f"couldn't download {a['n']}: {e}")
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
|
||||
|
||||
def fetch_icon(a):
|
||||
if not a.get('i', '').startswith('https://f-droid.org/repo/'):
|
||||
return None
|
||||
try:
|
||||
with urllib.request.urlopen(a['i'], timeout=15) as r:
|
||||
data = r.read(2_000_000)
|
||||
return data if data[:8] == b'\x89PNG\r\n\x1a\n' else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def install(pkg):
|
||||
a = app(pkg)
|
||||
if a['r'] == 'no' and not a['t']:
|
||||
raise frame_android.FrameError(f"{a['n']} can't run on the Frame: {a['why'][0]}")
|
||||
return frame_android.install(fetch_apk(a), name=a['n'], icon_png=fetch_icon(a), source='F-Droid')
|
||||
|
||||
|
||||
def environment():
|
||||
"""SteamOS and Lepton build ids, recorded with every report."""
|
||||
if not _env:
|
||||
out = frame_android.ssh('. /etc/os-release; echo "$BUILD_ID"; '
|
||||
'sed -n \'s/.*"buildid"[[:space:]]*"\\([0-9]*\\)".*/\\1/p\' '
|
||||
'~/.local/share/Steam/steamapps/appmanifest_3056000.acf')
|
||||
lines = out.split()
|
||||
_env.update(steamos=lines[0] if lines else None, lepton=lines[1] if len(lines) > 1 else None)
|
||||
return _env
|
||||
|
||||
|
||||
RUNTIMES = ('instance', 'lepton-dev', 'other')
|
||||
|
||||
|
||||
def add_report(pkg, version, result=None, rating=None, notes='', via='user', runtime='instance',
|
||||
label=None, source=None):
|
||||
"""One report for any APK (F-Droid or not): did it work, and how was it run."""
|
||||
if not frame_android.PKG_RE.match(pkg or '') or len(pkg) > 200:
|
||||
raise ValueError('a report needs a valid package name, e.g. org.example.app')
|
||||
if rating not in (None, 'works', 'issues', 'broken'):
|
||||
raise ValueError('rating must be works, issues or broken')
|
||||
if result not in (None, 'runs', 'crashes', 'install_failed', 'instance_failed'):
|
||||
raise ValueError('bad result')
|
||||
if not rating and not result:
|
||||
raise ValueError('say whether it worked')
|
||||
if runtime not in RUNTIMES:
|
||||
raise ValueError(f"runtime must be one of {', '.join(RUNTIMES)}")
|
||||
try:
|
||||
env = environment()
|
||||
except frame_android.FrameError:
|
||||
env = {'steamos': None, 'lepton': None} # Frame asleep: still record the report
|
||||
clean = lambda v, n: (str(v).strip()[:n] or None) if v not in (None, '') else None
|
||||
return compat_db.add({'package': pkg, 'version': clean(version, 80), 'result': result, 'rating': rating,
|
||||
'notes': clean(notes, 1000), 'via': via, 'runtime': runtime,
|
||||
'label': clean(label, 120), 'source': clean(source, 300),
|
||||
'date': time.strftime('%Y-%m-%dT%H:%M:%S'), **env})
|
||||
|
||||
|
||||
def recent_reports(limit=200):
|
||||
"""Newest first, with a display name from the report or the catalogue."""
|
||||
catalog()
|
||||
names = {p: a['n'] for p, a in (_cache['by_pkg'] or {}).items()}
|
||||
out = []
|
||||
for r in sorted(compat_db.load(), key=lambda r: r.get('date') or '', reverse=True)[:limit]:
|
||||
out.append({**r, 'name': r.get('label') or names.get(r['package']) or r['package'],
|
||||
'inCatalog': r['package'] in names})
|
||||
return out
|
||||
|
||||
|
||||
def probe_and_report(pkg):
|
||||
r = frame_android.probe(pkg)
|
||||
add_report(pkg, r.get('version'), result=r['result'], notes=r.get('detail', ''), via='probe')
|
||||
return r
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Frame Control's compatibility database: a private Lakebed capsule
|
||||
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
|
||||
write, using a key kept in the macOS Keychain (service frame-control-compat-db,
|
||||
account app-key).
|
||||
|
||||
New reports go to a local outbox first and are sent from there, so nothing is
|
||||
lost offline. A mirror of every report is kept for offline reads. Both live in
|
||||
~/Library/Application Support/Frame Control/compat-db/. Python stdlib only.
|
||||
|
||||
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
|
||||
(import restores a backup; reports already in the database are skipped.)
|
||||
"""
|
||||
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
|
||||
|
||||
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
|
||||
KEYCHAIN = ('frame-control-compat-db', 'app-key')
|
||||
STATE = os.path.expanduser('~/Library/Application Support/Frame Control/compat-db')
|
||||
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
|
||||
MIRROR = os.path.join(STATE, 'compat-mirror.json')
|
||||
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
|
||||
'label', 'source')
|
||||
TTL = 60 # seconds a fetched copy is reused
|
||||
_lock = threading.Lock()
|
||||
_mem = {'at': 0, 'reports': None, 'source': None}
|
||||
|
||||
|
||||
class DBError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def key():
|
||||
k = os.environ.get('FRAME_CONTROL_KEY')
|
||||
if k:
|
||||
return k
|
||||
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
||||
capture_output=True, text=True)
|
||||
if p.returncode != 0 or not p.stdout.strip():
|
||||
raise DBError('No compatibility-database key in the Keychain '
|
||||
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
|
||||
return p.stdout.strip()
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Never follow redirects: urllib would copy the key header to the new host."""
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
_opener = urllib.request.build_opener(_NoRedirect)
|
||||
|
||||
|
||||
def _request(path, body=None, timeout=20):
|
||||
req = urllib.request.Request(URL + path, method='POST' if body is not None else 'GET',
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
headers={'x-frame-control-key': key(), 'content-type': 'application/json',
|
||||
'user-agent': 'FrameControl/1'})
|
||||
try:
|
||||
with _opener.open(req, timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
raise DBError(f'compatibility database said HTTP {e.code}')
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
||||
raise DBError(f"can't reach the compatibility database: {e}")
|
||||
|
||||
|
||||
def _from_row(row):
|
||||
r = {k: row.get(k) for k in FIELDS if k != 'date'}
|
||||
r['date'] = row.get('reportedAt')
|
||||
r['id'] = row.get('clientId') or row.get('id')
|
||||
return r
|
||||
|
||||
|
||||
def fetch_all():
|
||||
"""Every report from the database (paged), deduplicated."""
|
||||
seen, out, since = set(), [], ''
|
||||
for _ in range(200):
|
||||
page = _request('/v1/reports?since=' + urllib.parse.quote(since))
|
||||
for row in page.get('reports', []):
|
||||
if row.get('id') and row['id'] not in seen:
|
||||
seen.add(row['id'])
|
||||
out.append(_from_row(row))
|
||||
if not page.get('next') or page['next'] == since:
|
||||
break
|
||||
since = page['next']
|
||||
return out
|
||||
|
||||
|
||||
RESULTS = ('runs', 'crashes', 'install_failed', 'instance_failed')
|
||||
RATINGS = ('works', 'issues', 'broken')
|
||||
|
||||
|
||||
def problem(r):
|
||||
"""Why the server would reject this report, or None. Mirrors compat-db/server/index.ts."""
|
||||
if not isinstance(r, dict):
|
||||
return 'not an object'
|
||||
for k in ('package', 'id', 'date'):
|
||||
if not r.get(k) or not isinstance(r[k], str):
|
||||
return f'missing {k}'
|
||||
if r.get('result') not in (None, '', *RESULTS):
|
||||
return f"bad result {r['result']!r}"
|
||||
if r.get('rating') not in (None, '', *RATINGS):
|
||||
return f"bad rating {r['rating']!r}"
|
||||
return None
|
||||
|
||||
|
||||
def _quarantine(lines, why):
|
||||
"""Keep what can't be sent, with the reason, instead of dropping it."""
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with open(OUTBOX + '.rejected', 'a') as f:
|
||||
for line in lines:
|
||||
f.write(json.dumps({'why': why, 'at': time.strftime('%Y-%m-%dT%H:%M:%S'), 'line': line}) + '\n')
|
||||
|
||||
|
||||
def _outbox():
|
||||
"""Queued reports. Unreadable or invalid lines move to the .rejected file."""
|
||||
if not os.path.exists(OUTBOX):
|
||||
return []
|
||||
good, bad = [], []
|
||||
with open(OUTBOX) as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except ValueError:
|
||||
bad.append((line.rstrip('\n'), 'unreadable JSON'))
|
||||
continue
|
||||
why = problem(r)
|
||||
(bad.append((line.rstrip('\n'), why)) if why else good.append(r))
|
||||
if bad:
|
||||
for line, why in bad:
|
||||
_quarantine([line], why)
|
||||
_write_outbox(good)
|
||||
return good
|
||||
|
||||
|
||||
def _write_outbox(rows):
|
||||
with open(OUTBOX + '.tmp', 'w') as f:
|
||||
f.writelines(json.dumps(r, ensure_ascii=False) + '\n' for r in rows)
|
||||
os.replace(OUTBOX + '.tmp', OUTBOX)
|
||||
|
||||
|
||||
def flush():
|
||||
"""Send queued reports. Sent ones leave the outbox; ones the server rejects go to
|
||||
the .rejected file; on a network error the rest stay queued. Returns how many are left."""
|
||||
with _lock:
|
||||
pending = _outbox()
|
||||
while pending:
|
||||
batch = pending[:100]
|
||||
res = _request('/v1/reports', {'reports': [{**r, 'clientId': r['id']} for r in batch]})
|
||||
rejected = set(res.get('rejected') or [])
|
||||
if rejected:
|
||||
_quarantine([json.dumps(r) for r in batch if r['id'] in rejected], 'rejected by the server')
|
||||
pending = pending[100:]
|
||||
_write_outbox(pending)
|
||||
return len(pending)
|
||||
|
||||
|
||||
def _read_mirror():
|
||||
try:
|
||||
with open(MIRROR) as f:
|
||||
return [r for r in json.load(f).get('reports', []) if isinstance(r, dict) and r.get('package')]
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
|
||||
def load():
|
||||
"""All reports: the database (cached for TTL s), else the offline mirror; plus unsent ones."""
|
||||
now = time.time()
|
||||
if _mem['reports'] is None or now - _mem['at'] > TTL:
|
||||
try:
|
||||
try:
|
||||
flush()
|
||||
except Exception:
|
||||
pass # sending can fail for any reason; reading must still work
|
||||
reports, source = fetch_all(), 'lakebed'
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with open(MIRROR + '.tmp', 'w') as f:
|
||||
json.dump({'fetched': time.strftime('%Y-%m-%dT%H:%M:%S'), 'reports': reports}, f)
|
||||
os.replace(MIRROR + '.tmp', MIRROR)
|
||||
except DBError:
|
||||
reports, source = _read_mirror(), 'mirror'
|
||||
_mem.update(at=now, reports=reports, source=source)
|
||||
sent = {r.get('id') for r in _mem['reports']}
|
||||
return _mem['reports'] + [r for r in _outbox() if r['id'] not in sent]
|
||||
|
||||
|
||||
def add(report):
|
||||
"""Validate, queue, then try to send. Never raises once the report is queued."""
|
||||
r = {k: report.get(k) for k in FIELDS}
|
||||
r['id'] = report.get('id') or str(uuid.uuid4())
|
||||
why = problem(r)
|
||||
if why:
|
||||
raise ValueError(f'report not saved: {why}')
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with _lock, open(OUTBOX, 'a') as f:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + '\n')
|
||||
try:
|
||||
flush()
|
||||
_mem['at'] = 0 # refetch on next load
|
||||
except Exception:
|
||||
pass # stays queued; load() shows it and a later call sends it
|
||||
return r
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['count']
|
||||
try:
|
||||
if cmd == 'count':
|
||||
print(len(fetch_all()))
|
||||
elif cmd == 'export':
|
||||
reports = fetch_all()
|
||||
with open(args[0], 'w') as f:
|
||||
json.dump({'exported': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'source': URL,
|
||||
'count': len(reports), 'reports': reports}, f, indent=1)
|
||||
print(f'{len(reports)} reports -> {args[0]}')
|
||||
elif cmd == 'import':
|
||||
with open(args[0]) as f:
|
||||
backup = json.load(f)
|
||||
rows = [{**{k: r.get(k) for k in FIELDS}, 'id': r.get('id')} for r in backup['reports']]
|
||||
bad = [(r, problem(r)) for r in rows if problem(r)]
|
||||
ok = [r for r in rows if not problem(r)]
|
||||
for r, why in bad:
|
||||
print(f"skipped {r.get('package')!r}: {why}", file=sys.stderr)
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with _lock, open(OUTBOX, 'a') as f:
|
||||
f.writelines(json.dumps(r) + '\n' for r in ok)
|
||||
left = flush()
|
||||
print(f'{len(ok)} reports sent, {len(bad)} invalid skipped, {left} still queued; '
|
||||
'reports already in the database were not duplicated')
|
||||
elif cmd == 'flush':
|
||||
print(f'{flush()} still queued')
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
except DBError as e:
|
||||
sys.exit(f'error: {e}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Runs ON the Steam Frame (piped over SSH as `python3 -`); prints one JSON object.
|
||||
|
||||
Read-only. Every probe is best effort: a missing tool or file gives null, not an
|
||||
error. Paths verified on SteamOS 0.3.0 (vr), build 20260922.
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
# Runtimes and compatibility tools that show up as "apps" in steamapps/.
|
||||
TOOL_NAME = re.compile(r"^(Steam Linux Runtime|Proton|Steamworks Common|FEX$|Lepton Development$)")
|
||||
|
||||
|
||||
def read(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def run(*cmd):
|
||||
try:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=2).stdout.strip()
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
|
||||
def num(path, scale=1.0):
|
||||
v = read(path)
|
||||
try:
|
||||
return int(v) * scale
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def battery():
|
||||
for d in glob.glob("/sys/class/power_supply/*"):
|
||||
if read(d + "/type") != "Battery":
|
||||
continue
|
||||
cap = read(d + "/capacity")
|
||||
# max1720x reports microvolts/microamps; current is positive while charging.
|
||||
volts, amps = num(d + "/voltage_now", 1e-6), num(d + "/current_now", 1e-6)
|
||||
return {"percent": int(cap) if cap and cap.isdigit() else None,
|
||||
"status": read(d + "/status"),
|
||||
"watts": round(volts * amps, 2) if volts is not None and amps is not None else None,
|
||||
"timeToFull": num(d + "/time_to_full_now"),
|
||||
"timeToEmpty": num(d + "/time_to_empty_now"),
|
||||
"tempC": num(d + "/temp", 0.1),
|
||||
"health": read(d + "/health")}
|
||||
return None
|
||||
|
||||
|
||||
def power_source():
|
||||
"""The plugged-in charger, if any: {'type': 'C PD [PD_PPS]', 'watts': 20.0}."""
|
||||
for d in glob.glob("/sys/class/power_supply/*"):
|
||||
if read(d + "/type") == "USB" and read(d + "/online") == "1":
|
||||
volts, amps = num(d + "/voltage_now", 1e-6), num(d + "/current_now", 1e-6)
|
||||
return {"type": read(d + "/usb_type"),
|
||||
"watts": round(volts * amps, 1) if volts and amps else None}
|
||||
for d in glob.glob("/sys/class/power_supply/*"):
|
||||
if read(d + "/type") != "Battery" and read(d + "/online") == "1":
|
||||
return {"type": None, "watts": None}
|
||||
return None
|
||||
|
||||
|
||||
def disk(path):
|
||||
try:
|
||||
s = os.statvfs(path)
|
||||
except OSError:
|
||||
return None
|
||||
return {"total": s.f_blocks * s.f_frsize, "free": s.f_bavail * s.f_frsize}
|
||||
|
||||
|
||||
def memory():
|
||||
info = {}
|
||||
for line in (read("/proc/meminfo") or "").splitlines():
|
||||
k, _, v = line.partition(":")
|
||||
info[k] = int(v.split()[0]) * 1024 if v.split() else 0
|
||||
if "MemTotal" not in info:
|
||||
return None
|
||||
return {"total": info["MemTotal"], "available": info.get("MemAvailable", 0)}
|
||||
|
||||
|
||||
def max_temp():
|
||||
temps = []
|
||||
for z in glob.glob("/sys/class/thermal/thermal_zone*/temp"):
|
||||
t = read(z)
|
||||
if t and t.lstrip("-").isdigit():
|
||||
temps.append(int(t) / 1000)
|
||||
return max(temps) if temps else None
|
||||
|
||||
|
||||
def wifi():
|
||||
for line in run("nmcli", "-t", "-f", "active,ssid,signal", "dev", "wifi").splitlines():
|
||||
# nmcli escapes ':' inside fields as '\:'.
|
||||
parts = re.split(r"(?<!\\):", line)
|
||||
if len(parts) >= 3 and parts[0] == "yes":
|
||||
return {"ssid": parts[1].replace("\\:", ":"),
|
||||
"signal": int(parts[2]) if parts[2].isdigit() else None}
|
||||
return None
|
||||
|
||||
|
||||
def ip_addr():
|
||||
m = re.search(r"\s(\d+\.\d+\.\d+\.\d+)/", run("ip", "-4", "-brief", "addr", "show", "scope", "global"))
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def os_release():
|
||||
out = {}
|
||||
for line in (read("/etc/os-release") or "").splitlines():
|
||||
k, _, v = line.partition("=")
|
||||
out[k] = v.strip('"')
|
||||
return {"version": out.get("VERSION_ID"), "build": out.get("BUILD_ID"),
|
||||
"variant": out.get("VARIANT_ID")}
|
||||
|
||||
|
||||
def volume():
|
||||
m = re.search(r"Volume:\s*([\d.]+)(.*)", run("wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"))
|
||||
if not m:
|
||||
return None
|
||||
return {"level": float(m.group(1)), "muted": "MUTED" in m.group(2)}
|
||||
|
||||
|
||||
def process_names():
|
||||
return set(run("ps", "-e", "-o", "comm=").split()) # xrdp runs as root
|
||||
|
||||
|
||||
def port_listening(port):
|
||||
return f":{port} " in run("ss", "-ltn")
|
||||
|
||||
|
||||
def games():
|
||||
out = []
|
||||
for f in glob.glob(os.path.join(STEAM, "steamapps/appmanifest_*.acf")):
|
||||
text = read(f) or ""
|
||||
fields = dict(re.findall(r'^\s*"(appid|name|SizeOnDisk)"\s+"([^"]*)"', text, re.M))
|
||||
if fields.get("appid", "").isdigit() and not TOOL_NAME.match(fields.get("name", "")):
|
||||
out.append({"appid": fields["appid"], "name": fields.get("name", fields["appid"]),
|
||||
"size": int(fields.get("SizeOnDisk", "0")) if fields.get("SizeOnDisk", "").isdigit() else 0})
|
||||
return sorted(out, key=lambda g: g["name"].lower())
|
||||
|
||||
|
||||
def flatpaks():
|
||||
out = []
|
||||
for line in run("flatpak", "list", "--app", "--columns=application,name,version,installation").splitlines():
|
||||
p = line.split("\t")
|
||||
if len(p) == 4:
|
||||
out.append({"id": p[0], "name": p[1], "version": p[2], "installation": p[3]})
|
||||
return out
|
||||
|
||||
|
||||
uptime = read("/proc/uptime")
|
||||
procs = process_names()
|
||||
print(json.dumps({
|
||||
"time": time.time(),
|
||||
"hostname": socket.gethostname(),
|
||||
"os": os_release(),
|
||||
"uptime": float(uptime.split()[0]) if uptime else None,
|
||||
"battery": battery(),
|
||||
"power": power_source(),
|
||||
"disk": {"root": disk("/"), "home": disk("/home")},
|
||||
"memory": memory(),
|
||||
"temp": max_temp(),
|
||||
"wifi": wifi(),
|
||||
"ip": ip_addr(),
|
||||
"volume": volume(),
|
||||
"services": {
|
||||
"steamvr": "vrserver" in procs,
|
||||
"desktop": "plasmashell" in procs,
|
||||
"lepton": port_listening(5555),
|
||||
"rdp": "xrdp" in procs,
|
||||
},
|
||||
"games": games(),
|
||||
"flatpaks": flatpaks(),
|
||||
}))
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs ON the Frame (piped over SSH): read and drive the Steam client.
|
||||
|
||||
The Frame's Steam client starts with -cef-enable-debugging, so its UI's
|
||||
JavaScript context ("SharedJSContext") answers the Chrome DevTools protocol on
|
||||
127.0.0.1:8080. That context holds the library (appStore), downloads
|
||||
(downloadsStore) and the SteamClient API. This file is a stdlib-only WebSocket
|
||||
client for it, plus the few actions Frame Control needs.
|
||||
|
||||
Usage: python3 - owned # owned games + download status, JSON
|
||||
python3 - install APPID # start an install; reports the wizard state
|
||||
python3 - store APPID # open the app's store page in the headset
|
||||
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
CDP = "http://127.0.0.1:8080/json"
|
||||
|
||||
# EInstallMgrState values from the client's own JS (steamui, 2026-09).
|
||||
STATE = {0: "none", 1: "setup", 2: "waiting for license", 3: "free license", 4: "CD key",
|
||||
5: "waiting for app info", 6: "password", 7: "config", 8: "EULA", 9: "creating apps",
|
||||
10: "reading media", 11: "change media", 12: "legacy CD keys", 13: "signup",
|
||||
14: "complete", 15: "failed", 16: "canceled"}
|
||||
# States where Steam is waiting for someone to answer a dialog in the headset.
|
||||
NEEDS_HEADSET = {3, 4, 6, 8, 11, 13}
|
||||
BUSY = {1, 2, 5, 9, 10, 12}
|
||||
|
||||
|
||||
class Fail(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Page:
|
||||
"""Minimal CDP-over-WebSocket client (text frames, no extensions)."""
|
||||
|
||||
def __init__(self, title="SharedJSContext"):
|
||||
try:
|
||||
pages = json.load(urllib.request.urlopen(CDP, timeout=5))
|
||||
except OSError as e:
|
||||
raise Fail(f"Steam's UI isn't answering on {CDP} ({e}); is Steam running?")
|
||||
url = next((p["webSocketDebuggerUrl"] for p in pages if p.get("title") == title), None)
|
||||
if not url:
|
||||
raise Fail(f"no {title} page; Steam may still be starting")
|
||||
hostport, path = url.split("://", 1)[1].split("/", 1)
|
||||
host, port = hostport.rsplit(":", 1)
|
||||
self.sock = socket.create_connection((host, int(port)), timeout=30)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
self.sock.sendall((f"GET /{path} HTTP/1.1\r\nHost: {hostport}\r\nUpgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n").encode())
|
||||
head = b""
|
||||
while b"\r\n\r\n" not in head:
|
||||
chunk = self.sock.recv(4096)
|
||||
if not chunk:
|
||||
raise Fail("DevTools closed the connection during the handshake")
|
||||
head += chunk
|
||||
head, self.buf = head.split(b"\r\n\r\n", 1)
|
||||
if b" 101 " not in head.split(b"\r\n")[0]:
|
||||
raise Fail("DevTools refused the WebSocket upgrade")
|
||||
self.next_id = 0
|
||||
|
||||
def _send(self, text):
|
||||
data, mask = text.encode(), os.urandom(4)
|
||||
n = len(data)
|
||||
if n < 126:
|
||||
head = struct.pack(">BB", 0x81, 0x80 | n)
|
||||
elif n < 1 << 16:
|
||||
head = struct.pack(">BBH", 0x81, 0x80 | 126, n)
|
||||
else:
|
||||
head = struct.pack(">BBQ", 0x81, 0x80 | 127, n)
|
||||
self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
|
||||
|
||||
def _take(self, n):
|
||||
while len(self.buf) < n:
|
||||
chunk = self.sock.recv(1 << 16)
|
||||
if not chunk:
|
||||
raise Fail("DevTools closed the connection")
|
||||
self.buf += chunk
|
||||
out, self.buf = self.buf[:n], self.buf[n:]
|
||||
return out
|
||||
|
||||
def _recv(self):
|
||||
message = b""
|
||||
while True:
|
||||
b0, b1 = self._take(2)
|
||||
n = b1 & 0x7F
|
||||
if n == 126:
|
||||
n = struct.unpack(">H", self._take(2))[0]
|
||||
elif n == 127:
|
||||
n = struct.unpack(">Q", self._take(8))[0]
|
||||
payload = self._take(n)
|
||||
if b0 & 0x0F in (0x1, 0x0): # text or continuation; skip ping/pong/close
|
||||
message += payload
|
||||
if b0 & 0x80:
|
||||
return message.decode()
|
||||
|
||||
def eval(self, expr):
|
||||
"""Evaluate JS (awaiting promises) and return its JSON-serialisable value."""
|
||||
self.next_id += 1
|
||||
self._send(json.dumps({"id": self.next_id, "method": "Runtime.evaluate", "params": {
|
||||
"expression": expr, "awaitPromise": True, "returnByValue": True}}))
|
||||
while True:
|
||||
msg = json.loads(self._recv())
|
||||
if msg.get("id") != self.next_id:
|
||||
continue # events
|
||||
if "error" in msg: # protocol error, e.g. the page is reloading
|
||||
raise Fail(f"DevTools: {msg['error'].get('message', msg['error'])}")
|
||||
res = msg.get("result", {})
|
||||
if "exceptionDetails" in res:
|
||||
d = res["exceptionDetails"]
|
||||
raise Fail(d.get("exception", {}).get("description") or d.get("text") or "JS error")
|
||||
return res.get("result", {}).get("value")
|
||||
|
||||
|
||||
# steam_hw_compat_category_packed: 2 bits per device; the client reads the
|
||||
# Frame's rating as `packed >> 8 & 3` (0 unknown, 1 unsupported, 2 playable, 3 verified).
|
||||
OWNED_JS = r"""
|
||||
(async () => {
|
||||
const country = await SteamClient.User.GetIPCountry().catch(() => null);
|
||||
const dl = new Map(downloadsStore.m_DownloadOverview || []).get("0") || null;
|
||||
const games = appStore.allApps.filter(a => a.app_type == 1).map(a => {
|
||||
const c = a.local_per_client_data || {};
|
||||
return { id: a.appid, name: a.display_name, sort: a.sort_as || a.display_name,
|
||||
installed: !!c.installed, status: c.display_status ?? null, pct: c.status_percentage ?? null,
|
||||
frame: (a.steam_hw_compat_category_packed >> 8) & 3, deck: a.steam_hw_compat_category_packed & 3,
|
||||
vr: !!a.vr_supported, vrOnly: !!a.vr_only, size: Number(a.size_on_disk || 0),
|
||||
minutes: a.minutes_playtime_forever || 0, lastPlayed: a.rt_last_time_played || 0 };
|
||||
});
|
||||
return { games, country, download: dl && dl.update_appid ? {
|
||||
appid: dl.update_appid, state: dl.update_state, paused: dl.paused, install: dl.update_is_install,
|
||||
percent: dl.overall_percent_complete, eta: dl.overall_estimated_time_remaining_sec,
|
||||
bps: dl.update_network_bytes_per_second } : null };
|
||||
})()
|
||||
"""
|
||||
|
||||
WIZARD_JS = """SteamClient.Installs.GetInstallManagerInfo().then(i => ({
|
||||
state: i.eInstallState, app: i.currentAppID, need: i.nDiskSpaceRequired, free: i.nDiskSpaceAvailable,
|
||||
error: i.eAppError, detail: i.errorDetail }))"""
|
||||
|
||||
|
||||
def steam_url(url):
|
||||
"""Hand a steam:// URL to the running client (the `steam` wrapper forwards it)."""
|
||||
subprocess.Popen(["steam", url], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
|
||||
|
||||
def owned():
|
||||
return Page().eval(OWNED_JS)
|
||||
|
||||
|
||||
def install(appid):
|
||||
page = Page()
|
||||
app = page.eval(f"(a => a && {{name: a.display_name, installed: !!a.local_per_client_data?.installed}})"
|
||||
f"(appStore.GetAppOverviewByAppID({appid}))")
|
||||
if app and app["installed"]:
|
||||
return {"state": "installed", "message": f"{app['name']} is already installed"}
|
||||
name = app["name"] if app else str(appid)
|
||||
# The URL goes through Steam's own handler, which marks the install as
|
||||
# expected and skips the options dialog when there is one library folder
|
||||
# and enough space (verified 2026-09-25). A bare OpenInstallWizard doesn't.
|
||||
steam_url(f"steam://install/{appid}")
|
||||
w = None
|
||||
deadline = time.time() + 12
|
||||
while time.time() < deadline:
|
||||
time.sleep(0.4)
|
||||
w = page.eval(WIZARD_JS)
|
||||
if w["app"] == appid and w["state"] not in BUSY:
|
||||
break
|
||||
if not w or w["app"] != appid:
|
||||
# Steam didn't open a wizard for this app in time, or another app's is open.
|
||||
if not app:
|
||||
return {"state": "unknown", "message": f"Asked Steam to install {appid}. It isn't in this account's "
|
||||
"library, so Steam may be showing a store or license dialog in the headset"}
|
||||
other = f" Another install dialog (app {w['app']}) is open in the headset." if w and w["app"] else ""
|
||||
return {"state": "unknown", "message": f"Asked Steam to install {name}, but it didn't start within 12 s.{other}"}
|
||||
s = w["state"]
|
||||
# Steam stops at the options dialog when it wants to show a compatibility
|
||||
# note or the disk settings. Accept its defaults (the default library
|
||||
# folder) when the game fits, as the headset's own Install button does.
|
||||
if s == 7 and 0 < w["need"] < w["free"]:
|
||||
page.eval("SteamClient.Installs.ContinueInstall()")
|
||||
time.sleep(1.5)
|
||||
w = page.eval(WIZARD_JS)
|
||||
s = 14 if w["state"] in (0, 14) else w["state"]
|
||||
label = STATE.get(s, str(s))
|
||||
if s == 14:
|
||||
return {"state": "downloading", "message": f"{name} is queued to download on the Frame"}
|
||||
if s == 7:
|
||||
return {"state": "headset", "message": f"Steam is showing install options for {name} in the headset "
|
||||
f"(needs {w['need'] / 1e9:.1f} GB, {w['free'] / 1e9:.0f} GB free)"}
|
||||
if s in NEEDS_HEADSET:
|
||||
return {"state": "headset", "message": f"Steam needs you to accept the {label} for {name} in the headset"}
|
||||
if s == 15:
|
||||
raise Fail(f"Steam couldn't install {name}: {w.get('detail') or 'error ' + str(w.get('error'))}")
|
||||
if not app:
|
||||
return {"state": "unknown", "message": f"Asked Steam to install {appid}. It isn't in this account's "
|
||||
"library, so Steam may be showing a store or license dialog in the headset"}
|
||||
return {"state": label, "message": f"Asked Steam to install {name} (wizard: {label})"}
|
||||
|
||||
|
||||
def store(appid):
|
||||
steam_url(f"steam://store/{appid}")
|
||||
return {"message": "Opened the store page in the headset"}
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if cmd == "owned":
|
||||
out = owned()
|
||||
elif cmd in ("install", "store") and len(sys.argv) == 3 and sys.argv[2].isdigit():
|
||||
out = (install if cmd == "install" else store)(int(sys.argv[2]))
|
||||
else:
|
||||
raise Fail("usage: owned | install APPID | store APPID")
|
||||
except (Fail, OSError) as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
except Exception as e: # keep the one-JSON-object contract for the server
|
||||
print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
|
||||
sys.exit(1)
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Mac side: search the Steam store and look up each result's Steam Frame rating.
|
||||
|
||||
Uses the store's public endpoints (no key, no login):
|
||||
api/storesearch name search, price in the IP's currency
|
||||
saleaction/ajaxgetdeckappcompatibilityreport per-app Deck/SteamOS/Machine/Frame ratings;
|
||||
`frame_resolved_category` is the Frame's
|
||||
Buying happens on the store page, signed in as the user; nothing here buys.
|
||||
"""
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
STORE = "https://store.steampowered.com"
|
||||
UA = {"User-Agent": "FrameControl/1 (+local)"}
|
||||
COMPAT_TTL = 24 * 3600
|
||||
|
||||
_compat = {} # appid -> (time, category)
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get(path, params, timeout=10):
|
||||
url = f"{STORE}/{path}?{urllib.parse.urlencode(params)}"
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=timeout) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def frame_rating(appid):
|
||||
"""0 unknown, 1 unsupported, 2 playable, 3 verified (same scale as the client's)."""
|
||||
with _lock:
|
||||
hit = _compat.get(appid)
|
||||
if hit and time.time() - hit[0] < COMPAT_TTL:
|
||||
return hit[1]
|
||||
try:
|
||||
r = _get("saleaction/ajaxgetdeckappcompatibilityreport", {"nAppID": appid, "l": "english"})
|
||||
cat = int((r.get("results") or {}).get("frame_resolved_category") or 0)
|
||||
cat = cat if 0 <= cat <= 3 else 0
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
return 0 # not cached, so the next search retries
|
||||
with _lock:
|
||||
_compat[appid] = (time.time(), cat)
|
||||
return cat
|
||||
|
||||
|
||||
def search(term, cc):
|
||||
"""cc: two-letter store country; storesearch returns nothing without one."""
|
||||
term = term.strip()[:100]
|
||||
if not term:
|
||||
return []
|
||||
items = _get("api/storesearch", {"term": term, "l": "english", "cc": cc}).get("items") or []
|
||||
apps = [i for i in items if i.get("type") == "app" and str(i.get("id", "")).isdigit()]
|
||||
with ThreadPoolExecutor(8) as pool:
|
||||
ratings = list(pool.map(lambda i: frame_rating(int(i["id"])), apps))
|
||||
return [{"id": int(i["id"]), "name": i.get("name", ""), "price": i.get("price"),
|
||||
"image": i.get("tiny_image"), "metascore": i.get("metascore") or None, "frame": f}
|
||||
for i, f in zip(apps, ratings)]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Runs ON the Steam Frame (piped over SSH as `python3 -`); captures the headset view.
|
||||
|
||||
Asks SteamVR for a stereo screenshot through OpenVR's IVRScreenshots API (ctypes,
|
||||
so nothing to build or install). The result is a side-by-side PNG of both eyes
|
||||
with everything composited: the room, floating panels, dashboard and
|
||||
controllers. Verified 2026-09-25 (SteamOS 0.3.0, SteamVR 2.17.10): 1920x1080,
|
||||
960x1080 per eye.
|
||||
|
||||
Prints one JSON line: {"path": ...} or {"error": ...}. The caller copies the PNG
|
||||
back and deletes it.
|
||||
"""
|
||||
import ctypes as C
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
LIB = "/opt/steamvr/bin/linuxarm64/libopenvr_api.so"
|
||||
OUT_DIR = "/tmp/frame-vrcap"
|
||||
APP_OVERLAY = 2 # doesn't take focus from whatever is running
|
||||
SCREENSHOT_STEREO = 2
|
||||
ERRORS = {1: "request failed", 100: "incompatible version", 101: "not found",
|
||||
102: "buffer too small", 108: "screenshot already in progress"}
|
||||
Err, Handle = C.c_int, C.c_uint32
|
||||
|
||||
|
||||
class ScreenshotsFnTable(C.Structure):
|
||||
# openvr_capi.h, VR_IVRScreenshots_FnTable (IVRScreenshots_001).
|
||||
_fields_ = [
|
||||
("RequestScreenshot", C.CFUNCTYPE(Err, C.POINTER(Handle), C.c_int, C.c_char_p, C.c_char_p)),
|
||||
("HookScreenshot", C.CFUNCTYPE(Err, C.POINTER(C.c_int), C.c_int)),
|
||||
("GetScreenshotPropertyType", C.CFUNCTYPE(C.c_int, Handle, C.POINTER(Err))),
|
||||
("GetScreenshotPropertyFilename", C.CFUNCTYPE(C.c_uint32, Handle, C.c_int, C.c_char_p,
|
||||
C.c_uint32, C.POINTER(Err))),
|
||||
("UpdateScreenshotProgress", C.CFUNCTYPE(Err, Handle, C.c_float)),
|
||||
("TakeStereoScreenshot", C.CFUNCTYPE(Err, C.POINTER(Handle), C.c_char_p, C.c_char_p)),
|
||||
("SubmitScreenshot", C.CFUNCTYPE(Err, Handle, C.c_int, C.c_char_p, C.c_char_p)),
|
||||
]
|
||||
|
||||
|
||||
def done(**result):
|
||||
print(json.dumps(result))
|
||||
sys.exit(0 if "path" in result else 1)
|
||||
|
||||
|
||||
def settled(path, wait=0.1):
|
||||
"""True once the file exists and its size has stopped changing."""
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
time.sleep(wait)
|
||||
return size > 0 and size == os.path.getsize(path)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def capture(vr):
|
||||
err = Err(0)
|
||||
ptr = vr.VR_GetGenericInterface(b"FnTable:IVRScreenshots_001", C.byref(err))
|
||||
if err.value or not ptr:
|
||||
done(error=f"SteamVR screenshots unavailable ({err.value})")
|
||||
shots = C.cast(ptr, C.POINTER(ScreenshotsFnTable)).contents
|
||||
|
||||
os.makedirs(OUT_DIR, mode=0o700, exist_ok=True)
|
||||
for name in os.listdir(OUT_DIR): # leftovers from interrupted captures
|
||||
p = os.path.join(OUT_DIR, name)
|
||||
try:
|
||||
# Older than the server's 15 s `timeout`, so no capture still owns it.
|
||||
if time.time() - os.path.getmtime(p) > 20:
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
pass
|
||||
base = os.path.join(OUT_DIR, f"shot-{os.getpid()}")
|
||||
handle = Handle(0)
|
||||
rc = shots.RequestScreenshot(C.byref(handle), SCREENSHOT_STEREO,
|
||||
(base + "-preview").encode(), (base + "-vr").encode())
|
||||
if rc:
|
||||
done(error=f"SteamVR screenshot: {ERRORS.get(rc, rc)}")
|
||||
# The compositor appends .png. The preview (left eye only) is written last.
|
||||
stereo, preview = base + "-vr.png", base + "-preview.png"
|
||||
deadline = time.time() + 8
|
||||
while time.time() < deadline:
|
||||
if os.path.exists(preview) and settled(stereo):
|
||||
os.remove(preview)
|
||||
return stereo
|
||||
time.sleep(0.05)
|
||||
for p in (stereo, preview):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
done(error="SteamVR didn't write the screenshot in time")
|
||||
|
||||
|
||||
def main():
|
||||
vr = C.CDLL(LIB)
|
||||
vr.VR_InitInternal2.restype = C.c_uint32
|
||||
vr.VR_InitInternal2.argtypes = [C.POINTER(Err), C.c_int, C.c_char_p]
|
||||
vr.VR_GetGenericInterface.restype = C.c_void_p
|
||||
vr.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(Err)]
|
||||
err = Err(0)
|
||||
vr.VR_InitInternal2(C.byref(err), APP_OVERLAY, None)
|
||||
if err.value:
|
||||
done(error=f"Can't reach SteamVR (init error {err.value}). Is SteamVR running?")
|
||||
try:
|
||||
done(path=capture(vr))
|
||||
finally:
|
||||
vr.VR_ShutdownInternal()
|
||||
|
||||
|
||||
main()
|
||||
+1396
File diff suppressed because it is too large.
Load diff
Executable
+785
@@ -0,0 +1,785 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame Control: a small local web UI for managing the Steam Frame from the Mac.
|
||||
|
||||
Stdlib only. Listens on 127.0.0.1 and talks to the headset through the `frame`
|
||||
SSH alias set up by scripts/connect.sh, reusing the scripts in ../scripts.
|
||||
|
||||
Usage: ui/server.py [--port 47810] (normally started by scripts/frame-ui.sh)
|
||||
Env: FRAME_ALIAS (default frame)
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
import frame_android
|
||||
import frame_catalog
|
||||
import frame_store
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
SCRIPTS = HERE.parent / "scripts"
|
||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||
# Reuse one SSH connection for the frequent status/screenshot calls. /tmp, not
|
||||
# $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
||||
CONTROL = f"/tmp/frame-ui-{os.getuid()}-%C"
|
||||
MUX = ["ssh", "-o", "BatchMode=yes", "-o", f"ControlPath={CONTROL}"]
|
||||
# Commands use the master when it's up and connect directly when it isn't.
|
||||
SSH = [*MUX, "-o", "ControlMaster=no", "-o", "ConnectTimeout=5"]
|
||||
|
||||
# Android helpers share the multiplexed connection when it's up.
|
||||
frame_android.SSH_OPTS = SSH[1:]
|
||||
|
||||
APPID = re.compile(r"^\d{1,10}$")
|
||||
FLATPAK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$")
|
||||
MAX_UPLOAD = 8 * 1024**3
|
||||
MAX_JSON = 1024**2
|
||||
|
||||
# gamescope writes the PNG asynchronously; wait until its size stops changing.
|
||||
SCREENSHOT = r"""
|
||||
set -eu
|
||||
f=$(mktemp /tmp/frame-ui-XXXXXX.png)
|
||||
trap 'rm -f "$f"' EXIT
|
||||
XDG_RUNTIME_DIR=/run/user/$(id -u) WAYLAND_DISPLAY=gamescope-0 gamescopectl screenshot "$f" >/dev/null 2>&1
|
||||
last=-1
|
||||
for i in $(seq 1 50); do
|
||||
sleep 0.1
|
||||
size=$(stat -c %s "$f" 2>/dev/null || echo 0)
|
||||
if [ "$size" -gt 0 ] && [ "$size" = "$last" ]; then cat "$f"; exit 0; fi
|
||||
last=$size
|
||||
done
|
||||
echo "gamescope did not write a screenshot" >&2
|
||||
exit 1
|
||||
"""
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
def __init__(self, message, status=502):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
_master_lock = threading.Lock()
|
||||
_master = None
|
||||
|
||||
|
||||
def ensure_master():
|
||||
"""Start the shared SSH connection if it isn't up (one attempt at a time).
|
||||
|
||||
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
||||
"""
|
||||
global _master
|
||||
def up():
|
||||
try:
|
||||
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True,
|
||||
timeout=5).returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
|
||||
with _master_lock:
|
||||
if up() or (_master and _master.poll() is None):
|
||||
return
|
||||
# Keepalives make a dead link (Frame asleep, off Wi-Fi) exit within ~10s,
|
||||
# so the next request starts a fresh master.
|
||||
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
||||
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
for _ in range(60):
|
||||
if up() or _master.poll() is not None:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||
try:
|
||||
ensure_master()
|
||||
r = subprocess.run([*SSH, FRAME, remote], input=stdin, capture_output=True,
|
||||
text=text, errors="replace" if text else None, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"Timed out talking to {FRAME}")
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
|
||||
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
||||
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
|
||||
raise failure
|
||||
return r.stdout
|
||||
|
||||
|
||||
def script(name, *args, stdin=None, timeout=900):
|
||||
"""Run one of ../scripts and return its combined output."""
|
||||
try:
|
||||
r = subprocess.run([str(SCRIPTS / name), *args], input=stdin, text=True, timeout=timeout,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
env={**os.environ, "FRAME_ALIAS": FRAME})
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"{name} timed out")
|
||||
out = strip_ansi(r.stdout).strip()
|
||||
if r.returncode != 0:
|
||||
raise Failure(out or f"{name} exited {r.returncode}")
|
||||
return out
|
||||
|
||||
|
||||
def strip_ansi(s):
|
||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
||||
|
||||
|
||||
def terminal(command):
|
||||
"""Open Terminal.app running `command` (for anything needing a password)."""
|
||||
as_str = command.replace("\\", "\\\\").replace('"', '\\"')
|
||||
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
|
||||
"-e", f'do script "{as_str}"', "-e", "activate", "-e", "end tell"],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
# Usually macOS Automation consent for Terminal was denied.
|
||||
raise Failure(f"Couldn't open Terminal: {r.stderr.strip()}", 500)
|
||||
|
||||
|
||||
def open_app(name, fallback_url):
|
||||
if subprocess.run(["open", "-a", name], capture_output=True).returncode == 0:
|
||||
return f"Opened {name}"
|
||||
subprocess.run(["open", fallback_url])
|
||||
return f"{name} isn't installed; opened its download page"
|
||||
|
||||
|
||||
# ---- actions ---------------------------------------------------------------
|
||||
|
||||
def status(_body):
|
||||
return json.loads(ssh("python3 -", stdin=(HERE / "frame_status.py").read_text(), timeout=20))
|
||||
|
||||
|
||||
def headset_view():
|
||||
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
|
||||
# `timeout`: VR_Init can block if SteamVR is restarting.
|
||||
out = ssh("timeout 15 python3 -", stdin=(HERE / "frame_vrshot.py").read_text(), timeout=30)
|
||||
# SteamVR prints its own notices (e.g. about vrwebhelper) on stdout too, so
|
||||
# take the last line that is our result object.
|
||||
result = {"error": out.strip() or "no output"}
|
||||
for line in out.splitlines():
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(obj, dict) and ("path" in obj or "error" in obj):
|
||||
result = obj
|
||||
if "error" in result:
|
||||
raise Failure(str(result["error"]))
|
||||
path = str(result["path"])
|
||||
if not re.fullmatch(r"/tmp/frame-vrcap/shot-\d+-vr\.png", path):
|
||||
raise Failure(f"unexpected capture path {path!r}")
|
||||
# Captures show whatever was on screen, so delete even if the copy fails.
|
||||
try:
|
||||
return ssh(f"cat {path}; rc=$?; rm -f {path}; exit $rc", timeout=20, text=False)
|
||||
finally:
|
||||
try:
|
||||
ssh(f"rm -f {path}", timeout=10)
|
||||
except Failure:
|
||||
pass # frame_vrshot.py sweeps leftovers on the next capture
|
||||
|
||||
|
||||
def launch(body):
|
||||
appid = str(body.get("appid", ""))
|
||||
if not APPID.match(appid):
|
||||
raise Failure("bad appid", 400)
|
||||
ssh(f"steam steam://rungameid/{appid} >/dev/null 2>&1 &")
|
||||
return {"message": f"Launching {appid}"}
|
||||
|
||||
|
||||
def steam_frame(*args, timeout=40):
|
||||
"""Run frame_steam.py on the Frame (it drives the Steam client) and return its JSON."""
|
||||
try:
|
||||
out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
|
||||
stdin=(HERE / "frame_steam.py").read_text(), timeout=timeout)
|
||||
except Failure as e:
|
||||
# frame_steam.py prints {"error": ...} on stdout when it fails, but ssh()
|
||||
# reports stderr instead if there was any, so look in both.
|
||||
for line in reversed([*getattr(e, "stdout", "").splitlines(), *str(e).splitlines()]):
|
||||
try:
|
||||
raise Failure(json.loads(line)["error"]) from None
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue
|
||||
raise
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
def steam(body):
|
||||
"""Steam games: install an owned game, or open its store page in the headset."""
|
||||
appid, action = str(body.get("appid", "")), body.get("action")
|
||||
if not APPID.match(appid):
|
||||
raise Failure("bad appid", 400)
|
||||
if action not in ("install", "store"):
|
||||
raise Failure("action must be install or store", 400)
|
||||
return steam_frame(action, appid)
|
||||
|
||||
|
||||
def steam_search(query):
|
||||
q = parse_qs(query)
|
||||
cc = (q.get("cc") or [""])[0].upper()
|
||||
if not re.fullmatch(r"[A-Z]{2}", cc):
|
||||
raise Failure("cc must be a two-letter country code", 400)
|
||||
try:
|
||||
return {"results": frame_store.search((q.get("q") or [""])[0], cc)}
|
||||
except (OSError, ValueError, TypeError, AttributeError) as e:
|
||||
raise Failure(f"Steam store search failed: {e}")
|
||||
|
||||
|
||||
def set_volume(body):
|
||||
if "muted" in body:
|
||||
ssh(f"wpctl set-mute @DEFAULT_AUDIO_SINK@ {1 if body['muted'] else 0}")
|
||||
if "level" in body:
|
||||
level = float(body["level"])
|
||||
if not 0 <= level <= 1:
|
||||
raise Failure("level must be 0..1", 400)
|
||||
ssh(f"wpctl set-volume @DEFAULT_AUDIO_SINK@ {level:.2f}")
|
||||
return {"message": "Volume updated"}
|
||||
|
||||
|
||||
def clipboard(body):
|
||||
if body.get("fromMac"):
|
||||
return {"message": script("paste-to-frame.sh", timeout=30)}
|
||||
text = body.get("text")
|
||||
if not isinstance(text, str) or not text:
|
||||
raise Failure("nothing to send", 400)
|
||||
return {"message": script("paste-to-frame.sh", "-", stdin=text, timeout=30)}
|
||||
|
||||
|
||||
def flatpak(body):
|
||||
app, action = str(body.get("id", "")), body.get("action")
|
||||
if not FLATPAK_ID.match(app):
|
||||
raise Failure("bad Flatpak app ID", 400)
|
||||
if action == "install":
|
||||
return {"message": script("install-apps.sh", app)}
|
||||
if action == "uninstall":
|
||||
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
|
||||
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
|
||||
raise Failure("action must be install or uninstall", 400)
|
||||
|
||||
|
||||
def open_thing(body):
|
||||
what = body.get("what")
|
||||
alias = shlex.quote(FRAME)
|
||||
if what == "terminal":
|
||||
terminal(f"ssh {alias}")
|
||||
return {"message": "Opened an SSH session in Terminal"}
|
||||
if what in ("reboot", "poweroff", "suspend"):
|
||||
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
||||
terminal(f"ssh -t {alias} sudo systemctl {what}")
|
||||
return {"message": f"Confirm with the Developer Mode password in Terminal to {what}"}
|
||||
if what == "steamlink":
|
||||
return {"message": open_app("Steam Link", "https://store.steampowered.com/remoteplay")}
|
||||
if what == "rdp":
|
||||
return {"message": open_app("Windows App", "https://apps.apple.com/app/windows-app/id1295203466")}
|
||||
if what == "sftp":
|
||||
terminal(f"sftp {alias}")
|
||||
return {"message": "Opened an SFTP session in Terminal"}
|
||||
raise Failure("unknown target", 400)
|
||||
|
||||
|
||||
def android(body):
|
||||
"""Android apps, each in its own persistent Lepton instance (frame_android.py)."""
|
||||
action, pkg = body.get("action"), str(body.get("package", ""))
|
||||
ensure_master()
|
||||
try:
|
||||
if action == "install":
|
||||
m = frame_catalog.install(pkg)
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.", "app": m}
|
||||
if action in ("launch", "stop"):
|
||||
m = getattr(frame_android, action)(pkg)
|
||||
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
||||
if action == "remove":
|
||||
m = frame_android.remove(pkg, keep_data=bool(body.get("keepData")))
|
||||
return {"message": f"Removed {m['label']}"}
|
||||
if action == "probe":
|
||||
r = frame_catalog.probe_and_report(pkg)
|
||||
word = {"runs": "runs", "crashes": "crashed", "instance_failed": "didn't start"}.get(r["result"], r["result"])
|
||||
return {"message": f"{pkg} {word}" + (f": {r['detail']}" if r.get("detail") else ""), "probe": r}
|
||||
if action == "report":
|
||||
# Any APK, not only catalogue or installed ones: package, did it work, how it was run.
|
||||
r = frame_catalog.add_report(pkg, body.get("version"), rating=body.get("rating"),
|
||||
notes=str(body.get("notes") or ""),
|
||||
runtime=body.get("runtime") or "instance",
|
||||
label=body.get("label"), source=body.get("source"))
|
||||
name = r.get("label") or pkg
|
||||
return {"message": f"Saved your report for {name}", "report": r}
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e))
|
||||
raise Failure("unknown action", 400)
|
||||
|
||||
|
||||
# ---- Android display (wm size / wm density / font_scale over ADB) -----------
|
||||
#
|
||||
# Each running Lepton instance listens for ADB on the Frame (5555 is Lepton
|
||||
# Development; own-instance apps get the next free port). ADB goes through a
|
||||
# dedicated SSH forward that lives only for the request, like
|
||||
# scripts/install-apk.sh, and is always torn down with an adb disconnect.
|
||||
|
||||
ADB_PORTS = range(5555, 5600)
|
||||
SIZE_RE = re.compile(r"^(\d{3,4})x(\d{3,4})$")
|
||||
DENSITY_RANGE = (120, 640)
|
||||
WIDTH_RANGE, HEIGHT_RANGE = (640, 3840), (360, 2160)
|
||||
FONT_RANGE = (0.5, 2.0)
|
||||
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.
|
||||
_adb_lock = threading.Lock()
|
||||
_live_tunnels = set() # ssh processes to kill if the server stops mid-request
|
||||
|
||||
|
||||
def adb_path():
|
||||
for cand in (os.environ.get("ADB"), shutil.which("adb"), "/opt/homebrew/bin/adb",
|
||||
str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"):
|
||||
if cand and os.access(cand, os.X_OK):
|
||||
return cand
|
||||
raise Failure("adb missing on the Mac: brew install android-platform-tools", 500)
|
||||
|
||||
|
||||
def adb(adb_bin, *args, timeout=20):
|
||||
try:
|
||||
r = subprocess.run([adb_bin, *args], capture_output=True, text=True,
|
||||
errors="replace", timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"adb {' '.join(args[-2:])} timed out")
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
if r.returncode != 0:
|
||||
raise Failure(out or f"adb exited {r.returncode}")
|
||||
return out
|
||||
|
||||
|
||||
def free_local_port():
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
class AdbTunnel:
|
||||
"""SSH forwards from Mac loopback to Frame ADB ports, plus adb connections.
|
||||
|
||||
`with AdbTunnel([5555, 5557]) as t: t.shell(5555, "wm size")`. On exit it
|
||||
disconnects adb and kills the ssh process, whatever happened inside.
|
||||
"""
|
||||
|
||||
def __init__(self, ports):
|
||||
self.remote = list(ports)
|
||||
self.local = {}
|
||||
self.proc = None
|
||||
self.adb = adb_path()
|
||||
|
||||
def __enter__(self):
|
||||
if not _adb_lock.acquire(timeout=60):
|
||||
raise Failure("another Android display request is still running; try again", 503)
|
||||
try:
|
||||
self._open()
|
||||
except BaseException:
|
||||
self.__exit__(None, None, None)
|
||||
raise
|
||||
return self
|
||||
|
||||
def _open(self):
|
||||
try:
|
||||
self._forward()
|
||||
except Failure:
|
||||
# A local port picked by free_local_port() can be taken before ssh
|
||||
# binds it (ExitOnForwardFailure turns that into an error): retry once.
|
||||
self._stop_ssh()
|
||||
self._forward()
|
||||
self.failed = {}
|
||||
for p in self.remote:
|
||||
out = adb(self.adb, "connect", self.serial(p), timeout=15)
|
||||
# adb connect exits 0 even when it fails; check what it says. Keep
|
||||
# going so one stuck port doesn't hide the healthy instances.
|
||||
if "connected to" not in out:
|
||||
self.failed[p] = f"adb couldn't connect to Frame port {p}: {out}"
|
||||
|
||||
def _forward(self):
|
||||
self.local = {p: free_local_port() for p in self.remote}
|
||||
fwd = [a for p, lp in self.local.items() for a in ("-L", f"127.0.0.1:{lp}:127.0.0.1:{p}")]
|
||||
# Its own connection (ControlPath=none), so killing it drops the forwards.
|
||||
self.proc = _proc = subprocess.Popen(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none",
|
||||
"-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=5", "-N", *fwd, FRAME],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
_live_tunnels.add(_proc)
|
||||
deadline = time.time() + 12
|
||||
pending = set(self.local.values())
|
||||
while pending:
|
||||
if self.proc.poll() is not None:
|
||||
err = self.proc.stderr.read().decode(errors="replace").strip()
|
||||
raise Failure(f"ADB tunnel failed: {err or 'ssh exited ' + str(self.proc.returncode)}")
|
||||
if time.time() > deadline:
|
||||
raise Failure("ADB tunnel didn't come up within 12s")
|
||||
for lp in list(pending):
|
||||
try:
|
||||
socket.create_connection(("127.0.0.1", lp), timeout=0.5).close()
|
||||
pending.discard(lp)
|
||||
except OSError:
|
||||
pass
|
||||
if pending:
|
||||
time.sleep(0.1)
|
||||
|
||||
def serial(self, port):
|
||||
return f"127.0.0.1:{self.local[port]}"
|
||||
|
||||
def shell(self, port, command, timeout=20):
|
||||
if port in getattr(self, "failed", {}):
|
||||
raise Failure(self.failed[port])
|
||||
return adb(self.adb, "-s", self.serial(port), "shell", command, timeout=timeout)
|
||||
|
||||
def _stop_ssh(self):
|
||||
proc, self.proc = self.proc, None
|
||||
if not proc:
|
||||
return
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(5)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
_live_tunnels.discard(proc)
|
||||
if proc.stderr:
|
||||
proc.stderr.close()
|
||||
|
||||
def __exit__(self, *exc):
|
||||
try:
|
||||
# Tunnel first: it's what could outlive us. `adb disconnect` only
|
||||
# talks to the local adb server, so it works without the tunnel.
|
||||
self._stop_ssh()
|
||||
for p in self.local:
|
||||
try:
|
||||
subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, timeout=10)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
finally:
|
||||
_adb_lock.release()
|
||||
return False
|
||||
|
||||
|
||||
DISPLAY_READ = "echo @@pkgs; pm list packages -3; echo @@size; wm size; echo @@density; wm density; " \
|
||||
"echo @@font; settings get system font_scale"
|
||||
|
||||
|
||||
def parse_display(out):
|
||||
sec, parts = None, {}
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("@@"):
|
||||
sec = line[2:]
|
||||
parts[sec] = []
|
||||
elif sec and line:
|
||||
parts[sec].append(line)
|
||||
|
||||
def pick(lines, key):
|
||||
for line in lines:
|
||||
if line.lower().startswith(key) and ":" in line:
|
||||
return line.split(":", 1)[1].strip()
|
||||
return None
|
||||
|
||||
size, density = parts.get("size", []), parts.get("density", [])
|
||||
font = (parts.get("font") or ["null"])[0]
|
||||
try:
|
||||
font_scale = None if font == "null" else float(font)
|
||||
except ValueError:
|
||||
font_scale = None
|
||||
phys_d, over_d = pick(density, "physical density"), pick(density, "override density")
|
||||
return {
|
||||
"packages": sorted(l.split(":", 1)[1] for l in parts.get("pkgs", []) if l.startswith("package:")),
|
||||
"physicalSize": pick(size, "physical size"),
|
||||
"overrideSize": pick(size, "override size"),
|
||||
"physicalDensity": int(phys_d) if phys_d and phys_d.isdigit() else None,
|
||||
"overrideDensity": int(over_d) if over_d and over_d.isdigit() else None,
|
||||
# null means never set, which Android treats as 1.0.
|
||||
"fontScale": font_scale,
|
||||
}
|
||||
|
||||
|
||||
def lepton_ports():
|
||||
"""Frame ADB ports (5555-5599) that are listening now, with container names and app labels."""
|
||||
out = ssh("ss -ltnH; echo @@podman; podman ps --format '{{.Names}} {{.Labels.adb_port}}' 2>/dev/null; "
|
||||
f"echo @@meta; for f in {frame_android.APPS_DIR}/*/meta.json; do [ -f \"$f\" ] && cat \"$f\" && echo @@m; done; true",
|
||||
timeout=20)
|
||||
listen, _, rest = out.partition("@@podman")
|
||||
podman, _, meta = rest.partition("@@meta")
|
||||
ports = set()
|
||||
for line in listen.splitlines():
|
||||
cols = line.split()
|
||||
if len(cols) >= 4:
|
||||
port = cols[3].rsplit(":", 1)[-1]
|
||||
if port.isdigit() and int(port) in ADB_PORTS:
|
||||
ports.add(int(port))
|
||||
containers = {}
|
||||
for line in podman.splitlines():
|
||||
cols = line.split()
|
||||
if len(cols) == 2 and cols[1].isdigit():
|
||||
containers[int(cols[1])] = cols[0]
|
||||
labels = dict(KNOWN_LABELS)
|
||||
for chunk in meta.split("@@m"):
|
||||
try:
|
||||
m = json.loads(chunk)
|
||||
labels[str(m["package"])] = str(m["label"])
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pass
|
||||
return sorted(ports), containers, labels
|
||||
|
||||
|
||||
def android_displays():
|
||||
ports, containers, labels = lepton_ports()
|
||||
if not ports:
|
||||
return {"instances": []}
|
||||
instances = []
|
||||
with AdbTunnel(ports) as t:
|
||||
for p in ports:
|
||||
item = {"port": p, "container": containers.get(p)}
|
||||
try:
|
||||
item.update(parse_display(t.shell(p, DISPLAY_READ)))
|
||||
except Failure as e:
|
||||
item["error"] = str(e)
|
||||
item["labels"] = {pkg: labels[pkg] for pkg in item.get("packages", []) if pkg in labels}
|
||||
instances.append(item)
|
||||
return {"instances": instances}
|
||||
|
||||
|
||||
def android_display(body):
|
||||
port = body.get("port")
|
||||
if type(port) is not int or port not in ADB_PORTS:
|
||||
raise Failure(f"port must be an integer {ADB_PORTS.start}-{ADB_PORTS.stop - 1}", 400)
|
||||
cmds = []
|
||||
|
||||
size = body.get("size")
|
||||
if size is not None:
|
||||
if size == "reset":
|
||||
cmds.append("wm size reset")
|
||||
else:
|
||||
m = SIZE_RE.fullmatch(size) if isinstance(size, str) else None
|
||||
if not m:
|
||||
raise Failure("size must be WIDTHxHEIGHT (e.g. 2560x1440) or \"reset\"", 400)
|
||||
w, h = int(m[1]), int(m[2])
|
||||
if not (WIDTH_RANGE[0] <= w <= WIDTH_RANGE[1] and HEIGHT_RANGE[0] <= h <= HEIGHT_RANGE[1]):
|
||||
raise Failure(f"size must be {WIDTH_RANGE[0]}-{WIDTH_RANGE[1]} wide and "
|
||||
f"{HEIGHT_RANGE[0]}-{HEIGHT_RANGE[1]} high", 400)
|
||||
cmds.append(f"wm size {w}x{h}")
|
||||
|
||||
density = body.get("density")
|
||||
if density is not None:
|
||||
if density == "reset":
|
||||
cmds.append("wm density reset")
|
||||
elif type(density) is int and DENSITY_RANGE[0] <= density <= DENSITY_RANGE[1]:
|
||||
cmds.append(f"wm density {density}")
|
||||
else:
|
||||
raise Failure(f"density must be an integer {DENSITY_RANGE[0]}-{DENSITY_RANGE[1]} or \"reset\"", 400)
|
||||
|
||||
font = body.get("fontScale")
|
||||
if font is not None:
|
||||
if font == "reset":
|
||||
# Applying a config change (e.g. the wm resets just before) writes
|
||||
# font_scale=1.0 back asynchronously, so delete again once it settles.
|
||||
cmds.append("settings delete system font_scale; sleep 1; settings delete system font_scale")
|
||||
elif type(font) in (int, float) and FONT_RANGE[0] <= font <= FONT_RANGE[1]:
|
||||
cmds.append(f"settings put system font_scale {round(float(font), 3):g}")
|
||||
else:
|
||||
raise Failure(f"fontScale must be a number {FONT_RANGE[0]}-{FONT_RANGE[1]} or \"reset\"", 400)
|
||||
|
||||
if not cmds:
|
||||
raise Failure("nothing to change: give density, size or fontScale", 400)
|
||||
ports, _, _ = lepton_ports()
|
||||
if port not in ports:
|
||||
raise Failure(f"no Lepton instance is listening on Frame port {port}", 404)
|
||||
with AdbTunnel([port]) as t:
|
||||
for c in cmds:
|
||||
out = t.shell(port, c)
|
||||
# wm prints usage or an exception on failure but may still exit 0.
|
||||
if re.search(r"exception|error|usage", out, re.I):
|
||||
raise Failure(f"{c}: {out}")
|
||||
now = parse_display(t.shell(port, DISPLAY_READ))
|
||||
now["port"] = port
|
||||
return {"message": f"Port {port}: " + "; ".join(c.split(";")[0] for c in cmds), "display": now}
|
||||
|
||||
|
||||
POST = {"/api/android/display": android_display, "/api/android": android,"/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing}
|
||||
|
||||
|
||||
# ---- HTTP ------------------------------------------------------------------
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "FrameControl/1"
|
||||
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("%s %s\n" % (self.command, fmt % args))
|
||||
|
||||
def local_request(self):
|
||||
# Blocks DNS rebinding (Host) and cross-site form posts (custom header
|
||||
# forces a CORS preflight, which this server never approves).
|
||||
host = (self.headers.get("Host") or "").rsplit(":", 1)[0]
|
||||
if host not in ("127.0.0.1", "localhost"):
|
||||
self.send_json({"error": "forbidden host"}, 403)
|
||||
return False
|
||||
# All of /api/*, not just POST: an <img> on any website could otherwise
|
||||
# trigger a headset capture and display it.
|
||||
api = urlparse(self.path).path.startswith("/api/")
|
||||
if (self.command == "POST" or api) and self.headers.get("X-Frame-UI") != "1":
|
||||
self.send_json({"error": "missing X-Frame-UI header"}, 403)
|
||||
return False
|
||||
return True
|
||||
|
||||
def send_bytes(self, data, ctype, status=200, headers=()):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ctype)
|
||||
for name, value in headers:
|
||||
self.send_header(name, value)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
# Nobody may frame the UI (clickjacking).
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def send_json(self, obj, status=200):
|
||||
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
||||
|
||||
def do_GET(self):
|
||||
if not self.local_request():
|
||||
return
|
||||
url = urlparse(self.path)
|
||||
path = url.path
|
||||
try:
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/api/android":
|
||||
ensure_master()
|
||||
self.send_json({"apps": frame_android.list_apps()})
|
||||
elif path == "/api/android/displays":
|
||||
self.send_json(android_displays())
|
||||
elif path == "/api/android/reports":
|
||||
self.send_json({"reports": frame_catalog.recent_reports()})
|
||||
elif path == "/api/android/catalog":
|
||||
self.send_json({"apps": frame_catalog.catalog()})
|
||||
elif path == "/api/status":
|
||||
self.send_json(status({}))
|
||||
elif path == "/api/steam/owned":
|
||||
self.send_json(steam_frame("owned"))
|
||||
elif path == "/api/steam/search":
|
||||
self.send_json(steam_search(url.query))
|
||||
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
|
||||
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
|
||||
elif path == "/api/screenshot":
|
||||
self.send_bytes(ssh(SCREENSHOT, timeout=20, text=False), "image/png",
|
||||
headers=[("X-Capture-Source", "gamescope")])
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
except Failure as e:
|
||||
self.send_json({"error": str(e)}, e.status)
|
||||
except Exception as e:
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
def do_POST(self):
|
||||
if not self.local_request():
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
try:
|
||||
if path == "/api/upload":
|
||||
self.send_json(self.upload())
|
||||
return
|
||||
handler = POST.get(path)
|
||||
if not handler:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
return
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
if not 0 <= length <= MAX_JSON:
|
||||
raise Failure("request body too large", 413)
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
if not isinstance(body, dict):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
self.send_json(handler(body))
|
||||
except Failure as e:
|
||||
self.send_json({"error": str(e)}, e.status)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.send_json({"error": f"bad request: {e}"}, 400)
|
||||
except Exception as e:
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
def upload(self):
|
||||
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only)."""
|
||||
name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
|
||||
mode = self.headers.get("X-Mode", "push")
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
if not name or name.startswith("."):
|
||||
raise Failure("missing filename", 400)
|
||||
if length <= 0 or length > MAX_UPLOAD:
|
||||
raise Failure("empty or too-large upload", 400)
|
||||
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
|
||||
raise Failure("APK install needs a .apk file", 400)
|
||||
tmp = Path(tempfile.mkdtemp(prefix="frame-ui-"))
|
||||
try:
|
||||
dest = tmp / name
|
||||
with open(dest, "wb") as f:
|
||||
remaining = length
|
||||
while remaining:
|
||||
chunk = self.rfile.read(min(remaining, 1 << 20))
|
||||
if not chunk:
|
||||
raise Failure("upload interrupted", 400)
|
||||
f.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
if mode == "apkinfo":
|
||||
# Read an APK for a report without installing it.
|
||||
try:
|
||||
info = frame_android.apk_info(str(dest))
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e), 400)
|
||||
info.pop("icon_png", None)
|
||||
try:
|
||||
frame_android.check_installable(info)
|
||||
info["blocker"] = None
|
||||
except frame_android.FrameError as e:
|
||||
info["blocker"] = str(e)
|
||||
return {"message": f"Read {info['label']} {info['version']}", "apk": info}
|
||||
if mode == "apk":
|
||||
ensure_master()
|
||||
try:
|
||||
m = frame_android.install(str(dest), source=name)
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e), 400)
|
||||
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
|
||||
return {"message": script("push.sh", str(dest))}
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
||||
args = ap.parse_args()
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
# The master was started with -N, so it stays up until told to exit.
|
||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
|
||||
if _master and _master.poll() is None:
|
||||
_master.terminate()
|
||||
for proc in list(_live_tunnels): # ADB forwards of requests cut off mid-way
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user