Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b32fdcdc8 | ||
|
|
503f1e202f | ||
|
|
2740899ef5 | ||
|
|
355a80cdd8 | ||
|
|
c72eb33095 |
No files matched your search
@@ -1,47 +0,0 @@
|
||||
---
|
||||
name: steam-frame
|
||||
description: Operate the user's Valve Steam Frame headset from the Mac through this repo's helpers and field notes. Use for Steam Frame SSH, screen streaming, clipboard, file push, APK or Flatpak installs, launching apps on the headset, arranging floating windows or panels in VR space, or debugging SteamOS/gamescope/SteamVR on the Frame.
|
||||
---
|
||||
|
||||
# Steam Frame
|
||||
|
||||
SSH works through the `frame` alias
|
||||
(user `steamos`). The headset has to be awake for anything that touches its
|
||||
desktop or panels.
|
||||
|
||||
## Start here
|
||||
|
||||
1. Read `docs/how-the-frame-works.md`. It's the map: the layer cake (SteamVR →
|
||||
gamescope → nested Plasma), the verified facts, and debug recipes.
|
||||
2. Open the topic doc for the task:
|
||||
|
||||
| Task | Doc | Script |
|
||||
|---|---|---|
|
||||
| Floating windows in the room, one panel per app | `docs/panels.md` | `scripts/panel-on-frame.sh` |
|
||||
| First-time access, SSH keys | `docs/ssh.md` | `scripts/connect.sh` |
|
||||
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
|
||||
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
|
||||
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
|
||||
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
|
||||
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||
| Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` |
|
||||
| Mac GUI over all of this | `README.md` → Frame Control | `scripts/frame-ui.sh` |
|
||||
| What's still unverified | `docs/open-questions.md` | — |
|
||||
|
||||
Each script's usage is in its header comment. Read the header rather than
|
||||
running `--help`: `paste-to-frame.sh`, `serve-bootstrap.sh` and
|
||||
`bootstrap-on-frame.sh` act on any argument.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- Label every claim **verified** (seen on the device, with the date and
|
||||
SteamOS build) or **inferred**. The docs use this convention. Keep it, and
|
||||
move items out of `docs/open-questions.md` once they're checked.
|
||||
- When you learn something new about the Frame, record it in
|
||||
`docs/how-the-frame-works.md` (or the topic doc) in the same change.
|
||||
- The Frame's rootfs is read-only and SteamOS updates replace it. Put changes in
|
||||
`~` (`--user` Flatpaks, `~/.config`) rather than `steamos-readonly disable`.
|
||||
- `sudo` on the Frame asks for the user's Developer Mode password. Hand those
|
||||
steps to the user (open Terminal) and keep automation to non-sudo commands.
|
||||
- The Mac uses BSD userland and zsh (no `timeout`, use `head -n`).
|
||||
@@ -1,8 +0,0 @@
|
||||
# Scripts run on the Frame (Linux) and on macOS/Linux: keep LF even in Windows checkouts.
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.js text eol=lf
|
||||
*.html text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.bat text eol=crlf
|
||||
@@ -1,56 +0,0 @@
|
||||
name: checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.9" # the oldest python3 the Mac app may pick up (Xcode CLT)
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
- name: Script syntax
|
||||
run: |
|
||||
for f in scripts/*.sh frame/*/*.sh; do
|
||||
case "$(head -n 1 "$f")" in
|
||||
*zsh*) zsh -n "$f" ;;
|
||||
*) bash -n "$f" ;;
|
||||
esac
|
||||
done
|
||||
- name: Python compiles
|
||||
run: python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-python.js
|
||||
|
||||
# The server runs on each desktop OS the app ships for. Windows uses the same
|
||||
# Python version the app bundles (app/build/fetch-python.js).
|
||||
server-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
python: "3.12"
|
||||
- os: macos-latest
|
||||
python: "3.12"
|
||||
- os: ubuntu-latest
|
||||
python: "3.13"
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
@@ -1,61 +0,0 @@
|
||||
name: release
|
||||
|
||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
||||
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||
# Pull requests that touch the app build the same installers as artifacts.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
script: dist
|
||||
files: app/dist/*.dmg app/dist/*.zip
|
||||
- os: windows-latest
|
||||
script: dist:win
|
||||
files: app/dist/*.exe app/dist/*.zip
|
||||
- os: ubuntu-latest
|
||||
script: dist:linux
|
||||
files: app/dist/*.AppImage app/dist/*.deb
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Build
|
||||
working-directory: app
|
||||
shell: bash
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
- name: Upload to the release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
|
||||
gh release upload "$tag" ${{ matrix.files }} --clobber
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frame-control-${{ matrix.os }}
|
||||
path: |
|
||||
app/dist/*.dmg
|
||||
app/dist/*.exe
|
||||
app/dist/*.zip
|
||||
app/dist/*.AppImage
|
||||
app/dist/*.deb
|
||||
if-no-files-found: ignore
|
||||
@@ -1,6 +0,0 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
apk-catalog/data/cache/
|
||||
apk-catalog/data/index-v2.json*
|
||||
compat-db/.env.lakebed.server
|
||||
compat-db/.lakebed/
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 saphid
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,219 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="docs/img/icon.png" width="112" alt="Frame Control icon">
|
||||
|
||||
# Frame Control
|
||||
|
||||
**Manage your Valve Steam Frame from your computer.**<br>
|
||||
See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
|
||||
|
||||
[](https://github.com/saphid/steam-frame/releases/latest)
|
||||
[](#install)
|
||||
[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[**Download**](#install) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
|
||||
|
||||
<br>
|
||||
|
||||
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900">
|
||||
|
||||
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**👓 Headset view**<br>
|
||||
Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**🔋 Battery and status**<br>
|
||||
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**🎮 Steam games**<br>
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
**🤖 Android apps**<br>
|
||||
About 4,500 F-Droid apps rated for the Frame. One click installs each as its own app in your Steam library.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**📁 Files and clipboard**<br>
|
||||
Drag files onto the window to send them. Send text or your clipboard straight to the headset's desktop.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
**📸 Screenshots**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**🧩 Flatpaks and display**<br>
|
||||
Install desktop apps like Moonlight or VLC, and set each Android app's resolution and text size.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
**⚡ One-click tools**<br>
|
||||
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Nothing is installed on the Frame for any of this: the app uses what SteamOS
|
||||
already ships. [How each feature works](docs/frame-control.md).
|
||||
|
||||
## Install
|
||||
|
||||
| | Download | Needs |
|
||||
|---|---|---|
|
||||
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Python 3 (`xcode-select --install`) |
|
||||
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra: Python is bundled, and SSH is built into Windows |
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `python3` and `ssh` (most desktops have both) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | same |
|
||||
|
||||
Optional: `adb` for Android apps
|
||||
([macOS](https://formulae.brew.sh/formula/android-platform-tools) `brew install android-platform-tools` ·
|
||||
Windows `winget install Google.PlatformTools` · Linux `sudo apt install adb`).
|
||||
|
||||
<details>
|
||||
<summary><b>macOS: the app isn't notarized</b></summary>
|
||||
|
||||
There's no paid Apple developer account behind it, so macOS says the app is
|
||||
damaged or can't be checked. Drag it to Applications, then clear the download
|
||||
quarantine once:
|
||||
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||
```
|
||||
|
||||
The first time, macOS also asks to allow local network access (for SSH) and
|
||||
control of Terminal (for the password prompts).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Windows: SmartScreen warning</b></summary>
|
||||
|
||||
The installer isn't code-signed, so Windows SmartScreen may say it protected
|
||||
your PC. Choose **More info → Run anyway**. The portable `.zip` avoids the
|
||||
installer: unzip it anywhere and run `Frame Control.exe`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux: running the AppImage</b></summary>
|
||||
|
||||
```sh
|
||||
chmod +x Frame-Control-linux-*.AppImage && ./Frame-Control-linux-*.AppImage
|
||||
```
|
||||
|
||||
If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
|
||||
or run it with `--appimage-extract-and-run`. Sending the clipboard needs
|
||||
`wl-clipboard` (Wayland) or `xclip` (X11).
|
||||
</details>
|
||||
|
||||
## Set up the headset
|
||||
|
||||
You type one password on the headset, once. Everything else happens on your
|
||||
computer.
|
||||
|
||||
1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
|
||||
in the Developer section, **Set User Password**. Pick something short:
|
||||
you'll type it once more on your computer and then never again.
|
||||
2. **On your computer:** open Frame Control. It offers to **Set Up
|
||||
Connection**, which finds the headset, creates an SSH key, and asks for that
|
||||
password once in a terminal window. If it can't find the Frame, type the
|
||||
IP address from the Frame's Quick Settings.
|
||||
3. That's it. The app now reaches the headset whenever it's awake and on the
|
||||
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
|
||||
|
||||
**What it changes:** only what you click. Installs go to your user account on
|
||||
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads), and nothing
|
||||
needs `sudo` except the power buttons. On your computer it adds a `Host frame`
|
||||
entry to `~/.ssh/config` and a key at `~/.ssh/id_ed25519_frame`.
|
||||
|
||||
## Feedback
|
||||
|
||||
This is a first public test, so reports are really useful, especially from
|
||||
Windows and Linux. Please [open an issue](https://github.com/saphid/steam-frame/issues/new)
|
||||
with:
|
||||
|
||||
- what you tried and what happened
|
||||
- your computer's OS and your SteamOS build (Steam Settings → System)
|
||||
- the server log: **Frame → Show Server Log** in the app
|
||||
|
||||
## Going further
|
||||
|
||||
This repo also holds the scripts behind the app and field notes on how the
|
||||
Frame's software fits together, all checked against a real headset and labelled
|
||||
**verified** or **inferred**.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
|
||||
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
|
||||
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||
|
||||
<details>
|
||||
<summary><b>Security notes</b></summary>
|
||||
|
||||
- With Developer Mode on, `sshd`, ADB and xrdp are all reachable on your LAN.
|
||||
Each running Lepton (Android) instance opens its own ADB port in 5555–5599,
|
||||
listening on `0.0.0.0` rather than only loopback. This was seen on the
|
||||
device on 2026-09-25, so anyone on the network can reach it. Use trusted
|
||||
networks only, and turn Developer Mode off when you don't need it.
|
||||
- Frame Control reaches ADB and the Steam client's DevTools port (Frame
|
||||
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
|
||||
database key (maintainer-only) is never written to the repo.
|
||||
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
|
||||
you've switched to key auth, a short password still protects `sudo` and
|
||||
RDP, so pick one that isn't trivially guessable.
|
||||
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access,
|
||||
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
|
||||
**every** Frame port is reachable from your tailnet, including Steam's DevTools
|
||||
on loopback 8080; see [docs/tailscale.md](docs/tailscale.md).
|
||||
</details>
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests # server tests; no headset needed
|
||||
cd app && npm install && npm start # run the app from the checkout
|
||||
```
|
||||
|
||||
The server is Python stdlib only; the app is Electron. GitHub Actions runs the
|
||||
tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
|
||||
into the release. See [building](docs/frame-control.md#building).
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE). Steam, Steam Frame and SteamVR are trademarks of Valve
|
||||
Corporation. This project isn't affiliated with or endorsed by Valve.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Android app catalogue and compatibility reports
|
||||
|
||||
The data behind Frame Control's **Android apps** section: every app in the
|
||||
F-Droid main repo, rated for Lepton (the Frame's Android container), plus our
|
||||
own compatibility reports. No ProtonDB-style database for sideloaded Android
|
||||
apps on the Frame existed as of 2026-09-25 (Steam Frame Hub and Valve's
|
||||
"Great on Frame" cover Steam games only), so we keep our own.
|
||||
|
||||
```sh
|
||||
scripts/frame-ui.sh # Frame Control → Android apps: search, Install, Test, Report
|
||||
scripts/apk-catalog.sh # refresh the F-Droid data (only scans what changed)
|
||||
```
|
||||
|
||||
## Verdicts
|
||||
|
||||
| Verdict | Meaning |
|
||||
|---|---|
|
||||
| Works on Frame | The latest report says it runs (automated Test or a person's rating) |
|
||||
| Should work | No known blocker found in the APK |
|
||||
| Might work | Something uncertain: Compose version unknown, Godot, Qt, Play Services, no launcher icon (widgets, tiles, keyboards), or a report of issues |
|
||||
| Probably crashes | Compose UI < 1.11, SDL or Kivy |
|
||||
| Won't work | Needs Android 12+ or has no 64-bit ARM build, or a report says it's broken |
|
||||
|
||||
"Should work" means the app opens. Features that need something Lepton lacks
|
||||
(browser links, file picker, Play Services, camera app) can still fail. The
|
||||
rules and the evidence behind them are in [docs/apks.md](../docs/apks.md).
|
||||
|
||||
## Compatibility reports
|
||||
|
||||
Reports are saved on your Mac. The maintainer's copy of Frame Control also
|
||||
syncs them to a private Lakebed database (see
|
||||
[compat-db/README.md](../compat-db/README.md), including backups). **Test**
|
||||
records whether an app stays up in its own instance (`result`); **Report**
|
||||
(on any installed app, catalogue card, or **+ Report an APK** for anything else, e.g. an
|
||||
APK file or your own build) records `works`, `issues` or `broken`, how it was run
|
||||
(own instance, Lepton Development, other), where the APK came from, and notes. Each report carries
|
||||
the SteamOS `BUILD_ID` and the Lepton build id. Newest wins, and a person's
|
||||
rating beats an automated result (`reports.py`).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `zipcd.py` | Reads an APK's zip directory and single entries with HTTP range requests |
|
||||
| `scan.py` | Per app: native ABIs, frameworks (from `lib/*.so`), Compose/GMS/Firebase resource names from `resources.arsc`. Writes `data/scan.jsonl` |
|
||||
| `scan2.py` | Per app: Compose UI version, launcher/IME/feature strings from `AndroidManifest.xml`. Writes `data/scan2.jsonl` |
|
||||
| `pick.py` | Which version to rate and install: newest with an arm64 build (or no native code) and minSdk ≤ 30 |
|
||||
| `pins.json` | Versions pinned by hand (F-Droid 1.17.2) |
|
||||
| `reports.py` | How reports override predictions (the reports are in compat-db) |
|
||||
| `build.py` | Applies the rules and writes `site/apps.js` (predictions; Frame Control adds reports at runtime) |
|
||||
|
||||
Frame Control's `ui/frame_catalog.py` loads `site/apps.js`, applies the
|
||||
reports from `ui/frame_compat_db.py`, downloads APKs (SHA-256 checked against the
|
||||
F-Droid index), and installs them with `ui/frame_android.py`.
|
||||
|
||||
Both scans skip apps whose version hasn't changed. Compose is detected by its
|
||||
resource ids (`compose_view_saveable_id_tag`) because many apps strip the
|
||||
`META-INF` version files; those apps are rated "Might work".
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Merge the F-Droid index, both APK scans and the on-device results into
|
||||
site/apps.js, applying the Lepton compatibility rules in docs/apks.md.
|
||||
|
||||
Usage: python3 build.py (run from apk-catalog/, after scan.py and scan2.py)
|
||||
"""
|
||||
import json, os, re, time
|
||||
from pick import pick_version, LEPTON_SDK
|
||||
import reports
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA = os.path.join(HERE, 'data')
|
||||
REPO = 'https://f-droid.org/repo'
|
||||
# Compose UI below this crashes on any Compose screen: it casts the missing
|
||||
# clipboard service to non-null while building AndroidComposeView.
|
||||
COMPOSE_OK = (1, 11)
|
||||
RANK = {'works': 0, 'likely': 1, 'maybe': 2, 'unlikely': 3, 'no': 4}
|
||||
|
||||
|
||||
def jsonl(path):
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
return {r['pkg']: r for r in map(json.loads, open(path))}
|
||||
|
||||
|
||||
def loc(d):
|
||||
if not isinstance(d, dict):
|
||||
return d or ''
|
||||
return d.get('en-US') or d.get('en') or next(iter(d.values()), '')
|
||||
|
||||
|
||||
def ver_tuple(v):
|
||||
m = re.match(r'(\d+)\.(\d+)', v or '')
|
||||
return (int(m[1]), int(m[2])) if m else None
|
||||
|
||||
|
||||
def classify(m, s, s2):
|
||||
"""Return (verdict, reasons). Hard failures first, then crash signals."""
|
||||
no, bad, maybe, notes = [], [], [], []
|
||||
min_sdk = m.get('usesSdk', {}).get('minSdkVersion', 1)
|
||||
if min_sdk > LEPTON_SDK:
|
||||
no.append(f'Needs Android API {min_sdk}; Lepton is Android 11 (API 30), so it won\'t install')
|
||||
native = m.get('nativecode') or s.get('abis') or []
|
||||
if native and 'arm64-v8a' not in native:
|
||||
no.append(f'Native code only for {", ".join(native)}; Lepton is 64-bit ARM only, so it won\'t install')
|
||||
|
||||
cv = s2.get('compose_ver')
|
||||
if cv and ver_tuple(cv) and ver_tuple(cv) < COMPOSE_OK:
|
||||
bad.append(f'Jetpack Compose {cv}: Compose screens crash (no clipboard service); 1.11+ is fine')
|
||||
elif s.get('compose') and not cv:
|
||||
maybe.append('Uses Jetpack Compose, version unknown: crashes if older than 1.11')
|
||||
elif cv:
|
||||
notes.append(f'Jetpack Compose {cv} (fine)')
|
||||
fw = set(s.get('frameworks', []))
|
||||
if 'sdl' in fw or s2.get('sdl3'):
|
||||
bad.append('SDL app: registers a clipboard listener at start-up and crashes')
|
||||
if 'kivy' in fw:
|
||||
bad.append('Kivy (SDL) app: crashes at start-up on the missing clipboard')
|
||||
if 'godot' in fw:
|
||||
maybe.append('Godot: 4.3 crashed (clipboard), 4.6 worked')
|
||||
if 'reactnative' in fw or 'hermes' in fw:
|
||||
notes.append('React Native: 2 of 3 tested apps worked')
|
||||
if 'qt' in fw or 'qt6' in fw:
|
||||
maybe.append('Qt app: the one tested crashed on a missing libc++ symbol')
|
||||
if 'flutter' in fw:
|
||||
notes.append('Flutter (tested apps worked)')
|
||||
if 'gdx' in fw:
|
||||
notes.append('libGDX (tested games worked)')
|
||||
if s.get('gms') or s2.get('gms_meta'):
|
||||
maybe.append('Uses Google Play Services, which Lepton lacks')
|
||||
if s2 and not s2.get('launcher'):
|
||||
if s2.get('ime'):
|
||||
maybe.append('Keyboard (IME), not an app you open; untested in Lepton')
|
||||
else:
|
||||
maybe.append('No launcher icon (widget, tile, wallpaper or plug-in)')
|
||||
feats = s2.get('features', [])
|
||||
if 'android.hardware.touchscreen.multitouch' in feats:
|
||||
notes.append('Mentions multi-touch; the Frame pointer is single-touch (inferred)')
|
||||
if any(f in feats for f in ('android.hardware.telephony', 'android.hardware.nfc')):
|
||||
notes.append('Mentions telephony or NFC, which Lepton lacks')
|
||||
if 'android.hardware.type.watch' in feats:
|
||||
maybe.append('Wear OS watch app')
|
||||
|
||||
if no:
|
||||
return 'no', no + bad + maybe + notes
|
||||
if bad:
|
||||
return 'unlikely', bad + maybe + notes
|
||||
if maybe:
|
||||
return 'maybe', maybe + notes
|
||||
if not s or 'error' in s:
|
||||
return 'maybe', ['APK not scanned'] + notes
|
||||
return 'likely', notes or ['No known blockers']
|
||||
|
||||
|
||||
def finalize(app, reps):
|
||||
"""Set the shown verdict ('r', 'why', 't') from the prediction plus any reports."""
|
||||
rv = reports.verdict(reps)
|
||||
if rv:
|
||||
app['r'], lines = rv
|
||||
app['why'] = lines + ['Rule check: ' + r for r in app['pw'] if not r.startswith('No known')]
|
||||
else:
|
||||
app['r'], app['why'] = app['pr'], list(app['pw'])
|
||||
app['t'] = bool(rv)
|
||||
return app
|
||||
|
||||
|
||||
def load_catalog(path=None):
|
||||
"""Read site/apps.js back into a list (for serve.py and Frame Control)."""
|
||||
src = open(path or os.path.join(HERE, 'site', 'apps.js'), encoding='utf-8').read()
|
||||
return json.loads(src.split('window.APPS=', 1)[1].rstrip().rstrip(';'))
|
||||
|
||||
|
||||
def main():
|
||||
idx = json.load(open(os.path.join(DATA, 'index-v2.json')))
|
||||
s1 = jsonl(os.path.join(DATA, 'scan.jsonl'))
|
||||
s2 = jsonl(os.path.join(DATA, 'scan2.jsonl'))
|
||||
pins = json.load(open(os.path.join(HERE, 'pins.json'))) if os.path.exists(os.path.join(HERE, 'pins.json')) else {}
|
||||
cats = idx.get('repo', {}).get('categories', {})
|
||||
apps = []
|
||||
for pkg, p in idx['packages'].items():
|
||||
if not p.get('versions'):
|
||||
continue
|
||||
v = pick_version(p)
|
||||
md, m = p['metadata'], v['manifest']
|
||||
verdict, why = classify(m, s1.get(pkg, {}), s2.get(pkg, {}))
|
||||
apk, sha, shown_ver = REPO + v['file']['name'], v['file'].get('sha256'), m.get('versionName')
|
||||
pin = pins.get(pkg)
|
||||
if pin:
|
||||
apk, sha, shown_ver = pin['apk'], pin['sha256'], pin['version']
|
||||
why = [pin['why']] + why
|
||||
icon = loc(md.get('icon'))
|
||||
apps.append(finalize({
|
||||
'p': pkg,
|
||||
'n': loc(md.get('name')) or pkg,
|
||||
's': loc(md.get('summary')),
|
||||
'c': [loc(cats.get(c, {}).get('name')) or c for c in md.get('categories', [])],
|
||||
'i': REPO + icon['name'] if isinstance(icon, dict) and icon.get('name') else '',
|
||||
'v': shown_ver,
|
||||
'z': v['file'].get('size'),
|
||||
'u': md.get('lastUpdated'),
|
||||
'a': apk,
|
||||
'h': sha,
|
||||
'af': sorted(v.get('antiFeatures', {}).keys()),
|
||||
'pr': verdict,
|
||||
'pw': why,
|
||||
}, None))
|
||||
apps.sort(key=lambda a: (RANK[a['r']], a['n'].lower()))
|
||||
out = os.path.join(HERE, 'site', 'apps.js')
|
||||
meta = {'built': time.strftime('%Y-%m-%d'), 'count': len(apps),
|
||||
'source': 'F-Droid main repo, rated on the newest version each app has that Lepton can install'}
|
||||
with open(out, 'w') as f:
|
||||
f.write('window.CATALOG_META=' + json.dumps(meta) + ';\n')
|
||||
f.write('window.APPS=' + json.dumps(apps, separators=(',', ':'), ensure_ascii=False) + ';\n')
|
||||
counts = {k: sum(a['r'] == k for a in apps) for k in RANK}
|
||||
print(out, counts)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Choose which F-Droid version of an app to rate and install.
|
||||
|
||||
F-Droid often publishes one APK per ABI under different version codes, and
|
||||
the highest code is frequently the x86_64 build. Prefer the newest version
|
||||
Lepton can install (arm64-v8a or no native code, minSdk <= 30), else the newest.
|
||||
"""
|
||||
LEPTON_SDK = 30
|
||||
|
||||
|
||||
def installable(v):
|
||||
m = v['manifest']
|
||||
native = m.get('nativecode') or []
|
||||
return (not native or 'arm64-v8a' in native) and \
|
||||
m.get('usesSdk', {}).get('minSdkVersion', 1) <= LEPTON_SDK
|
||||
|
||||
|
||||
def pick_version(p):
|
||||
vs = sorted(p['versions'].values(), key=lambda v: v['manifest'].get('versionCode', 0), reverse=True)
|
||||
return next((v for v in vs if installable(v)), vs[0])
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"org.fdroid.fdroid": {
|
||||
"apk": "https://f-droid.org/archive/org.fdroid.fdroid_1017002.apk",
|
||||
"sha256": "756b7dfc7fb43ef28c27d276428a2f7826cd482fd794bbd9eeaef24016b2081c",
|
||||
"version": "1.17.2",
|
||||
"why": "Pinned to 1.17.2: 1.23.2 crashed (old Compose). 2.0 uses Compose 1.12 but is untested. Don't let it update itself."
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"""How compatibility reports turn into a verdict. The reports themselves live
|
||||
in Frame Control's private database (ui/frame_compat_db.py, a Lakebed capsule
|
||||
in compat-db/); this module is the pure logic shared by the build and the app.
|
||||
|
||||
A report: package, version, result (runs | crashes | install_failed |
|
||||
instance_failed, from an automated test), rating (works | issues | broken, from
|
||||
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
|
||||
Newest wins, and a person's rating beats an automated result.
|
||||
"""
|
||||
|
||||
|
||||
def verdict(reports):
|
||||
"""(verdict, summary lines) for one package's reports, or None."""
|
||||
if not reports:
|
||||
return None
|
||||
rs = sorted(reports, key=lambda r: r.get('date') or '')
|
||||
people = [r for r in rs if r.get('rating')]
|
||||
best = people[-1] if people else rs[-1]
|
||||
kind = best.get('rating') or best.get('result')
|
||||
v = {'works': 'works', 'runs': 'works', 'issues': 'maybe'}.get(kind, 'no')
|
||||
n_ok = sum((r.get('rating') or r.get('result')) in ('works', 'runs') for r in rs)
|
||||
lines = [f"Reported on a Frame {(best.get('date') or '')[:10]} (v{best.get('version')}): "
|
||||
f"{kind}{' – ' + best['notes'] if best.get('notes') else ''}"]
|
||||
if len(rs) > 1:
|
||||
lines.append(f'{len(rs)} reports, {n_ok} working')
|
||||
return v, lines
|
||||
|
||||
|
||||
def by_package(reports):
|
||||
out = {}
|
||||
for r in reports:
|
||||
out.setdefault(r['package'], []).append(r)
|
||||
return out
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Scan F-Droid APKs (latest version per app) for Steam Frame / Lepton signals.
|
||||
|
||||
Reads only the zip central directory and the resources.arsc key-string pool
|
||||
through HTTP range requests. Output: one JSON line per package (resumable).
|
||||
"""
|
||||
import json, os, struct, sys, zlib, threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import zipcd
|
||||
from pick import pick_version
|
||||
|
||||
REPO = 'https://f-droid.org/repo'
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
IDX = os.path.join(HERE, 'data', 'index-v2.json')
|
||||
OUT = os.path.join(HERE, 'data', 'scan.jsonl')
|
||||
|
||||
KEYS = {
|
||||
'compose': [b'compose_view_saveable_id_tag', b'wrapped_composition_tag',
|
||||
b'androidx_compose_ui_view_compositionlocal_map'],
|
||||
'gms': [b'common_google_play_services_unknown_issue',
|
||||
b'common_google_play_services_install_title'],
|
||||
'firebase': [b'google_app_id', b'gcm_defaultSenderId'],
|
||||
}
|
||||
LIBS = {
|
||||
'unity': 'libunity.so', 'flutter': 'libflutter.so', 'reactnative': 'libreactnative',
|
||||
'hermes': 'libhermes', 'godot': 'libgodot_android.so', 'gdx': 'libgdx.so',
|
||||
'sdl': 'libSDL2.so', 'unreal': 'libUE4.so', 'unreal5': 'libUnreal.so',
|
||||
'xamarin': 'libmonodroid.so', 'qt': 'libQt5Core', 'qt6': 'libQt6Core',
|
||||
'cocos': 'libcocos', 'love': 'liblove.so', 'renpy': 'librenpy',
|
||||
'kivy': 'libpython', 'gomobile': 'libgojni.so',
|
||||
}
|
||||
|
||||
|
||||
def arsc_keys(url, entries):
|
||||
comp, csize, lho = entries['resources.arsc']
|
||||
h = zipcd.rng(url, lho, lho + 29)
|
||||
nl, el = struct.unpack('<HH', h[26:30])
|
||||
base = lho + 30 + nl + el
|
||||
if comp == 8:
|
||||
blob = zlib.decompress(zipcd.rng(url, base, base + csize - 1), -15)
|
||||
read = lambda o, n: blob[o:o + n]
|
||||
elif comp == 0:
|
||||
read = lambda o, n: zipcd.rng(url, base + o, base + o + n - 1)
|
||||
else:
|
||||
raise ValueError(f'arsc compression {comp}')
|
||||
th = read(0, 12)
|
||||
if struct.unpack('<H', th[:2])[0] != 2:
|
||||
raise ValueError('not a ResTable')
|
||||
off = struct.unpack('<H', th[2:4])[0]
|
||||
pools = []
|
||||
total = struct.unpack('<I', th[4:8])[0]
|
||||
while off < total:
|
||||
ch = read(off, 8)
|
||||
ctype, chdr, csz = struct.unpack('<HHI', ch)
|
||||
if ctype == 0x0200: # package
|
||||
ph = read(off, 288)
|
||||
key_off = struct.unpack('<I', ph[8 + 4 + 256 + 8:8 + 4 + 256 + 12])[0]
|
||||
kh = read(off + key_off, 8)
|
||||
ksz = struct.unpack('<I', kh[4:8])[0]
|
||||
pools.append(read(off + key_off, ksz))
|
||||
if csz <= 0:
|
||||
break
|
||||
off += csz
|
||||
return b''.join(pools)
|
||||
|
||||
|
||||
def scan(pkg, meta, ver):
|
||||
f = ver['file']
|
||||
url = REPO + f['name']
|
||||
m = ver['manifest']
|
||||
r = {'pkg': pkg, 'vc': m.get('versionCode'), 'vn': m.get('versionName'),
|
||||
'apk': url, 'size': f.get('size')}
|
||||
try:
|
||||
names, entries, _ = zipcd.list_names(url, f['size'])
|
||||
libs = {n for n in names if n.startswith('lib/')}
|
||||
r['frameworks'] = sorted(k for k, s in LIBS.items() if any(s in n for n in libs))
|
||||
r['abis'] = sorted({n.split('/')[1] for n in libs if n.count('/') >= 2})
|
||||
r['metainf_compose'] = any(n.startswith('META-INF/androidx.compose.ui') for n in names)
|
||||
r['assets_bin_data'] = any(n.startswith('assets/bin/Data/') for n in names)
|
||||
if 'resources.arsc' in entries:
|
||||
kp = arsc_keys(url, entries)
|
||||
for k, pats in KEYS.items():
|
||||
r[k] = any(p in kp or p.decode().encode('utf-16-le') in kp for p in pats)
|
||||
else:
|
||||
r['no_arsc'] = True
|
||||
except Exception as e: # keep going; record the failure
|
||||
r['error'] = f'{type(e).__name__}: {e}'[:200]
|
||||
return r
|
||||
|
||||
|
||||
def main():
|
||||
idx = json.load(open(IDX))
|
||||
done = set()
|
||||
if os.path.exists(OUT):
|
||||
for line in open(OUT):
|
||||
try:
|
||||
r = json.loads(line)
|
||||
done.add((r['pkg'], r.get('vc')))
|
||||
except Exception:
|
||||
pass
|
||||
jobs = []
|
||||
for pkg, p in idx['packages'].items():
|
||||
if not p.get('versions'):
|
||||
continue
|
||||
ver = pick_version(p)
|
||||
if (pkg, ver['manifest'].get('versionCode')) not in done:
|
||||
jobs.append((pkg, p['metadata'], ver))
|
||||
print(f'{len(done)} done, {len(jobs)} to scan', flush=True)
|
||||
lock = threading.Lock()
|
||||
n = 0
|
||||
with open(OUT, 'a') as out, ThreadPoolExecutor(int(os.environ.get('WORKERS', '12'))) as ex:
|
||||
futs = [ex.submit(scan, *j) for j in jobs]
|
||||
for fu in as_completed(futs):
|
||||
r = fu.result()
|
||||
with lock:
|
||||
out.write(json.dumps(r) + '\n'); out.flush()
|
||||
n += 1
|
||||
if n % 100 == 0:
|
||||
print(f'{n}/{len(jobs)}', flush=True)
|
||||
print('finished', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Second pass: Compose UI version, launcher activity, GMS meta-data, uses-feature
|
||||
strings from AndroidManifest.xml (binary XML string pool). Resumable JSONL."""
|
||||
import json, os, struct, sys, threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import zipcd
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
IN = os.path.join(HERE, 'data', 'scan.jsonl')
|
||||
OUT = os.path.join(HERE, 'data', 'scan2.jsonl')
|
||||
FEATURES = ['android.hardware.touchscreen.multitouch', 'android.hardware.telephony',
|
||||
'android.hardware.nfc', 'android.hardware.bluetooth_le', 'android.hardware.usb.host',
|
||||
'android.hardware.camera', 'android.hardware.vr.high_performance',
|
||||
'android.software.leanback', 'android.hardware.type.watch']
|
||||
|
||||
|
||||
def axml_strings(b):
|
||||
# ResXMLTree_header (8) then string pool chunk
|
||||
off = struct.unpack('<H', b[2:4])[0]
|
||||
t, hs, sz, cnt, _styles, flags, sstart, _ = struct.unpack('<HHIIIIII', b[off:off + 28])
|
||||
utf8 = flags & 0x100
|
||||
offs = struct.unpack(f'<{cnt}I', b[off + hs:off + hs + 4 * cnt])
|
||||
base = off + sstart
|
||||
out = []
|
||||
for o in offs:
|
||||
p = base + o
|
||||
if utf8:
|
||||
n = b[p]; p += 2 if n & 0x80 else 1
|
||||
n = b[p]; hi = n & 0x80
|
||||
if hi:
|
||||
n = ((n & 0x7f) << 8) | b[p + 1]; p += 2
|
||||
else:
|
||||
p += 1
|
||||
out.append(b[p:p + n].decode('utf-8', 'replace'))
|
||||
else:
|
||||
n = struct.unpack('<H', b[p:p + 2])[0]; p += 2
|
||||
if n & 0x8000:
|
||||
n = ((n & 0x7fff) << 16) | struct.unpack('<H', b[p:p + 2])[0]; p += 2
|
||||
out.append(b[p:p + 2 * n].decode('utf-16-le', 'replace'))
|
||||
return out
|
||||
|
||||
|
||||
def scan(r):
|
||||
o = {'pkg': r['pkg'], 'vc': r.get('vc')}
|
||||
try:
|
||||
names, ent, _ = zipcd.list_names(r['apk'], r['size'])
|
||||
for n in ('META-INF/androidx.compose.ui_ui.version', 'META-INF/androidx.compose.ui_ui-android.version'):
|
||||
if n in ent:
|
||||
o['compose_ver'] = zipcd.read_entry(r['apk'], ent, n).decode().strip()
|
||||
break
|
||||
o['sdl3'] = any(n.endswith('/libSDL3.so') for n in names)
|
||||
s = set(axml_strings(zipcd.read_entry(r['apk'], ent, 'AndroidManifest.xml')))
|
||||
o['launcher'] = 'android.intent.category.LAUNCHER' in s
|
||||
o['leanback_launcher'] = 'android.intent.category.LEANBACK_LAUNCHER' in s
|
||||
o['gms_meta'] = 'com.google.android.gms.version' in s
|
||||
o['ime'] = 'android.view.InputMethod' in s
|
||||
o['features'] = [f for f in FEATURES if f in s]
|
||||
except Exception as e:
|
||||
o['error2'] = f'{type(e).__name__}: {e}'[:200]
|
||||
return o
|
||||
|
||||
|
||||
def main():
|
||||
rows = list({r['pkg']: r for r in map(json.loads, open(IN))}.values()) # latest per app
|
||||
done = set()
|
||||
if os.path.exists(OUT):
|
||||
done = {(o['pkg'], o.get('vc')) for o in map(json.loads, open(OUT))}
|
||||
rows = [r for r in rows if (r['pkg'], r.get('vc')) not in done and 'error' not in r]
|
||||
print(len(done), 'done', len(rows), 'todo', flush=True)
|
||||
lock = threading.Lock(); n = 0
|
||||
with open(OUT, 'a') as out, ThreadPoolExecutor(int(os.environ.get('WORKERS', '40'))) as ex:
|
||||
for fu in as_completed([ex.submit(scan, r) for r in rows]):
|
||||
with lock:
|
||||
out.write(json.dumps(fu.result()) + '\n'); out.flush(); n += 1
|
||||
if n % 200 == 0:
|
||||
print(n, flush=True)
|
||||
print('finished', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,2 +0,0 @@
|
||||
window.CATALOG_META={"built": "2026-09-25", "count": 4455, "source": "F-Droid main repo, rated on the newest version each app has that Lepton can install"};
|
||||
window.APPS=[{"p":"com.terokarvinen.x54ask","n":"0x54ask","s":"Todo.txt manager. Offline, works with Syncthing. Fork of SimpleTask Cloudless","c":["Calendar & Agenda","Note","Task"],"i":"https://f-droid.org/repo/com.terokarvinen.x54ask/en-US/icon_FOzSYq6etfsaWRiMc7bx-8vLVKtsug1dmhT9NvxRj9w=.png","v":"1.1.2 (fork of Simpletask)","z":13037003,"u":1788427366142,"a":"https://f-droid.org/repo/com.terokarvinen.x54ask_1010200.apk","h":"f05781226bb84205caa5b5aa6a511afcb8df86de8bc4b53e33b7de34c2940e8a","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.github.ashutoshgngwr.tenbitclockwidget","n":"10-bit Clock Widget","s":"A beautiful BCD clock for your home screen","c":["Clock"],"i":"https://f-droid.org/repo/com.github.ashutoshgngwr.tenbitclockwidget/en-US/icon_TrUyJLRoXZGniCc2uQM3OnsVmlOokr_KZk0ZQaPrtjY=.png","v":"2.2-1","z":1281564,"u":1696789501000,"a":"https://f-droid.org/repo/com.github.ashutoshgngwr.tenbitclockwidget_221.apk","h":"35ff9940fd3d73acd1099f3640be6367c311ec9f6c34fa17e4748e874ecfe763","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"dev.lonami.klooni","n":"1010! Klooni","s":"A libGDX game based on 1010","c":["Puzzle Game"],"i":"https://f-droid.org/repo/icons/dev.lonami.klooni.860.png","v":"0.8.6","z":2735506,"u":1598918400000,"a":"https://f-droid.org/repo/dev.lonami.klooni_860.apk","h":"55641cdb5dba7f30c1d229cf8a34f390a8ff6b3f60cdff9b45d277919f33ce24","af":[],"pr":"likely","pw":["libGDX (tested games worked)"],"r":"likely","why":["libGDX (tested games worked)"],"t":false},{"p":"eu.quelltext.counting","n":"12345 - Learn Counting","s":"Learn counting in different languages with pictures","c":["Educational Game","Science & Education"],"i":"https://f-droid.org/repo/eu.quelltext.counting/en-US/icon_30ymRTCTMZiTzSNXPRLEOukBSubDfmp1CV_cpbGudKw=.png","v":"1.3","z":2413060,"u":1646352000000,"a":"https://f-droid.org/repo/eu.quelltext.counting_3.apk","h":"98fe65f21ff8e51918b94e80d25d99d52f5527d24a69dcd8dd9ca1a5da9b7a02","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.roufsyed.onekey","n":"1Key Password Manager","s":"Offline password manager. 2FA + notes. No account, no network, no telemetry.","c":["Password & 2FA","Security"],"i":"https://f-droid.org/repo/com.roufsyed.onekey/en-US/icon_7Oq_UnE5rGthf-UdC05ENbWiZZe00b9J8cKU2qrdVMQ=.png","v":"1.1.1","z":4738420,"u":1784362608829,"a":"https://f-droid.org/repo/com.roufsyed.onekey_3.apk","h":"690a58bb75780d9f835183ae6deb563e06db659218a4275ddd40ba15353d66ce","af":[],"pr":"likely","pw":["Jetpack Compose 1.11.2 (fine)"],"r":"likely","why":["Jetpack Compose 1.11.2 (fine)"],"t":false},{"p":"org.og8.a1tox","n":"1toX","s":"Remember numbers quick to train your brain","c":["Educational Game"],"i":"https://f-droid.org/repo/icons/org.og8.a1tox.1.png","v":"1.00","z":639637,"u":1567641600000,"a":"https://f-droid.org/repo/org.og8.a1tox_1.apk","h":"34895a84a638d53bd5ed57d134511eee9468f5461cb0e41874a1968ac256e4c8","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.dasp.worldcup2026","n":"2026 Football Fixtures Widget","s":"2026 football fixtures and widgets.","c":["Sports & Health"],"i":"","v":"0.1.0","z":33934,"u":1780506857489,"a":"https://f-droid.org/repo/com.dasp.worldcup2026_1.apk","h":"8c7b60c9cef5a6343f000f12ff0a0714bc6f3de72ced17c57f1ce4a147bc4a67","af":["NonFreeNet"],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"org.secuso.privacyfriendly2048","n":"2048 (Privacy Friendly)","s":"(SECUSO) Try to reach 2048 in this puzzle game","c":["Puzzle Game"],"i":"https://f-droid.org/repo/org.secuso.privacyfriendly2048/en-US/icon__EtkwPp725lQQYnzjkzDUiOqD2X5nnY1CiZSIYN9TVU=.png","v":"1.4.2","z":9294779,"u":1753701498000,"a":"https://f-droid.org/repo/org.secuso.privacyfriendly2048_100.apk","h":"02c799d3d582669daf2acf920093c68d2933f60aa937bb72fa2a805557233fe8","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"org.mattvchandler.a2050","n":"2050","s":"A game loosely based on 2048, but with circles instead of squares","c":["Puzzle Game"],"i":"https://f-droid.org/repo/org.mattvchandler.a2050/en-US/icon_3BMQD76YZDYHbtVP8WR8CTKi6E7pd6L82YveKdLHjR4=.png","v":"1.0.10","z":5079962,"u":1693608133000,"a":"https://f-droid.org/repo/org.mattvchandler.a2050_190010010.apk","h":"98a0e75e589c319093db56cf98bfa32d920b9436a9cbe7c30b32dcf7a4a6d284","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"nl.eventinfra.wifisetup","n":"37C3 Wifi Setup","s":"Official NOC application for connecting to the 36C3 Wi-Fi","c":["Connectivity"],"i":"","v":"0.37","z":2405866,"u":1729155289000,"a":"https://f-droid.org/repo/nl.eventinfra.wifisetup_20231222.apk","h":"aa0ca052e9e48ad7945f9aa535f5fd691018a5356a8ff95b0e5bf94662a54a10"Line truncated
|
||||
@@ -1,31 +0,0 @@
|
||||
import struct, urllib.request, zlib
|
||||
UA={'User-Agent':'steam-frame-compat-scan/1.0'}
|
||||
def rng(url, start, end=None):
|
||||
h=dict(UA); h['Range']=f'bytes={start}-' if end is None else f'bytes={start}-{end}'
|
||||
with urllib.request.urlopen(urllib.request.Request(url,headers=h),timeout=60) as r:
|
||||
return r.read()
|
||||
def tail(url, n):
|
||||
h=dict(UA); h['Range']=f'bytes=-{n}'
|
||||
with urllib.request.urlopen(urllib.request.Request(url,headers=h),timeout=60) as r:
|
||||
return r.read()
|
||||
def list_names(url, size):
|
||||
t=tail(url, min(size, 65557))
|
||||
i=t.rfind(b'PK\x05\x06')
|
||||
if i<0: raise ValueError('no EOCD')
|
||||
cd_size, cd_off = struct.unpack('<II', t[i+12:i+20])
|
||||
base=size-len(t)
|
||||
cd = t[cd_off-base:cd_off-base+cd_size] if cd_off>=base else rng(url, cd_off, cd_off+cd_size-1)
|
||||
names=[]; p=0; entries={}
|
||||
while p+46<=len(cd) and cd[p:p+4]==b'PK\x01\x02':
|
||||
comp,=struct.unpack('<H',cd[p+10:p+12])
|
||||
csize,usize=struct.unpack('<II',cd[p+20:p+28])
|
||||
nl,el,cl=struct.unpack('<HHH',cd[p+28:p+34]); lho,=struct.unpack('<I',cd[p+42:p+46])
|
||||
n=cd[p+46:p+46+nl].decode('utf-8','replace'); names.append(n); entries[n]=(comp,csize,lho)
|
||||
p+=46+nl+el+cl
|
||||
return names, entries, cd_size
|
||||
def read_entry(url, entries, name):
|
||||
comp,csize,lho=entries[name]
|
||||
h=rng(url, lho, lho+29)
|
||||
nl,el=struct.unpack('<HH',h[26:30])
|
||||
data=rng(url, lho+30+nl+el, lho+30+nl+el+csize-1)
|
||||
return zlib.decompress(data,-15) if comp==8 else data
|
||||
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/python-win/
|
||||
@@ -1,49 +0,0 @@
|
||||
// Downloads the official Windows embeddable Python into build/python-win, which
|
||||
// the Windows build bundles as resources/python (so Windows users need no Python).
|
||||
// Pinned by version and SHA-256. Run: node build/fetch-python.js
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const VERSION = "3.12.10";
|
||||
const SHA256 = "4acbed6dd1c744b0376e3b1cf57ce906f9dc9e95e68824584c8099a63025a3c3";
|
||||
const URL = `https://www.python.org/ftp/python/${VERSION}/python-${VERSION}-embed-amd64.zip`;
|
||||
const OUT = path.join(__dirname, "python-win");
|
||||
|
||||
function get(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
return resolve(get(res.headers.location));
|
||||
}
|
||||
if (res.statusCode !== 200) return reject(new Error(`${url}: HTTP ${res.statusCode}`));
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
}).on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const stamp = path.join(OUT, ".version");
|
||||
if (fs.existsSync(path.join(OUT, "python.exe")) && fs.existsSync(stamp) && fs.readFileSync(stamp, "utf8") === VERSION) {
|
||||
console.log(`Python ${VERSION} already in ${OUT}`);
|
||||
return;
|
||||
}
|
||||
const zip = await get(URL);
|
||||
const sum = crypto.createHash("sha256").update(zip).digest("hex");
|
||||
if (sum !== SHA256) throw new Error(`checksum mismatch for ${URL}: ${sum}`);
|
||||
fs.rmSync(OUT, { recursive: true, force: true });
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const file = path.join(OUT, "python.zip");
|
||||
fs.writeFileSync(file, zip);
|
||||
// bsdtar (macOS, Windows 10+) reads zip files; GNU tar doesn't, so fall back to unzip.
|
||||
try { execFileSync("tar", ["-xf", file, "-C", OUT]); }
|
||||
catch { execFileSync("unzip", ["-q", "-o", file, "-d", OUT]); }
|
||||
fs.rmSync(file);
|
||||
fs.writeFileSync(path.join(OUT, ".version"), VERSION);
|
||||
console.log(`Python ${VERSION} -> ${OUT}`);
|
||||
})().catch((e) => { console.error(e.message); process.exit(1); });
|
||||
|
Before Width: | Height: | Size: 265 KiB |
@@ -1,30 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1a9fff"/>
|
||||
<stop offset="1" stop-color="#6f42c1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="visor" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#dfe8f5"/>
|
||||
</linearGradient>
|
||||
<clipPath id="tile"><rect x="100" y="100" width="824" height="824" rx="185"/></clipPath>
|
||||
<mask id="nose">
|
||||
<rect width="1024" height="1024" fill="#fff"/>
|
||||
<ellipse cx="512" cy="690" rx="78" ry="96" fill="#000"/>
|
||||
</mask>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#0b1020" flood-opacity=".35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<g clip-path="url(#tile)">
|
||||
<rect x="100" y="100" width="824" height="824" fill="url(#bg)"/>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="222" y="350" width="580" height="320" rx="130" fill="url(#visor)" mask="url(#nose)"/>
|
||||
</g>
|
||||
<rect x="300" y="430" width="160" height="124" rx="50" fill="#13233a"/>
|
||||
<rect x="564" y="430" width="160" height="124" rx="50" fill="#13233a"/>
|
||||
<rect x="320" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
|
||||
<rect x="584" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,32 +0,0 @@
|
||||
// Renders build/icon.svg to icon.png (1024px) and icon.icns. Run: npm run icon
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { execFileSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
app.dock?.hide();
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ width: 1024, height: 1024, show: false, transparent: true, frame: false,
|
||||
useContentSize: true, webPreferences: { offscreen: true } });
|
||||
const svg = fs.readFileSync(path.join(__dirname, "icon.svg"), "utf8");
|
||||
await win.loadURL("data:text/html," + encodeURIComponent(
|
||||
`<body style="margin:0;background:transparent">${svg}</body>`));
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const png = (await win.webContents.capturePage({ x: 0, y: 0, width: 1024, height: 1024 }))
|
||||
.resize({ width: 1024, height: 1024 }).toPNG();
|
||||
fs.writeFileSync(path.join(__dirname, "icon.png"), png);
|
||||
|
||||
const set = fs.mkdtempSync(path.join(os.tmpdir(), "icon-")) + "/icon.iconset";
|
||||
fs.mkdirSync(set);
|
||||
for (const size of [16, 32, 128, 256, 512]) {
|
||||
for (const scale of [1, 2]) {
|
||||
const px = size * scale, name = `icon_${size}x${size}${scale === 2 ? "@2x" : ""}.png`;
|
||||
execFileSync("sips", ["-z", String(px), String(px), path.join(__dirname, "icon.png"),
|
||||
"--out", path.join(set, name)], { stdio: "ignore" });
|
||||
}
|
||||
}
|
||||
execFileSync("iconutil", ["-c", "icns", set, "-o", path.join(__dirname, "icon.icns")]);
|
||||
console.log("wrote build/icon.png and build/icon.icns");
|
||||
app.quit();
|
||||
});
|
||||
@@ -1,327 +0,0 @@
|
||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||
// a free loopback port and shows it in a native window. The server does all the
|
||||
// work over the `frame` SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
const IS_MAC = process.platform === "darwin";
|
||||
const IS_WIN = process.platform === "win32";
|
||||
|
||||
// Packaged: <resources>/{ui,scripts,python}. Dev: the repo checkout.
|
||||
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
|
||||
const SERVER = path.join(ROOT, "ui", "server.py");
|
||||
const SCRIPTS = path.join(ROOT, "scripts");
|
||||
const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Control")
|
||||
: path.join(app.getPath("userData"), "logs");
|
||||
const LOG = path.join(LOG_DIR, "server.log");
|
||||
const BG = "#0d1117";
|
||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||
|
||||
let server = null;
|
||||
let url = null;
|
||||
let win = null;
|
||||
let quitting = false;
|
||||
let python = null;
|
||||
|
||||
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses
|
||||
// Homebrew's python3, rsync and adb (desktop launchers on Linux can be as bare).
|
||||
// Take PATH from the login shell instead. Windows has no login shell to ask.
|
||||
// Runs asynchronously so a slow shell profile can't freeze the window.
|
||||
let cachedPath = null;
|
||||
async function loginPath() {
|
||||
if (IS_WIN) return process.env.PATH || "";
|
||||
if (cachedPath) return cachedPath;
|
||||
const shellPath = os.userInfo().shell || process.env.SHELL || (IS_MAC ? "/bin/zsh" : "/bin/sh");
|
||||
const extra = IS_MAC ? ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")] : [];
|
||||
let fromShell = "";
|
||||
try {
|
||||
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
|
||||
{ encoding: "utf8", timeout: 5000 });
|
||||
fromShell = (stdout.match(/__PATH__(.*)__PATH__/) || [])[1] || "";
|
||||
} catch {}
|
||||
const parts = [...fromShell.split(":"), ...(process.env.PATH || "").split(":"), ...extra];
|
||||
const joined = [...new Set(parts.filter(Boolean))].join(":");
|
||||
if (fromShell) cachedPath = joined; // retry next time if the shell didn't answer
|
||||
return joined;
|
||||
}
|
||||
|
||||
// The Windows build bundles Python; elsewhere use the system's python3 (3.8+).
|
||||
async function findPython(env) {
|
||||
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"];
|
||||
const candidates = [];
|
||||
if (IS_WIN) candidates.push(path.join(ROOT, "python", "python.exe"));
|
||||
for (const dir of env.PATH.split(path.delimiter)) {
|
||||
// The WindowsApps "python.exe" is a stub that opens the Microsoft Store.
|
||||
if (!dir || (IS_WIN && /\\WindowsApps\\?$/i.test(dir))) continue;
|
||||
for (const name of names) candidates.push(path.join(dir, name));
|
||||
}
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
// /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed.
|
||||
await run(p, ["-c", "import http.server, sys; assert sys.version_info >= (3, 8)"],
|
||||
{ timeout: 10000, env, windowsHide: true });
|
||||
return p;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function hasSsh(env) {
|
||||
try { await run("ssh", ["-V"], { timeout: 5000, env, windowsHide: true }); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
const PYTHON_HELP = IS_MAC
|
||||
? "Install the Xcode Command Line Tools (xcode-select --install) or Homebrew's python, then reopen the app."
|
||||
: IS_WIN ? "The bundled Python is missing; reinstall Frame Control."
|
||||
: "Install Python 3.8 or later from your distribution (e.g. sudo apt install python3), then reopen the app.";
|
||||
const SSH_HELP = IS_WIN
|
||||
? "Turn on Windows' OpenSSH client: Settings → System → Optional features → Add a feature → OpenSSH Client."
|
||||
: "Install the OpenSSH client (e.g. sudo apt install openssh-client).";
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = net.createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); });
|
||||
});
|
||||
}
|
||||
|
||||
// Ready once the port answers with our Server header.
|
||||
function ping(target) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(target, { timeout: 1000 }, (res) => {
|
||||
res.resume();
|
||||
resolve(/^FrameControl/.test(res.headers.server || ""));
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.on("timeout", () => { req.destroy(); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1",
|
||||
PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", FRAME_CONTROL_APP: "1" };
|
||||
python = await findPython(env);
|
||||
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
|
||||
if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`);
|
||||
const port = await freePort();
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
const log = fs.openSync(LOG, "a");
|
||||
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
|
||||
// stdin stays open while the app runs; the server exits cleanly when it closes.
|
||||
// -X utf8: the bundled Windows Python ignores PYTHON* variables (isolated mode).
|
||||
const child = spawn(python, ["-X", "utf8", SERVER, "--port", String(port), "--exit-on-eof"],
|
||||
{ env, stdio: ["pipe", log, log], windowsHide: true });
|
||||
child.stdin.on("error", () => {});
|
||||
fs.closeSync(log);
|
||||
server = child;
|
||||
let exited = null;
|
||||
child.once("error", (err) => {
|
||||
exited = err.message;
|
||||
if (server === child) { server = null; if (!quitting && url) serverDied(err.message); }
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
exited = signal || code;
|
||||
if (server !== child) return; // replaced by Restart Server
|
||||
server = null;
|
||||
if (!quitting && url) serverDied(exited);
|
||||
});
|
||||
|
||||
const target = `http://127.0.0.1:${port}/`;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
||||
if (await ping(target)) { url = target; return; }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (server === child) server = null;
|
||||
endServer(child);
|
||||
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
|
||||
}
|
||||
|
||||
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
||||
// way on Windows); SIGTERM does the same elsewhere.
|
||||
function endServer(child) {
|
||||
try { child.stdin.end(); } catch {}
|
||||
if (!IS_WIN) child.kill("SIGTERM");
|
||||
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
|
||||
}
|
||||
|
||||
function stopServer() {
|
||||
if (server) endServer(server);
|
||||
}
|
||||
|
||||
function errorPage(message) {
|
||||
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
|
||||
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
|
||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
|
||||
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
|
||||
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
|
||||
}
|
||||
|
||||
function serverDied(why) {
|
||||
url = null;
|
||||
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
const old = server;
|
||||
server = null;
|
||||
url = null;
|
||||
if (old) endServer(old);
|
||||
await load();
|
||||
}
|
||||
|
||||
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
||||
const CHROME_CSS = IS_MAC && `
|
||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
||||
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
||||
`;
|
||||
|
||||
// Restart Server can start a new load while an older one is still waiting for
|
||||
// its server; only the newest load may touch the window.
|
||||
let loadGen = 0;
|
||||
async function load() {
|
||||
const gen = ++loadGen;
|
||||
try {
|
||||
if (!url) await startServer();
|
||||
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
||||
} catch (e) {
|
||||
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
// `ssh -G` prints the effective config. An alias nobody configured keeps its
|
||||
// own name as HostName; connect.sh always writes a HostName.
|
||||
async function aliasConfigured(env) {
|
||||
try {
|
||||
const { stdout } = await run("ssh", ["-G", FRAME], { encoding: "utf8", timeout: 5000, env });
|
||||
return (stdout.match(/^hostname (.*)$/m) || [])[1] !== FRAME;
|
||||
} catch {
|
||||
return true; // can't tell; don't nag
|
||||
}
|
||||
}
|
||||
|
||||
let setupOffered = false;
|
||||
async function firstRunCheck() {
|
||||
if (setupOffered || !url) return; // not on the error page, and once per launch
|
||||
if (await aliasConfigured({ ...process.env, PATH: await loginPath() })) return;
|
||||
if (!win || setupOffered) return;
|
||||
setupOffered = true;
|
||||
const { response } = await dialog.showMessageBox(win, {
|
||||
type: "info",
|
||||
message: "Connect to your Steam Frame",
|
||||
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → `
|
||||
+ "Enable Developer Mode, then Developer → Set User Password. Then run the setup: it finds the "
|
||||
+ "headset, creates a key, and asks for that password once in a terminal window.",
|
||||
buttons: ["Set Up Connection…", "Later"],
|
||||
defaultId: 0, cancelId: 1,
|
||||
});
|
||||
if (response === 0) setUpConnection();
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||
title: "Frame Control", backgroundColor: BG, show: false,
|
||||
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
|
||||
: { icon: path.join(__dirname, "build", "icon.png") }),
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||
});
|
||||
win.once("ready-to-show", () => win.show());
|
||||
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
|
||||
// External links open in the default browser; the app never navigates away.
|
||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||
if (/^https?:\/\//.test(target)) shell.openExternal(target);
|
||||
return { action: "deny" };
|
||||
});
|
||||
win.webContents.on("will-navigate", (e, target) => {
|
||||
if (!url || new URL(target).origin !== new URL(url).origin) e.preventDefault();
|
||||
});
|
||||
win.on("closed", () => { win = null; });
|
||||
load();
|
||||
}
|
||||
|
||||
// Opens a terminal window (Terminal, a Linux terminal emulator or a console) via
|
||||
// ui/frame_host.py, which the server uses too: setup and power actions ask for the
|
||||
// Developer Mode password there.
|
||||
async function runInTerminal(argv) {
|
||||
try {
|
||||
const env = { ...process.env, PATH: await loginPath() };
|
||||
const py = python || await findPython(env);
|
||||
if (!py) throw new Error(`Python 3.8 or later is needed. ${PYTHON_HELP}`);
|
||||
await run(py, [path.join(ROOT, "ui", "frame_host.py"), "terminal", "--", ...argv],
|
||||
{ env, timeout: 15000, windowsHide: true });
|
||||
} catch (err) {
|
||||
dialog.showErrorBox("Couldn't open a terminal", String((err.stderr || err.message || err)).trim());
|
||||
}
|
||||
}
|
||||
|
||||
async function setUpConnection() {
|
||||
const alias = `FRAME_ALIAS=${FRAME}`;
|
||||
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
||||
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
||||
const setup = [py || "python3", path.join(ROOT, "ui", "frame_connect.py")];
|
||||
// A new console inherits our environment on Windows; Linux terminals may not.
|
||||
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
|
||||
}
|
||||
|
||||
function buildMenu() {
|
||||
const template = [
|
||||
...(IS_MAC ? [{ role: "appMenu" }] : []),
|
||||
{ role: "fileMenu" },
|
||||
{ role: "editMenu" },
|
||||
{
|
||||
label: "Frame",
|
||||
submenu: [
|
||||
{ label: "Set Up Connection…", click: setUpConnection },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
|
||||
{ type: "separator" },
|
||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||
{ label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) },
|
||||
...(IS_WIN ? [] : [{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" },
|
||||
{ type: "separator" }, { role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
...(IS_MAC ? [{ role: "windowMenu" }] : []),
|
||||
{
|
||||
role: "help",
|
||||
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
|
||||
},
|
||||
];
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit();
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.3.0",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||
"dist": "electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "electron-builder --mac --arm64 --dir",
|
||||
"dist:linux": "electron-builder --linux --x64 --arm64 --publish never",
|
||||
"dist:win": "node build/fetch-python.js && electron-builder --win --x64 --publish never"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
"electron-builder": "^26.15.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.saphid.frame-control",
|
||||
"productName": "Frame Control",
|
||||
"directories": {
|
||||
"output": "dist",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../ui",
|
||||
"to": "ui",
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../scripts",
|
||||
"to": "scripts",
|
||||
"filter": [
|
||||
"*.sh"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../frame/android",
|
||||
"to": "frame/android",
|
||||
"filter": [
|
||||
"*.sh",
|
||||
"*.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../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."
|
||||
},
|
||||
"artifactName": "Frame-Control-mac-${arch}.${ext}"
|
||||
},
|
||||
"dmg": {
|
||||
"title": "Frame Control ${version}"
|
||||
},
|
||||
"electronFuses": {
|
||||
"runAsNode": false,
|
||||
"enableNodeOptionsEnvironmentVariable": false,
|
||||
"enableNodeCliInspectArguments": false
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"category": "Utility",
|
||||
"icon": "build/icon.png",
|
||||
"executableName": "frame-control",
|
||||
"synopsis": "Manage a Valve Steam Frame over SSH",
|
||||
"artifactName": "Frame-Control-linux-${arch}.${ext}",
|
||||
"desktop": {
|
||||
"entry": {
|
||||
"StartupWMClass": "frame-control"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deb": {
|
||||
"depends": [
|
||||
"python3",
|
||||
"openssh-client"
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"zip"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/python-win",
|
||||
"to": "python",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"artifactName": "Frame-Control-Setup-${arch}.${ext}"
|
||||
}
|
||||
},
|
||||
"homepage": "https://github.com/saphid/steam-frame",
|
||||
"author": {
|
||||
"name": "saphid",
|
||||
"email": "4596216+saphid@users.noreply.github.com"
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
.lakebed/
|
||||
.env.lakebed.server
|
||||
@@ -1,88 +0,0 @@
|
||||
# Lakebed app instructions
|
||||
|
||||
Treat this capsule directory as the whole app. Use Lakebed's built-in APIs and CLI.
|
||||
|
||||
## Limits to check first
|
||||
|
||||
- The public alpha is not production-ready.
|
||||
- App code cannot use arbitrary npm packages or Node built-ins. Do not install app dependencies.
|
||||
- Database fields support `string()`, `boolean()`, `number()`, `id(...)`, and `userId()`. Chain `.optional()` or `.default(value)` on any field.
|
||||
- Local database data and uploaded files reset when the dev server restarts.
|
||||
- Hosted server secrets and outbound server-side `fetch` require a claimed deploy.
|
||||
- Unclaimed deploys expire. Use the expiry printed by the CLI. Claimed deploys do not expire.
|
||||
|
||||
## App structure and APIs
|
||||
|
||||
- `server/index.ts` exports the default `capsule()` definition. Put server code in `server/`. Import from `lakebed/server` or relative server and shared files.
|
||||
- `client/index.tsx` exports `App`. Put client code in `client/`. Import from `lakebed/client`, `preact`, `preact/hooks`, `preact/jsx-runtime`, `preact/jsx-dev-runtime`, or relative client and shared files.
|
||||
- Keep `shared/` pure TypeScript. Do not import DOM APIs, Node built-ins, env values, or Lakebed runtimes there.
|
||||
- In client code, use `import type app from "../server/index"` and `createClient<typeof app>()` for typed queries, mutations, and actions. Query hooks return `undefined` until the first result arrives.
|
||||
- Database calls are async. Await or return every database operation. Declare indexes with `.index(name, fields)` and query with `withIndex`. Use `by_creation` for unfiltered creation-order queries. Do not use legacy `where`, `orderBy`, `limit`, or `all`.
|
||||
- Queries and actions cannot write to the database. Use mutations or endpoints for writes. Filter user-owned data by the caller's `userId` and check ownership again before updates or deletes.
|
||||
- Guests get protected browser sessions without setup. Use `ctx.auth` on the server and `useAuth()` on the client. A user ID is not a credential. Do not invent guest IDs or check their prefixes.
|
||||
- Use `ctx.auth.requireIdentity()` for data that belongs to a guest or signed-in user. Use `ctx.auth.requireSignedIn()` for account-only operations. `isGuest` and `isSignedIn` are separate checks. Neither is true without a session.
|
||||
- Set `auth: { requireSignIn: true }` in `capsule()` to block all app data operations until sign-in. Client UI checks alone do not protect data. On the client, gate data components on `canAccessApp()` from `lakebed/client`.
|
||||
- If `auth.error` blocks access, show `retryAuth()` and Google sign-in. Retry cannot renew an expired or revoked token for a pending guest upgrade. Keep data components unmounted until auth recovers.
|
||||
- Declare Lakebed user fields with `userId()` from `lakebed/server`, never `string()`. When a guest signs in, declared `userId()` fields follow them to their account. `userId()` does not grant access. Keep owner filters and ownership checks. Make shared data intentional with a shared query, not a fake global user.
|
||||
- Use `auth.onGuestUpgrade` only for app-specific merge rules. It runs before automatic reference transfer in the same transaction. Plain strings, profile text, and external data do not transfer automatically.
|
||||
- Add Google sign-in with `SignInWithGoogle` or `signInWithGoogle()` from `lakebed/client`. For custom endpoints, send the identity token from `getIdentity().token` in the `X-Lakebed-Token` header. `Authorization` belongs to the app. Same-origin guest cookies work without that header.
|
||||
- Read server secrets through `ctx.env`, with values in `.env.lakebed.server`. They are not available at build time. Never put secrets in client or shared code. Deploy sync replaces hosted env with the file contents after the deploy is claimed.
|
||||
- Use complete Tailwind class names in JSX. Lakebed compiles CSS automatically from client files and their imports. Use inline styles for values loaded at runtime. Do not add CSS files, CSS modules, PostCSS, or a separate Tailwind build step.
|
||||
- Use the router from `lakebed/client` for pages. There is no file-based routing. Use `endpoint({ method, path }, handler)` from `lakebed/server` for webhooks and external HTTP clients. Request helpers include `headers.get(name)`, `query`, `json()`, `text()`, and `bytes()`.
|
||||
- Static capsule assets are limited to the favicon. Use `favicon.svg`, `favicon.ico`, or the `favicon` option in `capsule()`. Use `client.storage` for user uploads.
|
||||
|
||||
## External data and dashboards
|
||||
|
||||
Use global `fetch(url, options)` inside a handler, not `ctx.fetch`. Queries, mutations, actions, and endpoints can fetch locally and on claimed deploys. A mutation or writable endpoint can fetch external data and write rows in the same call. Data does not need to pass through the browser. Fetch shares the handler time budget and can hold up other writes, so ingest one small batch per call.
|
||||
|
||||
Lakebed has no built-in scheduler or durable continuation queue yet. For periodic ingest, use an external scheduler to call a protected `POST` endpoint. Return a cursor for the caller to advance across separate requests. Keep `auth.requireSignIn` off for public reads and check an app secret in the ingest endpoint. CLI deploy tokens do not authenticate app endpoint callers.
|
||||
|
||||
Database read budgets apply to the whole handler. A loop over `paginate()` does not bypass them. For totals larger than one handler can read, maintain summary rows during ingest. Store timestamps with `number()` as epoch milliseconds. See the [handler capability table](https://docs.lakebed.dev/capsule-api/index.md#handler-capabilities), [dashboard ingest example](https://docs.lakebed.dev/database/index.md#dashboard-counts), and [resource limits](https://docs.lakebed.dev/limits/index.md) before planning a backfill.
|
||||
|
||||
## Run and verify
|
||||
|
||||
Run commands from this capsule directory with `npx lakebed`.
|
||||
|
||||
Start dev in a terminal session that can stay open:
|
||||
|
||||
```sh
|
||||
npx lakebed dev
|
||||
```
|
||||
|
||||
Keep that process running. Edit the starter to build the requested app, then test its behavior at the URL printed by dev. Use another terminal to inspect logs and data:
|
||||
|
||||
```sh
|
||||
npx lakebed logs --port 3000
|
||||
npx lakebed db dump --port 3000
|
||||
```
|
||||
|
||||
Use the dev server's port if it differs from 3000. Fix compile errors and runtime errors before deploying. Check user-owned data with separate browser profiles or the `?lakebed_guest=<name>` local test override when the app stores private data. Named overrides are local test identities and cannot upgrade to an account.
|
||||
|
||||
## Deploy and verify
|
||||
|
||||
After local checks pass, deploy from another terminal:
|
||||
|
||||
```sh
|
||||
npx lakebed deploy
|
||||
```
|
||||
|
||||
If the CLI requires a claim for server secrets or outbound fetch, follow its claim instructions and deploy again. A claim-required preview is not a working app.
|
||||
|
||||
Open the returned URL and test the requested behavior. Inspect the deployed app from this capsule directory, using its returned ID or URL:
|
||||
|
||||
```sh
|
||||
npx lakebed inspect <deploy-id-or-url>
|
||||
npx lakebed logs <deploy-id-or-url>
|
||||
```
|
||||
|
||||
Hosted inspection is private by default. The CLI uses saved credentials. Report the working URL, the checks you ran, and the expiry if the deploy is unclaimed. Default app URLs use `lakebed.app` subdomains.
|
||||
|
||||
## Read when needed
|
||||
|
||||
- For server and client API details, read the [capsule API](https://docs.lakebed.dev/capsule-api/index.md).
|
||||
- For indexes and queries, read the [database guide](https://docs.lakebed.dev/database/index.md).
|
||||
- For Google sign-in and identity, read the [auth guide](https://docs.lakebed.dev/auth/index.md).
|
||||
- For user uploads, read the [storage guide](https://docs.lakebed.dev/storage/index.md).
|
||||
- For claiming, domains, and other CLI commands, read the [reference](https://docs.lakebed.dev/reference/index.md).
|
||||
- For an older capsule using synchronous database calls, read the [migration guide](https://docs.lakebed.dev/database-migration/index.md).
|
||||
- For anything else, read the [docs index](https://docs.lakebed.dev/llms.txt). It lists every page and section so you can fetch only the one you need.
|
||||
@@ -1 +0,0 @@
|
||||
@AGENTS.md
|
||||
@@ -1,58 +0,0 @@
|
||||
# compat-db: Frame Control's compatibility database
|
||||
|
||||
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
|
||||
reports for Android apps on the Steam Frame. For now only the maintainer's
|
||||
copy of Frame Control has the key to read or write it. Everyone else's reports
|
||||
stay on their own Mac (see `shared()` in `ui/frame_compat_db.py`).
|
||||
|
||||
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
|
||||
claimed, so it doesn't expire). The browser page only says it's private.
|
||||
- Access: `GET /v1/reports?since=<createdAt>` and `POST /v1/reports` with
|
||||
`{"reports": [...]}`. Both need the `x-frame-control-key` header. There are
|
||||
no Lakebed queries or mutations, so nothing else can reach the rows.
|
||||
- Key: `FRAME_CONTROL_KEY` in `.env.lakebed.server` (git-ignored, synced on
|
||||
deploy) and in the Mac's login Keychain (service `frame-control-compat-db`,
|
||||
account `app-key`), where `ui/frame_compat_db.py` reads it.
|
||||
- Duplicates: each report carries a `clientId`, and a report already stored is
|
||||
skipped, so retries and restores are safe to repeat.
|
||||
- Free-plan limits: 1 MiB of data and 16,384 rows per deploy, 1,000 writes a
|
||||
day. A report is about 300 bytes, so roughly 3,000 reports fit.
|
||||
|
||||
## Backups
|
||||
|
||||
`scripts/compat-db-backup.sh` exports every report through the app key and
|
||||
keeps dated copies in
|
||||
`~/Library/Application Support/Frame Control/compat-db/backups` (newest 60).
|
||||
When the data has changed, it also uploads them with `gog` to the Google
|
||||
Drive folder named by `DRIVE_FOLDER_ID` (set it in the LaunchAgent's
|
||||
`EnvironmentVariables`). A LaunchAgent runs it daily at 03:40 and logs to
|
||||
`~/Library/Logs/frame-compat-backup.log`. If an export has fewer reports than
|
||||
the last good backup (`backups/.last-good`), it's kept as `refused-*.json`,
|
||||
nothing is uploaded, and every later run refuses too until you rerun with
|
||||
`--accept-shrink`.
|
||||
|
||||
Reports that can't be sent (unreadable outbox lines, or ones the server
|
||||
rejects, which it lists by `clientId`) are never dropped: they move to
|
||||
`~/Library/Application Support/Frame Control/compat-db/compat-outbox.jsonl.rejected`,
|
||||
with the reason.
|
||||
|
||||
Restore (to this deploy or a new one):
|
||||
|
||||
```sh
|
||||
python3 ui/frame_compat_db.py import BACKUP.json # duplicates are skipped
|
||||
python3 ui/frame_compat_db.py count
|
||||
```
|
||||
|
||||
`npx lakebed db export dep_dDmcsosVSiFirpW6 --out full.json` is a second,
|
||||
owner-only export path through the Lakebed CLI.
|
||||
|
||||
## Change and deploy
|
||||
|
||||
```sh
|
||||
cd compat-db
|
||||
npx lakebed dev --port 3917 # local; data resets on restart
|
||||
npx lakebed deploy # updates frame-compat.lakebed.app
|
||||
```
|
||||
|
||||
To rotate the key: generate a new one, update the Keychain item and
|
||||
`.env.lakebed.server`, then deploy.
|
||||
@@ -1,9 +0,0 @@
|
||||
// No browser access to the data: Frame Control reads and writes it through the
|
||||
// key-protected /v1 endpoints only.
|
||||
export function App() {
|
||||
return (
|
||||
<main className="min-h-screen grid place-items-center bg-slate-900 text-slate-300 p-8">
|
||||
<p>Frame compatibility database. Private: only Frame Control can use it.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="lakebed-favicon-gradient" x1="12" y1="8" x2="52" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="hsl(157 84% 58%)" />
|
||||
<stop offset="1" stop-color="hsl(193 82% 44%)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="url(#lakebed-favicon-gradient)" />
|
||||
<circle cx="48" cy="16" r="18" fill="#fff" opacity=".16" />
|
||||
<text x="32" y="39" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="32" font-weight="800" fill="#fff">C</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 658 B |
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"deployId": "dep_dDmcsosVSiFirpW6"
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { capsule, endpoint, json, string, table, text } from "lakebed/server";
|
||||
|
||||
// Compatibility reports for Android apps on the Steam Frame, written and read
|
||||
// only by Frame Control. There are no queries or mutations, so browsers and
|
||||
// Lakebed clients can't reach the data; the two endpoints require the app key
|
||||
// (FRAME_CONTROL_KEY in .env.lakebed.server, kept in the Mac's Keychain).
|
||||
|
||||
const RESULTS = ["runs", "crashes", "install_failed", "instance_failed"];
|
||||
const RATINGS = ["works", "issues", "broken"];
|
||||
const PAGE = 500;
|
||||
|
||||
type Incoming = Record<string, unknown>;
|
||||
|
||||
function field(r: Incoming, key: string, max = 200): string | undefined {
|
||||
const v = r[key];
|
||||
if (v === undefined || v === null || v === "") return undefined;
|
||||
return String(v).slice(0, max);
|
||||
}
|
||||
|
||||
function authorised(ctx: { env: Record<string, string | undefined> }, key: string | null): boolean {
|
||||
const expected = ctx.env.FRAME_CONTROL_KEY;
|
||||
if (!expected || !key) return false;
|
||||
// Compare every position of the longer string so timing doesn't reveal the key length.
|
||||
const n = Math.max(key.length, expected.length);
|
||||
let diff = key.length ^ expected.length;
|
||||
for (let i = 0; i < n; i++) diff |= (key.charCodeAt(i) || 0) ^ (expected.charCodeAt(i) || 0);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export default capsule({
|
||||
name: "frame-compat",
|
||||
|
||||
auth: { requireSignIn: false },
|
||||
|
||||
schema: {
|
||||
reports: table({
|
||||
package: string(),
|
||||
version: string().optional(),
|
||||
result: string().optional(),
|
||||
rating: string().optional(),
|
||||
notes: string().optional(),
|
||||
via: string().optional(),
|
||||
reportedAt: string(),
|
||||
steamos: string().optional(),
|
||||
lepton: string().optional(),
|
||||
runtime: string().optional(),
|
||||
label: string().optional(),
|
||||
source: string().optional(),
|
||||
clientId: string()
|
||||
}).index("by_package", ["package"]).index("by_client", ["clientId"])
|
||||
},
|
||||
|
||||
endpoints: {
|
||||
// GET /v1/reports?since=<createdAt> -> { reports: [...], next: <createdAt> | null }
|
||||
// Pass `next` back as `since` until it's null; rows at the boundary repeat, so dedupe by id.
|
||||
list: endpoint({ method: "GET", path: "/v1/reports" }, async (ctx, req) => {
|
||||
if (!authorised(ctx, req.headers.get("x-frame-control-key"))) return text("unauthorized", { status: 401 });
|
||||
const since = req.query.get("since") ?? "";
|
||||
const rows = await ctx.db.reports
|
||||
.withIndex("by_creation", (q) => q.gte("createdAt", since))
|
||||
.take(PAGE);
|
||||
return json({ reports: rows, next: rows.length === PAGE ? rows[rows.length - 1].createdAt : null });
|
||||
}),
|
||||
|
||||
// POST /v1/reports body: { reports: [ {...}, ... ] } (max 100 per call)
|
||||
// clientId makes retries idempotent: a report already stored is skipped.
|
||||
// Invalid reports are listed in `rejected` (by clientId) so the app can keep them.
|
||||
add: endpoint({ method: "POST", path: "/v1/reports" }, async (ctx, req) => {
|
||||
if (!authorised(ctx, req.headers.get("x-frame-control-key"))) return text("unauthorized", { status: 401 });
|
||||
const body = await req.json<{ reports?: Incoming[] }>();
|
||||
const incoming = Array.isArray(body?.reports) ? body.reports.slice(0, 100) : [];
|
||||
let inserted = 0;
|
||||
const rejected: string[] = [];
|
||||
for (const r of incoming) {
|
||||
const pkg = field(r, "package");
|
||||
const clientId = field(r, "clientId", 80);
|
||||
const reportedAt = field(r, "date", 40);
|
||||
const result = field(r, "result");
|
||||
const rating = field(r, "rating");
|
||||
if (!pkg || !clientId || !reportedAt || (result && !RESULTS.includes(result)) ||
|
||||
(rating && !RATINGS.includes(rating))) {
|
||||
if (clientId) rejected.push(clientId);
|
||||
continue;
|
||||
}
|
||||
const dup = await ctx.db.reports.withIndex("by_client", (q) => q.eq("clientId", clientId)).first();
|
||||
if (dup) continue;
|
||||
await ctx.db.reports.insert({
|
||||
package: pkg, version: field(r, "version", 80), result, rating,
|
||||
notes: field(r, "notes", 1000), via: field(r, "via", 20), reportedAt,
|
||||
steamos: field(r, "steamos", 40), lepton: field(r, "lepton", 40),
|
||||
runtime: field(r, "runtime", 20), label: field(r, "label", 120),
|
||||
source: field(r, "source", 300), clientId
|
||||
});
|
||||
inserted++;
|
||||
}
|
||||
return json({ inserted, rejected, received: incoming.length });
|
||||
}),
|
||||
|
||||
status: endpoint({ method: "GET", path: "/v1/status" }, () => text("ok"))
|
||||
}
|
||||
});
|
||||
@@ -1,299 +0,0 @@
|
||||
# Installing APKs (Lepton)
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md). Android apps run
|
||||
in **Lepton**, Valve's Waydroid-based container. Lepton is built for games,
|
||||
not general Android use
|
||||
([GamingOnLinux](https://www.gamingonlinux.com/2026/09/lepton-from-valve-to-run-android-games-on-linux-is-now-open-source/)).
|
||||
|
||||
## Install from the Mac: one app, one Lepton instance (verified 2026-09-25)
|
||||
|
||||
Use Frame Control's **Android apps** section (search, Install, Test, Report), drop
|
||||
an `.apk` on **Send to Frame**, or:
|
||||
|
||||
```sh
|
||||
./scripts/install-apk.sh some-app.apk # own instance, Steam shortcut
|
||||
python3 ui/frame_android.py list|launch|stop|remove|probe <package>
|
||||
```
|
||||
|
||||
Each APK becomes its own app, the way T3 Code is set up (see the instance
|
||||
section below), instead of going into Lepton Development:
|
||||
|
||||
1. `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 Frame Control keeps its own. Your reports are saved on your Mac; the
|
||||
maintainer's copy also syncs them to a private Lakebed database.
|
||||
**Test** records whether the app stays up in its own instance, and **Report**
|
||||
(for any APK, F-Droid or not) records whether it worked, how it was run, where it came from, and notes, each with the SteamOS and Lepton build ids.
|
||||
See
|
||||
[compat-db/README.md](../compat-db/README.md) and
|
||||
[apk-catalog/README.md](../apk-catalog/README.md).
|
||||
|
||||
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
|
||||
|
||||
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
|
||||
newest archived build without Compose, runs, loads the full catalogue (the
|
||||
first repo update takes about 90s), and can install apps. F-Droid 2.0 uses
|
||||
Compose 1.12, so it should work, but it hasn't been tried. The catalogue's
|
||||
Install button for F-Droid installs 1.17.2. To let F-Droid install apps without
|
||||
a settings prompt, with the tunnel open:
|
||||
|
||||
```sh
|
||||
adb -s $S shell appops set org.fdroid.fdroid REQUEST_INSTALL_PACKAGES allow
|
||||
```
|
||||
|
||||
## Handy commands
|
||||
|
||||
Open a tunnel by hand (use any free local port):
|
||||
|
||||
```sh
|
||||
ssh -f -N -M -S /tmp/frame-adb.sock -L 127.0.0.1:15555:127.0.0.1:5555 frame
|
||||
adb connect 127.0.0.1:15555
|
||||
S=127.0.0.1:15555
|
||||
# when done: adb disconnect $S; ssh -S /tmp/frame-adb.sock -O exit frame
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
adb -s $S shell pm list packages -3 # installed third-party apps
|
||||
adb -s $S shell monkey -p <pkg> -c android.intent.category.LAUNCHER 1
|
||||
# If monkey exits with -5 (it did for T3 Code), start the activity directly:
|
||||
adb -s $S shell am start -W -n "$(adb -s $S shell cmd package resolve-activity --brief -c android.intent.category.LAUNCHER <pkg> | tail -n 1)"
|
||||
adb -s $S logcat -d -b crash # why an app died
|
||||
adb -s $S uninstall <pkg>
|
||||
adb -s $S exec-out screencap -p > shot.png # the Lepton window
|
||||
```
|
||||
|
||||
## Reaching a Mac service from Lepton (T3 Code v2, verified 2026-09-25)
|
||||
|
||||
Lepton runs in podman with `pasta` networking. It has **its own loopback**, so
|
||||
a port on the Frame's `127.0.0.1` isn't visible as `127.0.0.1` inside Android.
|
||||
But pasta runs with `--map-gw`, so the **gateway address inside Lepton
|
||||
(`192.168.1.1` on the home network) maps to the Frame host's loopback**.
|
||||
|
||||
T3 Code v2 on the Mac listens only on `127.0.0.1:3873`. To reach it:
|
||||
|
||||
1. Keep `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running on the Mac,
|
||||
for example from a LaunchAgent with `KeepAlive`, so launchd restarts it if
|
||||
it drops.
|
||||
2. In the app on the Frame, the environment host is `192.168.1.1:3873`.
|
||||
|
||||
The app on the Frame was built from the T3 Code v2 nightly source with `expo prebuild` and `gradlew assembleRelease
|
||||
-PreactNativeArchitectures=arm64-v8a`, using Homebrew `openjdk@17` and the
|
||||
`android-commandlinetools` SDK. It's signed with the debug key.
|
||||
|
||||
To pair again, issue a one-time code on the Mac and type it into
|
||||
**Add environment**:
|
||||
|
||||
```sh
|
||||
A="/Applications/T3 Code (V2 Preview).app"
|
||||
ELECTRON_RUN_AS_NODE=1 "$A/Contents/MacOS/T3 Code (Alpha)" \
|
||||
"$A/Contents/Resources/app.asar/apps/server/dist/bin.mjs" \
|
||||
auth pairing create --base-dir "$HOME/.t3-v2" --ttl 15m --label "Steam Frame"
|
||||
```
|
||||
|
||||
The app is deleted whenever Lepton Development closes (see above), so reinstall
|
||||
it afterwards or set `LEPTON_NO_CLEANUP=1`. The gateway address comes from the Frame's network when Lepton starts. On a
|
||||
different network, check it with `adb shell ip route` and edit the host.
|
||||
|
||||
## Lepton Development forgets apps; give an app its own instance (verified 2026-09-25)
|
||||
|
||||
**Lepton Development wipes every installed app when it exits.** Its launcher
|
||||
logs `Clearing baked app data due to non steamlaunch container`, unless
|
||||
`LEPTON_NO_CLEANUP` is set. A Steam-style launch (with `SteamAppId` set) is a
|
||||
"steamlaunch" context and keeps its data:
|
||||
|
||||
- App data lives in `STEAM_COMPAT_DATA_PATH/internal/<package>` (symlinked to
|
||||
`/data/data/<package>`) and survives everything, including APK updates.
|
||||
- `STEAM_COMPAT_DATA_PATH/baked` is Lepton's Android snapshot. It's rebuilt when
|
||||
the APK changes, or when the app exits within 30 seconds of starting.
|
||||
- `STEAM_COMPAT_DATA_PATH` must be under `~/.local/share/Steam` (use
|
||||
`steamapps/compatdata/<id>`). Only that tree is mounted in the container. Put
|
||||
it anywhere else and the symlinks dangle, so the app crashes with
|
||||
`ENOENT` on its first file write.
|
||||
- Lepton runs apps headless unless an empty `lepton-show-flatscreen` file sits
|
||||
next to the APK (`STEAM_COMPAT_INSTALL_PATH`).
|
||||
- Several instances can run at once. Each gets ADB on `5555 + offset`
|
||||
(`podman ps --format "{{.Names}} {{.Labels.adb_port}}"`).
|
||||
- Outside Steam, Lepton's `setpgid --foreground` re-exec fails with no
|
||||
terminal. Set `IS_PARENT=true` and start it with `setsid --wait`.
|
||||
|
||||
[`frame/t3code/launch.sh`](../frame/t3code/launch.sh) does all this for T3
|
||||
Code (context `steamlaunch-2873873873`; ADB is the first free `5555 + n`, e.g. 5557). It needs the Steam client
|
||||
running (it mounts `~/.steam/steam.pipe`).
|
||||
|
||||
**T3 Code in the Steam library (verified 2026-09-25).** The wrapper lives on
|
||||
the Frame at `~/Applications/T3Code/launch.sh`, with `t3code.apk` and the
|
||||
flatscreen marker next to it. It's a non-Steam shortcut called "T3 Code"
|
||||
(shortcut app id `3130509679`). Launching it from Steam gets its own SteamVR
|
||||
panel, `valve.steam.desktopgame.3130509679`, and opens already paired.
|
||||
|
||||
- The shortcut was added without restarting Steam, through Steam's CEF debug
|
||||
port (`127.0.0.1:8080` on the Frame, target `SharedJSContext`):
|
||||
`SteamClient.Apps.AddShortcut(name, exe, "", "")`, then `SetShortcutName`
|
||||
and `SetShortcutStartDir`. `steam steam://addnonsteamgame/<path>` only logged
|
||||
the URL and added nothing.
|
||||
- To launch it over SSH: `steam steam://rungameid/13445436691150012416`, which
|
||||
is `(3130509679 << 32) | 0x02000000`.
|
||||
- Steam sets `STEAM_FOSSILIZE_DUMP_PATH` for shortcut launches but not
|
||||
`STEAM_COMPAT_SHADER_PATH`. Lepton then dies with "unbound variable", so the
|
||||
wrapper sets both.
|
||||
- To update T3, replace `t3code.apk`. Lepton rebuilds its snapshot on the next
|
||||
launch, and the pairing survives.
|
||||
|
||||
## Crashing apps can take down the headset session (verified 2026-09-25)
|
||||
|
||||
Some apps crash Android's graphics composer HAL, which kills the Lepton
|
||||
container. On 2026-09-25 a batch crash-test also coincided with `steamvr.service`
|
||||
restarting "on client request", which stops and SIGKILLs `gamescope-session`.
|
||||
After one of those kills, gamescope crash-looped about once a second on
|
||||
`rendervulkan.cpp:2181 ... Assertion '!modifiers.empty()'` because it kept
|
||||
attaching to the SteamVR processes orphaned from the dead session. The fix
|
||||
without sudo was to `for p in vrdashboard vrcompositor vrserver; do pkill -TERM -x $p; done` (pkill takes one pattern). The
|
||||
next session then started SteamVR fresh and recovered within a minute.
|
||||
|
||||
## Android display: resolution, UI scale, text size (verified 2026-09-25, SteamOS 0.3.0, build 20260922.6101926)
|
||||
|
||||
Each running Lepton instance has its own ADB port on the Frame, assigned at
|
||||
launch: 5555 is Lepton Development, and own-instance apps take the next free
|
||||
port (T3 Code was on 5557). Find them with `ss -ltn` (5555–5599) and identify
|
||||
each with `pm list packages -3`. Both instances reported `Physical size:
|
||||
1920x1080`. Their densities were 180 dpi (Lepton Development) and 213 dpi
|
||||
(T3 Code), and `settings get system font_scale` returned `null` (1.0).
|
||||
|
||||
These all apply immediately and read back as set. Tested on Lepton Development
|
||||
only:
|
||||
|
||||
```sh
|
||||
adb -s $S shell wm size 2560x1440 # or: wm size reset
|
||||
adb -s $S shell wm density 240 # or: wm density reset
|
||||
adb -s $S shell settings put system font_scale 1.15
|
||||
adb -s $S shell settings delete system font_scale
|
||||
```
|
||||
|
||||
After a `wm` reset, Android writes `font_scale=1.0` back asynchronously, so a
|
||||
single delete that follows one reads back `1.0`. A second delete a second later
|
||||
leaves it `null`. Frame Control's **Android display** card does this for you
|
||||
(`/api/android/display`).
|
||||
|
||||
**Inferred, not yet checked in the headset:** a bigger Android resolution with
|
||||
density scaled to match (2560×1440 at 4/3 of the density) gives sharper text,
|
||||
because gamescope scales Lepton's surface to fit the same panel. Also unverified:
|
||||
whether the settings survive the app or its Lepton instance relaunching.
|
||||
Lepton Development rebuilds its Android data on exit, so there they probably
|
||||
don't.
|
||||
@@ -1,38 +0,0 @@
|
||||
# File transfer and clipboard
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md). Everything here
|
||||
depends on SSH working through the `frame` alias from `scripts/connect.sh`.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Command | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| **scp / rsync over SSH** | `./scripts/push.sh file-or-dir [dest]`, or `rsync -a --progress x frame:Downloads/` | **Inferred.** SSH is confirmed. Valve recommends WinSCP (SFTP) for Windows ([debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)), which means SFTP is enabled. | Recommended. The Mac ships `rsync` (newer macOS uses `openrsync`, which supports the flags used here). `rsync` must also exist on the Frame. It's in SteamOS on Deck; if it's missing on the Frame, `push.sh` falls back to `scp`. |
|
||||
| SFTP GUI | Finder can't do SFTP. Use Cyberduck / Transmit / ForkLift with `sftp://steamos@frame.local` | Inferred | Good for browsing. |
|
||||
| `adb push` | `adb push x /sdcard/Download/` (Lepton) | Confirmed that ADB exists ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)) | Only reaches the Android container's storage. |
|
||||
| SteamOS Devkit Client | "Title Upload" | Confirmed (Frame) ([loadgames](https://partner.steamgames.com/doc/steamhardware/steamframe/loadgames)) | For deploying apps and games, not general files. macOS support for the Devkit Client wasn't confirmed. |
|
||||
| Syncthing | A Syncthing Flatpak on the Frame (`./scripts/install-apps.sh <flathub-app-id>`), app on the Mac | Guess (which Syncthing Flatpak, and whether it has an aarch64 build, not checked) | Good for an ongoing shared folder. |
|
||||
| KDE Connect | KDE Connect on both | Guess | There's a macOS build of KDE Connect, but whether it's present or installable on the Frame wasn't confirmed. It would give you clipboard sync, file send, and remote input. Worth checking on-device. |
|
||||
| microSD | Physical card | Confirmed that the slot exists ([Wikipedia](https://en.wikipedia.org/wiki/Steam_Frame)) | Offline fallback. |
|
||||
|
||||
## Clipboard
|
||||
|
||||
`scripts/paste-to-frame.sh` sends the Mac clipboard (or stdin) to the
|
||||
headset's desktop clipboard. You can then paste in the headset with the
|
||||
virtual keyboard's paste key or a right-click → Paste.
|
||||
|
||||
```sh
|
||||
./scripts/paste-to-frame.sh # sends pbpaste
|
||||
echo "https://example.com" | ./scripts/paste-to-frame.sh -
|
||||
```
|
||||
|
||||
How it works (verified 2026-09-25). The headset's desktop is a Plasma Wayland
|
||||
session nested inside gamescope, with its own runtime dir
|
||||
(`/run/user/1000/nested_plasma`) and its own D-Bus bus. `wl-copy` and `xclip`
|
||||
aren't installed. The script reads the bus address from `plasmashell`'s
|
||||
environment and calls Klipper's `setClipboardContents` with `qdbus6`. The
|
||||
desktop has to be running in the headset. It's text only, and pastes over about
|
||||
100 KB hit the argument limit, so send big things with `push.sh`.
|
||||
|
||||
A simpler fallback: `ssh frame 'cat > ~/clip.txt'` < file, then open it in the
|
||||
headset.
|
||||
@@ -1,133 +0,0 @@
|
||||
# Frame Control in detail
|
||||
|
||||
What each part of the app does, how it works, and what has been checked on a
|
||||
real Frame. For installing it, see the [README](../README.md#install).
|
||||
|
||||
As of 2026-09-25 no other desktop app manages the Frame end to end.
|
||||
[Stream Frame](https://streamframe.app/) (macOS 14+, free) records and screenshots
|
||||
the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is
|
||||
Windows-only. Steam Link views the headset.
|
||||
|
||||
You can also run the same UI in a browser without the app, from a checkout:
|
||||
|
||||
```sh
|
||||
./scripts/frame-ui.sh # macOS: opens http://127.0.0.1:47810 in its own window
|
||||
python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||
floating panels, dashboard and controllers). Shows the left eye, like pointing
|
||||
a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
|
||||
is 720p video at about 30 fps: `ffmpeg` on the Frame encodes SteamVR's
|
||||
headset-view device (`/dev/video99`) to H.264 over SSH, and the page decodes
|
||||
it with WebCodecs. Live video is one eye; Capture still gets both. The viewer
|
||||
fits the whole frame; zoom with − / + (or scroll, or double-click), drag to
|
||||
pan, `0` to fit, `F` for full screen. Capture uses OpenVR's `IVRScreenshots`
|
||||
API through Python `ctypes` (`ui/frame_vrshot.py`). Nothing extra is
|
||||
installed on the Frame (SteamOS ships `ffmpeg`). **Desktop panel** captures
|
||||
gamescope's flat layer instead.
|
||||
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
|
||||
save them to `~/Pictures/SteamFrame`.
|
||||
- **Battery** with charging state: charge rate in watts, time to full or empty,
|
||||
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
|
||||
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
|
||||
the desktop, Lepton and xrdp are running.
|
||||
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
|
||||
- **Get games**: every game you own with its Steam Frame rating (Verified,
|
||||
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
|
||||
with live progress. Search the Steam store with prices and Frame ratings; Buy
|
||||
opens the store page in your browser, or Store on Frame opens it in the
|
||||
headset. It drives the Frame's own Steam client through its DevTools port;
|
||||
see [steam-games.md](steam-games.md).
|
||||
- **Volume** and mute (`wpctl`).
|
||||
- **Android apps**: search about 4,500 F-Droid apps rated for the Frame, install
|
||||
one with a click as its own Lepton instance (it keeps its data and shows in the
|
||||
Steam library), then launch, stop, test or remove it. **Report an APK** records
|
||||
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
|
||||
installed app). Your reports are saved on your computer and change the verdicts
|
||||
you see. They aren't uploaded anywhere: the shared database is maintainer-only
|
||||
for now (see [compat-db/README.md](../compat-db/README.md)). Needs `adb`, and
|
||||
`aapt2` for reading APK files.
|
||||
- **Android display**: pick a running Lepton instance (by the app in it) and set
|
||||
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
|
||||
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
|
||||
(0.85–1.3×) over ADB (`wm size`, `wm density`, `font_scale`). Reset puts all
|
||||
three back. Whether the settings survive the app relaunching is untested.
|
||||
- **Transfer**: drag and drop files to `~/Downloads`; `.apk` files install as
|
||||
their own Android app. Send typed text, or your computer's clipboard, to the
|
||||
Frame clipboard.
|
||||
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
||||
Remmina).
|
||||
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
||||
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
||||
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
||||
asks for the sudo password over SSH.
|
||||
|
||||
## How it works
|
||||
|
||||
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
|
||||
and shows it in its own window; the server stops when you quit the app. The
|
||||
app bundles `ui/`, `scripts/`, `frame/android/` and the rated catalogue from
|
||||
`apk-catalog/`, and on Windows an embedded Python too.
|
||||
|
||||
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
||||
with a non-local `Host` header, and any `/api/` request without a custom
|
||||
header, so other websites can't drive it or read captures. Everything reaches
|
||||
the headset through the `frame` SSH alias. On macOS and Linux it keeps one
|
||||
multiplexed SSH connection open, so status and each capture take about 0.3 s.
|
||||
Windows' OpenSSH can't share a connection, so there each request connects on
|
||||
its own and the app is a little slower. What differs between the three
|
||||
systems lives in `ui/frame_host.py`.
|
||||
|
||||
Headset captures are deleted from the Frame as soon as they're copied, because
|
||||
they show everything on screen, including anything private. The look follows
|
||||
the Steam client: its palette, Motiva Sans (loaded from Valve's CDN), portrait
|
||||
library capsules and green Play buttons.
|
||||
|
||||
**Verified on the Frame 2026-09-25 (macOS app):** status and charging details,
|
||||
both capture modes (headset view while in use, and a blank frame in standby,
|
||||
which the UI labels), clipboard, volume, file push, and input validation.
|
||||
**Not yet exercised from the UI:** Launch, Flatpak install/remove, APK drop,
|
||||
and the power buttons. Each of these calls a command that was verified
|
||||
separately.
|
||||
|
||||
## Per-platform notes
|
||||
|
||||
**macOS.** The app reads `PATH` from your login shell, so Homebrew's `rsync`,
|
||||
`adb` and Python work when you launch it from Finder. Set Up Connection runs
|
||||
`scripts/connect.sh` in Terminal. The log is at
|
||||
`~/Library/Logs/Frame Control/server.log`. The build is ad-hoc signed and not
|
||||
notarized: a downloaded copy is quarantined until you run
|
||||
`xattr -dr com.apple.quarantine "/Applications/Frame Control.app"`. The first
|
||||
time you use them, macOS asks to allow local network access (for SSH) and
|
||||
control of Terminal (for SSH and power actions).
|
||||
|
||||
**Windows.** Python is bundled; `ssh` is Windows' built-in OpenSSH client
|
||||
(Settings → System → Optional features, if it's been removed). Set Up
|
||||
Connection runs `ui/frame_connect.py` in a console window. Copies use `scp`
|
||||
because Windows has no `rsync`. The installer isn't code-signed, so SmartScreen
|
||||
warns on first run: choose **More info → Run anyway**. The log is at
|
||||
`%APPDATA%\Frame Control\logs\server.log`.
|
||||
|
||||
**Linux.** Needs `python3` (3.8 or later) and `ssh`, which most desktops
|
||||
have. The AppImage runs anywhere; the `.deb` pulls both in on Debian and
|
||||
Ubuntu. Set Up Connection runs `ui/frame_connect.py` in your terminal emulator
|
||||
(GNOME Terminal, Konsole, xterm and others). Sending the clipboard needs
|
||||
`wl-clipboard` (Wayland) or `xclip` (X11). The log is at
|
||||
`~/.config/Frame Control/logs/server.log`.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
cd app
|
||||
npm install
|
||||
npm start # run from the checkout without packaging
|
||||
npm run dist # macOS: dist/*.dmg and .zip (Apple Silicon)
|
||||
npm run dist:win # Windows: installer and .zip (fetches the embedded Python first)
|
||||
npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
|
||||
```
|
||||
|
||||
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to the
|
||||
release (`.github/workflows/release.yml`).
|
||||
@@ -1,78 +0,0 @@
|
||||
# How the Frame is put together (field notes)
|
||||
|
||||
What we learnt by poking at a real Frame over SSH. Unless a line says
|
||||
otherwise, it was **verified 2026-09-25** on SteamOS 0.3.0 (`VARIANT_ID=vr`,
|
||||
build 20260922.6101926, kernel 6.18, aarch64). Topic docs go deeper. This page
|
||||
is the map.
|
||||
|
||||
## The layer cake
|
||||
|
||||
```
|
||||
SteamVR (vrserver, vrcompositor, vrdashboard) ← renders the room + panels
|
||||
└─ gamescope --backend openvr ← one SteamVR overlay per app id
|
||||
├─ Xwayland :0 (Steam UI, games, tagged apps) ← STEAM_GAME property = app id
|
||||
├─ Xwayland :1 (STEAM_GAME_DISPLAY_0)
|
||||
├─ Wayland socket gamescope-0
|
||||
└─ steamos-nested-desktop ← "the Linux desktop" panel
|
||||
└─ dbus-run-session startplasma-wayland
|
||||
└─ kwin_wayland 1280×800, Wayland wayland-0, Xwayland :2
|
||||
└─ plasmashell, Konsole, Dolphin, Flatpaks you open there
|
||||
Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 3056000
|
||||
```
|
||||
|
||||
## Facts worth knowing
|
||||
|
||||
| Fact | Where it matters |
|
||||
|---|---|
|
||||
| The desktop is a **nested** Plasma session: runtime dir `/run/user/1000/nested_plasma`, its own D-Bus bus, `WAYLAND_DISPLAY=wayland-0`, `DISPLAY=:2`. A plain `ssh frame app` can't find it. Copy the env from `plasmashell`'s `/proc/<pid>/environ`. | `run-on-frame.sh`, `paste-to-frame.sh` |
|
||||
| The desktop size is hard-coded to 1280×800 in `/usr/bin/steamos-nested-desktop` (read-only rootfs). | [panels.md](panels.md) |
|
||||
| gamescope runs with `--virtual-connector-strategy PerAppId`. Each app id becomes a SteamVR overlay `valve.steam.desktopgame.<id>`, which is a panel you can float. Setting `STEAM_GAME` on an X11 window on `:0` makes a new panel. | `panel-on-frame.sh`, [panels.md](panels.md) |
|
||||
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
|
||||
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
|
||||
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
|
||||
| SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) |
|
||||
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
|
||||
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
|
||||
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
|
||||
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
|
||||
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
|
||||
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale` (installable in `~`, see below), `krfb`, `wayvnc`. | Script design |
|
||||
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` |
|
||||
| `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things |
|
||||
| Clipboard: Klipper over the nested D-Bus bus (`qdbus6 org.kde.klipper …`). | `paste-to-frame.sh` |
|
||||
| Lepton listens for ADB on the Frame's loopback `5555`, so tunnel it over SSH. It's Android 11 (API 30), 64-bit ARM only, with no `clipboard` service: Compose < 1.11, SDL/Kivy and Godot 4.3 apps crash on launch. | [apks.md](apks.md), `apk-catalog/` |
|
||||
| Lepton Development deletes every ADB-installed app when it exits (`clear_baked_app_data "non steamlaunch container"` in `…/common/Lepton/lepton`) unless `LEPTON_NO_CLEANUP` is set. | [apks.md](apks.md) |
|
||||
| Any APK can run as its own Lepton instance: run `…/common/Lepton/lepton waitforexitandrun -- app.apk` with `SteamAppId` set and `STEAM_COMPAT_DATA_PATH` under `~/.local/share/Steam`. Data persists and each gets its own container and panel. `frame/android/lepton-app.sh`, `ui/frame_android.py`. | [apks.md](apks.md) |
|
||||
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
|
||||
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app (verified 2026-09-26, seccomp sandbox off). What it looks like in the headset is still untested. Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
|
||||
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
|
||||
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
|
||||
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
||||
| 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)
|
||||
- Remote access from anywhere: [tailscale.md](tailscale.md)
|
||||
- Floating windows in space: [panels.md](panels.md)
|
||||
- What's still unverified: [open-questions.md](open-questions.md)
|
||||
|
Before Width: | Height: | Size: 892 KiB |
|
Before Width: | Height: | Size: 38 KiB |
@@ -1,115 +0,0 @@
|
||||
# Open questions and on-device checks
|
||||
|
||||
Research as of 2026-09-25, eight days after the Frame's retail release
|
||||
(2026-09-18). Most first-party detail comes from Valve's Steamworks developer
|
||||
pages. Searches of Reddit and the Steam forums turned up **almost no
|
||||
end-user reports** about SSH, desktop streaming, or macOS. Treat that as
|
||||
"not documented yet", not "doesn't work".
|
||||
|
||||
## Verified on device (2026-09-25)
|
||||
|
||||
Checked over SSH from the Mac, read-only, on SteamOS 0.3.0 (`VARIANT_ID=vr`,
|
||||
build 20260922.6101926, kernel 6.18, aarch64):
|
||||
|
||||
- **1–2.** Developer Mode + Set User Password gave working SSH with no terminal
|
||||
steps. `sshd` is enabled and active. The user is `steamos` (in `wheel`) and
|
||||
the hostname is `frame`.
|
||||
- **3.** `frame.local` resolves from the Mac; `avahi-daemon` is active.
|
||||
- **5.** `/etc/ssh/sshd_config` has `Include /etc/ssh/sshd_config.d/*.conf`.
|
||||
The existing drop-ins are `20-systemd-userdb.conf` and `99-archlinux.conf`, so
|
||||
`01-frame-keys-only.conf` would sort first as intended. (`--harden` itself
|
||||
hasn't been run.)
|
||||
- **8.** The in-headset desktop is `kwin_wayland` + `plasmashell` nested
|
||||
inside gamescope (1280×800), with `XDG_RUNTIME_DIR=/run/user/1000/nested_plasma`,
|
||||
`WAYLAND_DISPLAY=wayland-0`, `DISPLAY=:2` and a private D-Bus bus. SteamVR
|
||||
(`vrserver`, `vrcompositor`) and `xrdp` are running.
|
||||
- **9.** `rsync`, `flatpak`, `python3`, `git`, `qdbus6` and `xrdp` are present.
|
||||
`wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb` and `wayvnc`
|
||||
are **not** (Tailscale can be added in `~`; see [tailscale.md](tailscale.md)). `paste-to-frame.sh` now uses Klipper over D-Bus and round-trips
|
||||
text correctly.
|
||||
- Flathub is already configured as a **system** remote; Chromium is the only
|
||||
installed Flatpak. `/` is 10 GB (42% used); `/home` is 929 GB.
|
||||
- `push.sh` copied a test file with rsync.
|
||||
- **10.** `install-apps.sh remmina --vnc-host <mac>.local` installed Remmina as
|
||||
a `--user` Flatpak over SSH and wrote the profile. The desktop's
|
||||
`XDG_DATA_DIRS` includes the user Flatpak exports, so it shows up in the menu.
|
||||
The Frame can reach the Mac's Screen Sharing port (5900). The Remmina
|
||||
connection itself hasn't been tried in the headset yet (part of 11).
|
||||
|
||||
- **Panels.** An X11 window on gamescope's `:0` with its own `STEAM_GAME` id
|
||||
gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were
|
||||
created side by side with `panel-on-frame.sh`. See [panels.md](panels.md).
|
||||
|
||||
Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a reboot), 17–21.
|
||||
|
||||
## Check on the headset (in order)
|
||||
|
||||
1. **Is Developer Mode available on a retail unit?** Valve's pages are aimed at
|
||||
developers. Confirm that **Steam Settings → System → Enable Developer Mode**
|
||||
and **Developer → Set User Password** both exist on your OS channel (Stable
|
||||
vs Beta).
|
||||
2. **Does SSH work straight after that, with no terminal steps?** From the Mac,
|
||||
run `nc -z frame.local 22`, then `./scripts/connect.sh`.
|
||||
3. **Does `frame.local` resolve from the Mac (mDNS/Avahi)?** If not, use the IP
|
||||
and set up a DHCP reservation.
|
||||
4. **Does SSH stay enabled after a reboot and after an OS update?** Also check
|
||||
that `~/.ssh/authorized_keys` survives an update.
|
||||
5. **Is the `sshd_config.d` include present?** Check before `--harden`:
|
||||
`ssh frame 'grep -n Include /etc/ssh/sshd_config'`.
|
||||
6. **What does Steam Link on macOS show when connected to `frame`?** Is it the
|
||||
VR view, a flat mirror, or the desktop? Does keyboard/mouse input reach the
|
||||
headset?
|
||||
7. **Does the xrdp session work from Microsoft Windows App on macOS?** Valve
|
||||
only documents Windows Remote Desktop Connection. Is clipboard sync
|
||||
supported?
|
||||
8. **What kind of session is the in-headset Linux desktop?** It could be a
|
||||
normal Plasma Wayland session (with a `wayland-*` socket in
|
||||
`/run/user/$(id -u)`), X11, or something nested in SteamVR. This decides
|
||||
whether `paste-to-frame.sh` works. `ssh frame 'ls /run/user/$(id -u); loginctl list-sessions'`.
|
||||
9. **Are `wl-copy`, `xclip`, and `rsync` present on the image?**
|
||||
`ssh frame 'command -v wl-copy xclip rsync flatpak'`.
|
||||
10. **Can Flatpaks be installed `--user` over SSH, and do they appear in the
|
||||
headset's desktop?** Test with `./scripts/install-apps.sh remmina`.
|
||||
11. **Remmina → macOS Screen Sharing:** does it connect, and is it usable at
|
||||
Retina resolutions? Is the pre-seeded profile path
|
||||
(`~/.var/app/org.remmina.Remmina/data/remmina/`) the one Remmina
|
||||
actually reads?
|
||||
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** worth trying only if
|
||||
VNC is too slow.
|
||||
13. **KDE Connect**: is it preinstalled or installable on the Frame, and does
|
||||
it pair with KDE Connect for macOS?
|
||||
14. **Bluetooth keyboard pairing** on the Frame, for the rare times you do need
|
||||
to type locally.
|
||||
15. **ADB**: does `adb shell` over USB-C from a Mac (not just a Windows PC)
|
||||
reach the Linux side? Does USB power from the Mac cope?
|
||||
16. ~~**Tailscale**~~: answered 2026-09-25. A userspace `tailscaled` in `~`
|
||||
runs as a lingering user service with no sudo; see [tailscale.md](tailscale.md).
|
||||
Still open: reaching the Frame from outside the home network, and the service
|
||||
starting after a reboot.
|
||||
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the
|
||||
panels from `panel-on-frame.sh` show up, take input, and offer **Float in
|
||||
World** / **Move** / **Size**? Do floating positions survive closing and
|
||||
reopening the app, or a reboot?
|
||||
18. **`LEPTON_NO_CLEANUP=1 %command%`** as Lepton Development's launch
|
||||
option: do ADB-installed apps survive closing and reopening it?
|
||||
19. **Typing in Android apps:** Lepton has no IME installed. Does the SteamVR
|
||||
keyboard or a Bluetooth keyboard reach Android text fields, or does an
|
||||
F-Droid keyboard (installed and enabled with `ime enable`/`ime set`) work?
|
||||
20. **F-Droid 2.0** (Compose 1.12): does it run? If so, the catalogue can
|
||||
install it instead of 1.17.2.
|
||||
|
||||
21. **DeoVR local files:** does DeoVR's file browser show `Videos → VR`
|
||||
(the symlink from `push-vr-video.sh`) or `Z:\home\steamos\Videos\VR`, and do
|
||||
the colour-coded test clips play in 3D (red left eye, cyan right) for both
|
||||
H.264 and H.265? Does the DLNA browser find a server on the Mac?
|
||||
|
||||
## Unconfirmed claims made in these docs
|
||||
|
||||
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
|
||||
Steam Deck behaviour.
|
||||
- The whole Mac → Frame desktop path (VNC → Remmina). Each part is documented
|
||||
separately, but the combination is untested.
|
||||
- Steam Remote Play with a Mac as host is broken. That's based on community
|
||||
reports, not tested with the Frame.
|
||||
- `connect.sh --harden`, `serve-bootstrap.sh` and
|
||||
`bootstrap-on-frame.sh` haven't run against real hardware.
|
||||
@@ -1,125 +0,0 @@
|
||||
# Arranging windows in space
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
## The short version
|
||||
|
||||
- The in-headset **Linux desktop is one flat panel**: a nested Plasma session,
|
||||
fixed at 1280×800, drawn into a single SteamVR overlay. Windows *inside* it
|
||||
are arranged by KWin inside that rectangle. They can't leave it.
|
||||
- Every **Steam app gets its own panel**. gamescope runs with
|
||||
`--virtual-connector-strategy PerAppId`, so each distinct app id becomes a
|
||||
separate SteamVR overlay named `valve.steam.desktopgame.<appid>`.
|
||||
- To float a Linux app on its own, run it on gamescope's X display (`:0`)
|
||||
instead of in Plasma, and tag its window with an app id of its own.
|
||||
`scripts/panel-on-frame.sh` does this:
|
||||
|
||||
```sh
|
||||
./scripts/panel-on-frame.sh konsole # a terminal, as its own panel
|
||||
./scripts/panel-on-frame.sh --name notes -- kate '~/notes.md' # quote ~ so the Frame expands it
|
||||
./scripts/panel-on-frame.sh org.mozilla.firefox # a Flatpak
|
||||
./scripts/panel-on-frame.sh mac-screen # the Mac's screen (Remmina/VNC)
|
||||
```
|
||||
|
||||
- Then **place each panel with the SteamVR dashboard's docking controls**:
|
||||
**Float in World**, **Move**, **Size**, **Toggle Curvature**, dock on the
|
||||
left or right controller, **View in Theater**, and **Multitasking View**.
|
||||
|
||||
## How a panel is born (verified 2026-09-25)
|
||||
|
||||
gamescope's command line on the Frame includes:
|
||||
|
||||
```
|
||||
--backend openvr --xwayland-count 2 --virtual-connector-strategy PerAppId
|
||||
--vr-overlay-key valve.steam.gamepadui.fallback
|
||||
--vr-app-overlay-key valve.steam.desktopgame
|
||||
--vr-overlay-physical-width 2.67 --vr-overlay-enable-control-bar
|
||||
--nested-width 1280 --nested-height 720
|
||||
```
|
||||
|
||||
gamescope reads each X11 window's `STEAM_GAME` property as its app id. That's
|
||||
the same property Steam sets on games it launches. On a new id, Steam's
|
||||
SteamVR system UI logs:
|
||||
|
||||
```
|
||||
[Overlays] Created: valve.steam.desktopgame.7777777
|
||||
[Overlays] Created: valve.steam.desktopgame.7777777.layer1 … layer7
|
||||
```
|
||||
|
||||
The test: an `xterm` on `DISPLAY=:0`, tagged with
|
||||
`xprop -id <win> -f STEAM_GAME 32c -set STEAM_GAME 7777777`, produced the
|
||||
overlay above. Two more apps with different ids (`konsole`, `xterm`) produced
|
||||
two more overlays, and all three were listed together in the root property
|
||||
`GAMESCOPE_FOCUSABLE_APPS`. **Not yet checked by eye:** how the new panels
|
||||
look in the headset and how they handle input.
|
||||
|
||||
Untagged windows on `:0` get app id 0 and share the default panel. Plasma
|
||||
itself (`kwin_wayland`, pid in `GAMESCOPE_FOCUSABLE_WINDOWS`) is one of those.
|
||||
|
||||
### What `panel-on-frame.sh` does
|
||||
|
||||
1. Sets `DISPLAY=:0`, unsets `WAYLAND_DISPLAY`, and forces X11 in the
|
||||
toolkits (`QT_QPA_PLATFORM=xcb`, `GDK_BACKEND=x11`, `SDL_VIDEODRIVER=x11`,
|
||||
`MOZ_ENABLE_WAYLAND=0`). A Wayland-only app would connect to gamescope's
|
||||
own Wayland socket and not get tagged.
|
||||
2. Starts the app detached (`setsid nohup`), so it outlives SSH.
|
||||
3. Diffs the root window's children before and after, and sets `STEAM_GAME`
|
||||
on each new mapped top-level window. It keeps watching about 3s after the
|
||||
first window (for splash screens), up to 20s in total (for slow Flatpaks).
|
||||
It gives up early if the app exits before showing a window.
|
||||
4. The id comes from `--id`, or is derived from `--name`/the command in the
|
||||
range 2,000,000,000–2,000,999,999, far above real Steam app ids. The same
|
||||
label always gives the same id.
|
||||
|
||||
Limits:
|
||||
|
||||
- **Single-instance apps** (Remmina, most KDE apps with a running copy in
|
||||
Plasma) hand the request to the existing process, so the window opens
|
||||
wherever that process lives. Close the app in Plasma first.
|
||||
- A window the app opens later (a dialog, a second window) isn't tagged, so it
|
||||
lands on the default panel. Tag it by hand:
|
||||
`ssh frame 'DISPLAY=:0 xprop -id <win> -f STEAM_GAME 32c -set STEAM_GAME <id>'`
|
||||
(find `<win>` with `DISPLAY=:0 xwininfo -root -children`).
|
||||
- The script tags *any* new window on `:0` during its watch window, so a
|
||||
Steam popup that opens in those few seconds would join the panel too. For
|
||||
the same reason, run one `panel-on-frame.sh` at a time. If a stray window
|
||||
is tagged first, the script can report success while the app's own window
|
||||
stays on the default panel; check in the headset.
|
||||
- Each panel renders at gamescope's nested size (1280×720), not the Plasma
|
||||
desktop's 1280×800.
|
||||
- Steam treats the tagged id as "the current game": it applies a generic
|
||||
controller config and logs `Failed to get app info` for the made-up id. So
|
||||
far this hasn't caused anything worse.
|
||||
|
||||
## Placing panels: the SteamVR dashboard (inferred from SteamVR's UI code)
|
||||
|
||||
The Frame's SteamVR dashboard
|
||||
(`/opt/steamvr/resources/webinterface/dashboard/`) wraps each overlay in a
|
||||
frame with a **dock location**: `Dashboard`, `World`, `Theater`,
|
||||
`LeftController`, `RightController`. The strings and handlers are there
|
||||
(`dashboard_english.json`, `systemui.js`):
|
||||
|
||||
| Control | What it does |
|
||||
|---|---|
|
||||
| **Float in World** | Only shown while the panel is docked on the dashboard. Detaches it into the room, where it stays after the dashboard closes. |
|
||||
| **Move** / grab handle | Push, pull and drag the panel. *Grab Handle Acceleration* in SteamVR settings speeds up push and pull. |
|
||||
| **Size** | Resize the floating panel. |
|
||||
| **Toggle Curvature** | Flat vs curved. |
|
||||
| **Dock on Left/Right Controller** | Attach to a controller, like a wrist screen. |
|
||||
| **Dock on Dashboard / Return to Dashboard** | Put it back. |
|
||||
| **View in Theater** / Show/Hide Theater Screen | Shows the panel as a large theater screen. |
|
||||
| **Multitasking View** | Shows every open panel together (only if `VRHTML.BSupportsMultitaskingView()`). |
|
||||
| **More Options** (…) | Where the less common docking actions live. |
|
||||
|
||||
**Still to check in the headset:** where exactly each control appears, whether
|
||||
floating positions survive a panel closing and reopening, and whether there's
|
||||
a limit on the number of floating panels.
|
||||
|
||||
## Other routes
|
||||
|
||||
- **Just the desktop somewhere else**: float the Plasma panel itself. No
|
||||
script needed.
|
||||
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
||||
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
||||
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
||||
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
|
||||
@@ -1,103 +0,0 @@
|
||||
# Scripts and headset setup
|
||||
|
||||
The command-line side of this repo: how SSH gets set up with as little typing on
|
||||
the headset as possible, what to use for each job, and the helper scripts that
|
||||
Frame Control is built on. The scripts are zsh/bash and run on macOS; most also
|
||||
run on Linux. On Windows, use the app.
|
||||
|
||||
## Minimum typing on the headset
|
||||
|
||||
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
|
||||
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
|
||||
thing you type on the headset is a password you choose.
|
||||
|
||||
On the Frame:
|
||||
|
||||
1. **Steam Settings → System → Enable Developer Mode** (a toggle, no typing).
|
||||
2. Scroll down to the **Developer** section and click **Set User Password**.
|
||||
Type a password. **This is the only thing you type on the headset.** Pick
|
||||
something short, because you'll type it once more on the Mac and then
|
||||
never again.
|
||||
3. (Optional, no typing) Note the IP address from **Quick Settings** or
|
||||
**Steam Settings → Internet**, in case `frame.local` doesn't resolve.
|
||||
4. (Optional) Check **Steam Settings → System → Hostname**. Leaving it as
|
||||
`frame` means the scripts work without any extra setup.
|
||||
|
||||
On the Mac:
|
||||
|
||||
To use the scripts from a checkout instead of the app:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/saphid/steam-frame.git && cd steam-frame
|
||||
./scripts/connect.sh # or: ./scripts/connect.sh 192.168.1.50
|
||||
ssh frame # passwordless from now on
|
||||
```
|
||||
|
||||
`connect.sh` does four things:
|
||||
|
||||
- finds the headset (`frame.local`, then `frame`, or the IP/host you pass in)
|
||||
- creates a dedicated key (`~/.ssh/id_ed25519_frame`)
|
||||
- adds a `Host frame` block to `~/.ssh/config`
|
||||
- runs `ssh-copy-id`, which asks for the Developer Mode password once
|
||||
|
||||
Run `./scripts/connect.sh --harden` later if you want to turn off SSH password
|
||||
logins.
|
||||
|
||||
Sources: [Valve: Setting up your Steam Frame for development](https://partner.steamgames.com/doc/steamhardware/steamframe/setup),
|
||||
[Valve: Steam Frame Debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)
|
||||
(both **confirmed on Steam Frame**, Valve official).
|
||||
|
||||
**Fallback, only if the Developer Mode toggle doesn't give you SSH.** From the
|
||||
Mac, run `./scripts/serve-bootstrap.sh`. It prints a one-liner of about 30
|
||||
characters, like `curl -fsS mac.local:8765|bash`, to type into Konsole on the
|
||||
Frame's Linux desktop. The script it serves installs your Mac's public key and
|
||||
enables `sshd`. See [docs/ssh.md](ssh.md#fallback-bootstrap-one-liner).
|
||||
|
||||
## Recommended options
|
||||
|
||||
| Goal | Recommended | Confidence |
|
||||
|---|---|---|
|
||||
| Shell on the Frame | `ssh frame` (user `steamos`) | Confirmed (Valve docs) |
|
||||
| **See/control the Frame from the Mac** | **Steam Link for macOS → connect to `frame`** (Valve names this). Alternatives: RDP to `xrdp` with Microsoft *Windows App* for the Linux desktop, or `adb`/`scrcpy` for the Android (Lepton) layer only | Steam Link and xrdp confirmed on Frame; the Mac RDP client is inferred |
|
||||
| **Show the Mac's desktop inside the Frame** | **macOS Screen Sharing (built-in VNC) → Remmina (Flatpak, aarch64) on the Frame's Linux desktop**, installed over SSH | Inferred: each piece is documented, but the combination hasn't been tested on a Frame |
|
||||
| File transfer | `scp` / `rsync` over the `frame` alias (`scripts/push.sh`) | **Verified** (rsync is on the image) |
|
||||
| Paste Mac clipboard into the headset | `scripts/paste-to-frame.sh` (`pbpaste` → `ssh` → Klipper over D-Bus), or the clipboard sync in an RDP session | **Verified** (script); RDP untested |
|
||||
|
||||
Details: [docs/ssh.md](ssh.md), [docs/streaming.md](streaming.md),
|
||||
[docs/file-transfer.md](file-transfer.md),
|
||||
[docs/open-questions.md](open-questions.md). For how the Frame's software
|
||||
fits together, see [docs/how-the-frame-works.md](how-the-frame-works.md).
|
||||
|
||||
## Windows anywhere in the room
|
||||
|
||||
The in-headset Linux desktop is a single 1280×800 panel, and its windows can't
|
||||
leave it. Each Steam app, though, gets its own SteamVR panel. That also works
|
||||
for any Linux app tagged with an app id of its own:
|
||||
|
||||
```sh
|
||||
./scripts/panel-on-frame.sh konsole
|
||||
./scripts/panel-on-frame.sh mac-screen # the Mac's screen, in its own panel
|
||||
```
|
||||
|
||||
Then use the SteamVR dashboard's **Float in World**, **Move** and **Size**
|
||||
controls to place each panel. See [docs/panels.md](panels.md).
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Runs on | Purpose |
|
||||
|---|---|---|
|
||||
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
|
||||
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
|
||||
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
|
||||
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
|
||||
| `scripts/install-apk.sh` | Mac → Frame | Install APKs, each as its own persistent Lepton instance with a Steam library shortcut (`--dev`: old ADB path into Lepton Development) (**verified**; see [docs/apks.md](apks.md)) |
|
||||
| `scripts/panel-on-frame.sh` | Mac → Frame | Start an app as its own floating VR panel, outside the desktop (**verified**: overlays created; in-headset placement not yet checked) |
|
||||
| `scripts/run-on-frame.sh` | Mac → Frame | Start an app on the headset desktop, e.g. `mac-screen` opens Remmina straight into the Mac (**verified**) |
|
||||
| `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) |
|
||||
| `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) |
|
||||
| `scripts/compat-db-backup.sh` | Mac | Maintainer-only: back up the shared compatibility database locally and to Google Drive (**verified**) |
|
||||
| `scripts/push-vr-video.sh` | Mac → Frame | Upload VR180/360 videos to `~/Videos/VR`, linked into DeoVR's Proton prefix; `--launch` starts DeoVR (**verified**: upload and link; in-headset playback of local files not yet checked). See [docs/vr-video.md](vr-video.md) |
|
||||
| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
|
||||
| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
|
||||
| `scripts/bootstrap-on-frame.sh` | Frame | Fallback: install the key and enable `sshd` |
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
# SSH into the Steam Frame
|
||||
|
||||
Confidence labels:
|
||||
|
||||
- **Confirmed (Frame)**: Valve's Steam Frame docs or a Frame-specific source.
|
||||
- **Inferred (Deck/SteamOS)**: true on Steam Deck or SteamOS generally, but
|
||||
not checked on a Frame.
|
||||
- **Guess**: reasoned, with no source.
|
||||
|
||||
## How access is turned on
|
||||
|
||||
| Claim | Confidence | Source |
|
||||
|---|---|---|
|
||||
| **Steam Settings → System → Enable Developer Mode** enables SSH, ADB, and RDP | Confirmed (Frame) | [setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup), [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) |
|
||||
| A password is set in **Developer → Set User Password**. There is no default password. | Confirmed (Frame) | [setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup) |
|
||||
| The default user is **`steamos`**, not `deck` | Confirmed (Frame) | [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging): `ssh steamos@frame` |
|
||||
| The default hostname is **`frame`**, and can be changed in **Steam Settings → System → Hostname** | Confirmed (Frame) | [setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup), [adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton) |
|
||||
| The IP address is shown in Quick Settings or **Steam Settings → Internet** | Confirmed (Frame) | [adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton) |
|
||||
| The rootfs is read-only. `sudo steamos-readonly disable` makes it writable. | Confirmed (Frame) | [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) |
|
||||
| `sudo pacman` works. Helper aliases `cdd` (Frame scripts dir), `cdl` (Steam logs), and `lepton` exist. | Confirmed (Frame) | [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) |
|
||||
| There's a full KDE Plasma Linux desktop inside the headset, reachable from the SteamVR dashboard | Confirmed (Frame, press) | [Road to VR review](https://roadtovr.com/valve-steam-frame-review/), [UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/) |
|
||||
| On Deck, the manual route is Desktop Mode → Konsole → `passwd` → `sudo systemctl enable --now sshd` | Inferred (Deck) | [pimylifeup](https://pimylifeup.com/steam-deck-ssh/), [gist](https://gist.github.com/chphr/9c0791de6d2c659af3bf5890d9080973) |
|
||||
|
||||
On Deck, SSH needs the manual terminal steps. On the Frame, the Developer Mode
|
||||
UI handles both the password and the SSH service. That's why the headset-side
|
||||
checklist in the README involves no terminal at all.
|
||||
|
||||
## Name resolution from a Mac
|
||||
|
||||
Valve's examples use a bare `frame`. That works on Windows through
|
||||
LLMNR/NetBIOS. **On macOS, a bare single-label name usually doesn't resolve**
|
||||
unless your router's DNS registers DHCP client names.
|
||||
|
||||
- **Verified on device (2026-09-25):** `avahi-daemon` is running on the Frame
|
||||
and `frame.local` resolves from the Mac over mDNS.
|
||||
- `scripts/connect.sh` tries `frame.local`, then `frame`. If neither works, it tells you to re-run it with the IP.
|
||||
Once you have a working address, the `Host frame` alias means you just type
|
||||
`ssh frame`.
|
||||
- To check discovery yourself: `dns-sd -G v4 frame.local` (Ctrl-C to stop), or
|
||||
`dscacheutil -q host -a name frame.local`.
|
||||
- A DHCP reservation for the headset on your router makes the IP stable. That's
|
||||
the most reliable fallback.
|
||||
|
||||
## Key-based login (done by `scripts/connect.sh`)
|
||||
|
||||
```sh
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_frame -N '' -C "mac->steam-frame"
|
||||
ssh-copy-id -i ~/.ssh/id_ed25519_frame.pub steamos@frame.local
|
||||
```
|
||||
|
||||
`~/.ssh/config` block (managed between marker lines by the script):
|
||||
|
||||
```
|
||||
Host frame
|
||||
HostName frame.local
|
||||
User steamos
|
||||
IdentityFile ~/.ssh/id_ed25519_frame
|
||||
IdentitiesOnly yes
|
||||
ServerAliveInterval 30
|
||||
```
|
||||
|
||||
`~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS
|
||||
updates (inferred from Deck; the Frame uses the same A/B image scheme).
|
||||
|
||||
## Keeping `sshd` enabled across updates
|
||||
|
||||
- **Frame**: SSH is tied to the Developer Mode toggle, so it should survive
|
||||
updates as long as Developer Mode stays on. (Inferred: Valve doesn't say how
|
||||
the toggle is implemented.)
|
||||
- **Deck (for comparison)**: `systemctl enable sshd` usually persists because
|
||||
`/etc` is an overlay that survives updates. Changes under `/usr` do not.
|
||||
- Don't `pacman -S` anything you depend on for access. Packages installed into
|
||||
the read-only rootfs are **wiped by OS updates** on SteamOS. Use Flatpaks
|
||||
(`--user`) or `~/` for anything that needs to persist.
|
||||
|
||||
## Hardening (optional: `./scripts/connect.sh --harden`)
|
||||
|
||||
The script writes `/etc/ssh/sshd_config.d/01-frame-keys-only.conf` with
|
||||
`PasswordAuthentication no` and `KbdInteractiveAuthentication no`, then reloads
|
||||
`sshd`. First, it checks that key login works in BatchMode, so you can't lock
|
||||
yourself out.
|
||||
|
||||
- Needs `sudo` (Developer Mode password), entered on the **Mac**.
|
||||
- It assumes `/etc/ssh/sshd_config` includes `sshd_config.d/*.conf`, which is
|
||||
the Arch default. The script checks for this and stops if the include is
|
||||
missing.
|
||||
- `/etc` drop-ins normally persist across SteamOS updates (inferred from Deck).
|
||||
- It doesn't affect RDP (xrdp) or `sudo`, which still use the password.
|
||||
- Undo: `ssh frame 'sudo rm /etc/ssh/sshd_config.d/01-frame-keys-only.conf && sudo systemctl reload sshd'`.
|
||||
|
||||
## Other shells
|
||||
|
||||
- **ADB over USB-C** to the native Linux OS:
|
||||
`adb shell`. Plug the headset into the Mac. Valve notes that USB power may be
|
||||
insufficient. Install with `brew install android-platform-tools`. This is
|
||||
useful if Wi-Fi SSH is broken.
|
||||
(Confirmed (Frame): [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging))
|
||||
- **ADB over Wi-Fi** reaches the **Lepton (Android) container**, not Linux:
|
||||
`adb connect frame:5555`. It only works while "Lepton Development" or an
|
||||
Android app is running.
|
||||
(Confirmed (Frame): [adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton))
|
||||
- **RDP**: xrdp with user `steamos` and the Developer Mode password (see
|
||||
[streaming.md](streaming.md)).
|
||||
|
||||
## Fallback bootstrap one-liner
|
||||
|
||||
Use this only if the Developer Mode toggle doesn't give you SSH (for example,
|
||||
an OS build without it).
|
||||
|
||||
1. On the Mac: `./scripts/serve-bootstrap.sh`. It serves
|
||||
`bootstrap-on-frame.sh`, with your `~/.ssh/id_ed25519_frame.pub` embedded,
|
||||
on port 8765, and prints the exact one-liner.
|
||||
2. On the Frame's Linux desktop, open **Konsole** and type the printed line,
|
||||
roughly `curl -fsS mac.local:8765|bash` (~30 characters). If `mac.local`
|
||||
doesn't resolve, the script prints an IP form instead.
|
||||
3. The bootstrap installs the key into `~steamos/.ssh/authorized_keys`, and
|
||||
then runs `sudo systemctl enable --now sshd`. `sudo` asks for a password,
|
||||
and if none is set yet, it tells you to run `passwd` first. That means
|
||||
typing the password on the headset one more time.
|
||||
4. Stop the server on the Mac with Ctrl-C.
|
||||
|
||||
This is plain HTTP on your LAN, and it only serves a public key, so the
|
||||
content isn't secret. Anyone on the LAN who can spoof your Mac's address could
|
||||
serve a different script, though, so use it only on a trusted network.
|
||||
@@ -1,108 +0,0 @@
|
||||
# Installing and buying Steam games from the Mac
|
||||
|
||||
Frame Control's **Get games** section lists the games you own with each one's
|
||||
Steam Frame rating, installs them on the Frame, and searches the Steam store.
|
||||
This page covers how it works underneath, so you can do the same from a shell.
|
||||
|
||||
## How it works
|
||||
|
||||
The Frame's Steam client runs with `-cef-enable-debugging`. So its UI, a
|
||||
Chromium page, answers the Chrome DevTools protocol on the Frame's loopback,
|
||||
`127.0.0.1:8080`. The page titled **SharedJSContext** holds the client's own
|
||||
state and API:
|
||||
|
||||
| Object | What it gives you |
|
||||
|---|---|
|
||||
| `appStore.allApps` | Every app the account owns (868 games here), with `local_per_client_data.installed`, playtime, `vr_supported`/`vr_only` and `steam_hw_compat_category_packed` |
|
||||
| `downloadsStore.m_DownloadOverview` | A Map keyed by client ID. `"0"` is this machine: current app, percent, ETA, bytes/s |
|
||||
| `SteamClient.Installs.*` | The install wizard: `GetInstallManagerInfo`, `ContinueInstall`, `CancelInstall`, `OpenInstallWizard` |
|
||||
| `SteamClient.User.GetIPCountry()` | The store country (`AU` here), which store search needs |
|
||||
|
||||
`ui/frame_steam.py` is a stdlib-only WebSocket client for this page. It's piped
|
||||
over SSH like the other helpers:
|
||||
|
||||
```sh
|
||||
ssh frame 'python3 - owned' < ui/frame_steam.py # owned games + download
|
||||
ssh frame 'python3 - install 274190' < ui/frame_steam.py # install Broforce
|
||||
ssh frame 'python3 - store 1145360' < ui/frame_steam.py # store page in the headset
|
||||
```
|
||||
|
||||
The debugger port only listens on the Frame's loopback, so it's reachable over
|
||||
SSH and not from the network.
|
||||
|
||||
## Installing a game you own
|
||||
|
||||
`steam steam://install/<appid>`, run over SSH, hands the URL to the running
|
||||
client, which opens its install wizard. The wizard's state
|
||||
(`GetInstallManagerInfo().eInstallState`) then tells you what happens next:
|
||||
|
||||
| State | Meaning | What `frame_steam.py` does |
|
||||
|---|---|---|
|
||||
| 14 complete | Steam skipped the options dialog and queued the download | Reports "queued" |
|
||||
| 7 config | The options dialog is showing in the headset (library folder, compatibility note) | Calls `ContinueInstall()` when the game fits on disk, as the headset's Install button does |
|
||||
| 3, 4, 6, 8, 13 | Free license, CD key, password, EULA, signup | Leaves them for you to answer in the headset |
|
||||
| 15 failed | Error | Reports `errorDetail` |
|
||||
|
||||
**Verified 2026-09-25 (SteamOS 0.3.0, build 20260922.6101926):**
|
||||
|
||||
- Balatro (2379780, 67 MB) went straight to state 14 and installed in about 7 s,
|
||||
with nothing to answer in the headset.
|
||||
- Broforce (274190, 0.6 GB) stopped at state 7. Calling `ContinueInstall()` over
|
||||
DevTools queued the download, and the game installed.
|
||||
- Calling `SteamClient.Installs.OpenInstallWizard([appid])` directly did nothing:
|
||||
the state stayed at 0. Go through the `steam://install` URL instead.
|
||||
|
||||
**Inferred** from the client's JS: Steam skips the options dialog when there's
|
||||
one library folder, the game fits, and there's no compatibility note to show.
|
||||
Broforce is Deck "Playable", which probably explains why it stopped.
|
||||
|
||||
## Frame ratings
|
||||
|
||||
`steam_hw_compat_category_packed` holds two bits per device. The client decodes
|
||||
it like this (from `steamui/chunk~2dcc5aaf7.js`):
|
||||
|
||||
| Device | Bits |
|
||||
|---|---|
|
||||
| Steam Deck | `packed & 3` |
|
||||
| SteamOS | `packed >> 4 & 3` |
|
||||
| Steam Machine | `packed >> 6 & 3` |
|
||||
| **Steam Frame** | `packed >> 8 & 3` |
|
||||
|
||||
The values are 0 unknown, 1 unsupported, 2 playable and 3 verified. On
|
||||
2026-09-25 this library had 12 Frame Verified, 2 Playable, 6 Unsupported and 848
|
||||
Unknown games.
|
||||
|
||||
For games you don't own, the store's public
|
||||
`saleaction/ajaxgetdeckappcompatibilityreport?nAppID=<id>` returns
|
||||
`frame_resolved_category` on the same scale, along with `resolved_category`
|
||||
(Deck), `steamos_resolved_category` and `machine_resolved_category`. No key or
|
||||
login is needed.
|
||||
|
||||
## Buying
|
||||
|
||||
Frame Control doesn't buy anything. Purchases happen on Steam's own store page,
|
||||
signed in as you:
|
||||
|
||||
- **Buy on Steam ↗** opens `store.steampowered.com/app/<id>/` in the Mac's
|
||||
browser (the Electron app sends `target=_blank` links there).
|
||||
- **Store on Frame** runs `steam steam://store/<id>`, which opens the page in the
|
||||
Steam client on the headset. **Verified 2026-09-25:** a "Hades on Steam" page
|
||||
appeared in the DevTools page list. It wasn't visible in the headset capture
|
||||
because an app was in the foreground; it opens in Steam's dashboard.
|
||||
|
||||
After buying, press **Refresh** in Get games. The game shows up as owned, and
|
||||
**Install on Frame** installs it.
|
||||
|
||||
Store search uses `store.steampowered.com/api/storesearch/?term=…&cc=…`. It
|
||||
returns nothing without `cc`, so Frame Control takes the country from
|
||||
`SteamClient.User.GetIPCountry()` on the Frame.
|
||||
|
||||
## Not yet checked
|
||||
|
||||
- Free-to-play games: `steam://install` should stop at state 3 (free license)
|
||||
for you to accept in the headset. Not tried, because it adds a license to the
|
||||
account.
|
||||
- Games with a EULA (state 8).
|
||||
- Installing when there's more than one library folder, such as a microSD card.
|
||||
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
|
||||
headset.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Screen and desktop streaming
|
||||
|
||||
This covers two directions:
|
||||
|
||||
- **A. Frame → Mac**: see and control the headset from the Mac.
|
||||
- **B. Mac → Frame**: use the Mac's desktop inside the headset.
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
## A. See and control the Frame from the Mac
|
||||
|
||||
| Option | What you get | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| **Steam Link (macOS app) → `frame`** | A remote view of the headset | **Confirmed (Frame)**: Valve says to "use Steam Link on iOS, Android, or desktop to view the headset remotely by connecting to 'frame'" ([debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)) | Steam Link for macOS exists ([Tom's Guide](https://www.tomsguide.com/news/macbook-gaming-just-got-a-killer-upgrade-with-steam-link-heres-how-it-looks)). It's the lowest-effort option. Whether you get the VR view or a flat mirror, and whether input works, is unverified. |
|
||||
| **RDP to xrdp** | A separate Linux (Xorg) desktop session as `steamos` | **Confirmed (Frame)** for the server ([debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)); **Inferred** for the Mac client | On the Mac, use Microsoft **Windows App** (the old "Microsoft Remote Desktop") from the App Store. Add PC `frame.local` (or the IP), user `steamos`, and the Developer Mode password. Valve says Xorg is the default session. This is a *separate* X session, not a mirror of what's in the headset. It's good for running GUI apps and supports clipboard sync. |
|
||||
| **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. |
|
||||
| VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. |
|
||||
|
||||
**Recommendation for A:** start with Steam Link for macOS, because Valve
|
||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||
Mac with keyboard, mouse, and clipboard.
|
||||
|
||||
## B. Show the Mac's desktop inside the Frame
|
||||
|
||||
The Frame's streaming features are built around a **Windows PC running
|
||||
SteamVR** plus the USB Wi-Fi 6E dongle. Even Linux hosts had VR-streaming
|
||||
problems at launch
|
||||
([Steam discussion](https://steamcommunity.com/app/4165890/discussions/0/528765047224280796/),
|
||||
[gbl08ma](https://gbl08ma.com/posts/steam-frame-a-linux-machine-doesnt-support-linux/)).
|
||||
**macOS isn't a supported SteamVR host**, so for the Mac we're only looking at
|
||||
flat 2D desktop streaming into a window on the Frame's Linux desktop.
|
||||
|
||||
| Option | Setup | Confidence | Verdict |
|
||||
|---|---|---|---|
|
||||
| **macOS Screen Sharing (VNC) → Remmina on the Frame** | **Mac:** System Settings → General → Sharing → Screen Sharing on → (i) → enable "VNC viewers may control screen with password". **Frame:** `./scripts/install-apps.sh remmina` from the Mac, then open Remmina in the headset and connect to `vnc://<mac>.local` | **Inferred.** Remmina is on Flathub for **aarch64** with VNC and RDP ([Flathub](https://flathub.org/apps/org.remmina.Remmina)). The Frame desktop runs Flatpaks ([UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/)). macOS VNC is built in. | **Recommended.** Nothing to install on the Mac, and it's easy to set up. Latency is fine for productivity but not for games. You'll type the Mac's hostname once in Remmina on the headset, then save the profile. To avoid even that, the script can pre-seed a Remmina profile over SSH (see below). |
|
||||
| Sunshine (Mac) → Moonlight (Frame Flatpak) | `brew install` Sunshine on the Mac, then `./scripts/install-apps.sh moonlight` | Moonlight Flatpak supports **aarch64** ([Flathub](https://flathub.org/apps/com.moonlight_stream.Moonlight)). **Sunshine on macOS is poorly supported**: install problems on Apple Silicon/Sequoia, and no virtual gamepads ([LizardByte discussion #777](https://github.com/orgs/LizardByte/discussions/777)). | Try it if VNC is too laggy. Expect some friction. |
|
||||
| Steam Remote Play with the Mac as host | Steam on the Mac, Steam Link/Remote Play on the Frame | macOS-hosted Remote Play is reported broken or flaky in 2024–2026 ([Steam discussion](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/)) | Not recommended. It's only for games, if it works at all. |
|
||||
| Immersed / Virtual Desktop | Vendor apps | Immersed has a Mac agent but no known Frame client. Virtual Desktop's developer said he'd "try" to port it ([NewsBreak](https://www.newsbreak.com/news/4892834783961-virtual-desktop-dev-says-he-ll-try-to-bring-the-app-to-steam-frame)). | Not available as of 2026-09-25. Check again later. |
|
||||
| WiVRn / ALVR | VR streaming from a Linux or Windows PC | Irrelevant for a Mac host (no SteamVR/OpenXR runtime on macOS) | N/A |
|
||||
|
||||
For **VR video files** (180°/360° stereo), don't stream the Mac's screen. Play
|
||||
them on the Frame in DeoVR instead: see [vr-video.md](vr-video.md).
|
||||
|
||||
### Pre-seeding the Remmina profile (no typing in the headset)
|
||||
|
||||
`scripts/install-apps.sh remmina --vnc-host <your-mac>.local` writes
|
||||
`~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina` on the Frame over
|
||||
SSH. The profile then appears in Remmina's list, and you just click it. You'll
|
||||
still be asked for the VNC password in the headset the first time, unless you
|
||||
choose to save it. Remmina stores passwords encrypted with a per-install key,
|
||||
so the script doesn't try to write the password. (The Remmina file format is
|
||||
standard; the Flatpak data path is inferred.)
|
||||
|
||||
## Input and text entry without the virtual keyboard
|
||||
|
||||
- **A Bluetooth keyboard and mouse** paired to the Frame is the obvious way to
|
||||
avoid the virtual keyboard. Road to VR says there are "only a few things
|
||||
you'd actually want to do" on the Linux desktop unless you connect a
|
||||
keyboard and mouse.
|
||||
(Pairing a BT keyboard on the Frame is inferred from SteamOS; not verified.)
|
||||
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh` (see
|
||||
[file-transfer.md](file-transfer.md#clipboard)).
|
||||
- **RDP session**: Windows App syncs the clipboard with xrdp, but only inside
|
||||
that RDP session.
|
||||
@@ -1,91 +0,0 @@
|
||||
# Tailscale: use the Frame from anywhere
|
||||
|
||||
With Tailscale on the Frame, the `frame` SSH alias works off the home LAN, and
|
||||
so does everything built on it: Frame Control, the scripts and the Mac app.
|
||||
When the Mac and the Frame are on the same network, Tailscale connects them
|
||||
directly, so there's no relay in the way (`tailscale ping frame` → `via
|
||||
192.168.1.50:41641`, 20 ms).
|
||||
|
||||
```sh
|
||||
scripts/tailscale-on-frame.sh # install or update, then approve the login URL
|
||||
scripts/connect.sh frame.<tailnet>.ts.net # point the alias at Tailscale (the script prints this)
|
||||
scripts/tailscale-on-frame.sh --uninstall
|
||||
```
|
||||
|
||||
## How it's installed
|
||||
|
||||
There's no Tailscale Flatpak, and the rootfs is read-only. So the script
|
||||
installs Tailscale's static arm64 build in the `steamos` user's home and runs
|
||||
`tailscaled --tun=userspace-networking` as a systemd **user** service. It
|
||||
doesn't need sudo, and SteamOS updates don't touch it.
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `~/.local/share/tailscale/<version>/` | `tailscale`, `tailscaled` (SHA-256 checked against pkgs.tailscale.com) |
|
||||
| `~/.local/share/tailscale/current` | Symlink to the active version |
|
||||
| `~/.local/share/tailscale/state/` | Node key and state |
|
||||
| `~/.local/bin/tailscale` | CLI wrapper that points at the daemon's socket (`$XDG_RUNTIME_DIR/tailscale/tailscaled.sock`) |
|
||||
| `~/.config/systemd/user/tailscaled.service` | The service |
|
||||
|
||||
Lingering (`loginctl enable-linger`) is on, so the service starts at boot
|
||||
without anyone logging in. polkit allowed that without sudo. Re-running the
|
||||
script is safe. It restarts `tailscaled` only if the version or unit changed,
|
||||
and then does so detached after 3 s, because the SSH session may itself run
|
||||
over Tailscale.
|
||||
|
||||
To update, run the script again; it installs the latest stable version. To
|
||||
manage the node, use `ssh frame '~/.local/bin/tailscale status'` (or `set`,
|
||||
`down`, `up`).
|
||||
|
||||
**Verified 2026-09-25 (SteamOS 0.3.0, build 20260922.6101926, Tailscale
|
||||
1.102.4):**
|
||||
|
||||
- First install and login approval. This ran an earlier revision of the script,
|
||||
which restarted the daemon unconditionally. The node is `frame`,
|
||||
with a 100.x.y.z tailnet address.
|
||||
- SSH works over Tailscale: the Frame serves the same ED25519 host key as it
|
||||
does on `frame.local`.
|
||||
- Frame Control's status and Get games work through the alias.
|
||||
- The current script: a re-run with nothing changed doesn't restart anything,
|
||||
and a re-run with a changed unit restarts `tailscaled` 3 s after the SSH
|
||||
session ends and then reads `Running`. The timer needs
|
||||
`AccuracySec=100ms`; the default of 1 min made it fire up to a minute late.
|
||||
The first-install guard was checked on its own.
|
||||
|
||||
**Not verified:**
|
||||
|
||||
- A clean first install and login with the current script end to end. It would
|
||||
mean removing the node from the tailnet.
|
||||
- Reaching the Frame from outside the home network. Only the direct LAN path
|
||||
was tested.
|
||||
- The service coming up after a reboot. That's **inferred** from linger plus
|
||||
`WantedBy=default.target`; the Frame hasn't been rebooted since.
|
||||
|
||||
## Exposure: every port is on the tailnet
|
||||
|
||||
In userspace mode, `tailscaled` passes inbound tailnet connections to the
|
||||
Frame's **loopback**. Any device on the tailnet can therefore reach **every**
|
||||
listening port, including ones meant to be local-only. Checked from the Mac on
|
||||
2026-09-25:
|
||||
|
||||
| Port | Service | Normally |
|
||||
|---|---|---|
|
||||
| 22 | sshd | LAN |
|
||||
| 8080 | Steam client DevTools (full control of the Steam client and account session) | loopback only |
|
||||
| 27062 | SteamVR `vrserver` | loopback only |
|
||||
| 5555 | Lepton ADB (unauthenticated shell into Android) | LAN |
|
||||
| 3389 | xrdp | LAN |
|
||||
|
||||
The user accepted this on 2026-09-25, since the tailnet only holds their own
|
||||
devices. Other options:
|
||||
|
||||
- `tailscale set --shields-up` blocks **all** inbound connections. That
|
||||
includes SSH and Tailscale SSH (`--ssh`), both checked.
|
||||
- A tailnet policy that tags the Frame (`tag:frame`) and allows only
|
||||
`tag:frame:22` keeps the other ports private. This is an admin-console
|
||||
change.
|
||||
- Kernel-mode Tailscale (a root install, e.g. systemd-sysext) wouldn't expose
|
||||
loopback-only ports, but it needs sudo and may not survive SteamOS updates.
|
||||
|
||||
If the Mac's Tailscale is off, the alias won't resolve. Use
|
||||
`scripts/connect.sh frame.local` to go back to the LAN name.
|
||||
@@ -1,81 +0,0 @@
|
||||
# Watching VR video (180°/360°) on the Frame
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md). Everything here
|
||||
was checked on SteamOS 0.3.0, build 20260922.6101926.
|
||||
|
||||
## The short version
|
||||
|
||||
1. Install **DeoVR Video Player** from Steam (free, app 837380) and start it once
|
||||
in the headset. That creates its Proton prefix.
|
||||
2. On the Mac: `scripts/push-vr-video.sh --launch ~/Movies/beach_180_LR.mp4`
|
||||
3. In the headset, open DeoVR's local file browser → **Videos → VR** and
|
||||
pick the file.
|
||||
|
||||
## Why DeoVR
|
||||
|
||||
The Frame's Chromium can't play VR video in 3D. It has no immersive WebXR
|
||||
([how-the-frame-works.md](how-the-frame-works.md)). DeoVR's Windows build
|
||||
runs under Proton ARM64 + FEX as a real SteamVR app. **Verified 2026-09-25:**
|
||||
it found the Steam Frame headset and controller over OpenVR, and decoded
|
||||
7680×3840 and 8192×4096 H.265 VR180 side-by-side streams through AVPro's
|
||||
hardware Media Foundation path, mapped onto a 180° dome or fisheye mesh.
|
||||
|
||||
Known quirks (verified):
|
||||
|
||||
- The first launch takes about 45 s while it compiles shaders.
|
||||
- Grid thumbnails stay blank. Unity's own video player, which DeoVR uses for
|
||||
previews, fails with `0xc00d36bb` under Proton. Full playback uses AVPro
|
||||
and isn't affected.
|
||||
- The in-app store and web content are separate from local files. You don't
|
||||
need an account to play your own files.
|
||||
|
||||
## Getting files onto the headset
|
||||
|
||||
`scripts/push-vr-video.sh` copies files with `rsync --partial`, so an
|
||||
interrupted upload resumes. They go to `~/Videos/VR` on the Frame (`/home`
|
||||
has about 860 GB free). The script also links that folder into DeoVR's prefix
|
||||
as `C:\users\steamuser\Videos\VR`. It's reachable at
|
||||
`Z:\home\steamos\Videos\VR` as well. **Verified** that the upload and link
|
||||
work. **Not yet checked** whether DeoVR's file browser lands there
|
||||
(open question 21).
|
||||
|
||||
Speed: a test upload over Wi-Fi ran at about 3–5 MB/s (verified 2026-09-25,
|
||||
one sample). At that rate an 8K file of several GB takes tens of minutes, so
|
||||
start big uploads before you put the headset on.
|
||||
|
||||
## Naming files so they play correctly
|
||||
|
||||
DeoVR guesses the projection from the file name. Its binary contains the tags
|
||||
`_180`, `_360`, `_fisheye`, `_fisheye190`, `_mkx200`, `_vrca220` and `_rf52`
|
||||
(verified). For stereo layout, the common DeoVR convention is `_LR`/`_SBS`
|
||||
(side by side) and `_TB` (top/bottom) (inferred). If a video looks wrong
|
||||
(doubled, warped, or flat), change the projection and stereo mode in DeoVR's
|
||||
player menu.
|
||||
|
||||
Examples: `trip_180_LR.mp4`, `concert_360_TB.mp4`, `hike_fisheye190_LR.mp4`.
|
||||
|
||||
Codecs: H.265 at 8K played (verified, streamed). Local H.264 and H.265
|
||||
files haven't been played yet. The test clips below cover that.
|
||||
|
||||
## Test clips
|
||||
|
||||
The script doesn't include these. To check a setup, make two 20 s clips:
|
||||
3840×1920, 180° side by side, with the left eye tinted red and the right eye
|
||||
cyan. In the headset each eye should see only its own colour. A single mixed
|
||||
colour means the stereo split is wrong.
|
||||
|
||||
```sh
|
||||
ffmpeg -f lavfi -i testsrc2=size=1920x1920:rate=30:duration=20 \
|
||||
-filter_complex "[0:v]split[a][b];[a]colorchannelmixer=rr=1:gg=0.3:bb=0.3[l];[b]colorchannelmixer=rr=0.3:gg=1:bb=1[r];[l][r]hstack" \
|
||||
-c:v libx264 -pix_fmt yuv420p -b:v 20M frame-test_180_LR_h264.mp4
|
||||
scripts/push-vr-video.sh frame-test_180_LR_h264.mp4
|
||||
```
|
||||
|
||||
## Streaming from the Mac instead of copying (untested)
|
||||
|
||||
DeoVR has a DLNA browser (the binary contains `Searching for DLNA
|
||||
devices...` and a UPnP ContentDirectory client). A DLNA server on the Mac
|
||||
should therefore appear in DeoVR without copying anything, for example
|
||||
`brew install rclone` then `rclone serve dlna ~/Movies/VR`. This is **inferred**, not
|
||||
tried. 8K VR video needs roughly 50–100 Mbit/s sustained, and the Wi-Fi
|
||||
sample above (about 30–40 Mbit/s) suggests copying first is the safer default.
|
||||
@@ -1,116 +0,0 @@
|
||||
# WebXR in Chromium on the Frame
|
||||
|
||||
Goal: open a web VR180 or 360 player (DeoVR and DL8 embeds, WebXR samples),
|
||||
press its VR button, and watch in 3D in the headset.
|
||||
|
||||
## Why Flathub Chromium can't
|
||||
|
||||
**Verified 2026-09-25** (Frame BUILD_ID 20260922.6101926, Flathub
|
||||
`org.chromium.Chromium` 154.0.8037.57 aarch64):
|
||||
|
||||
- `navigator.xr` exists, but `isSessionSupported("immersive-vr")` is `false`.
|
||||
Flags don't change that, and neither does `--force-webxr-runtime=openxr`
|
||||
with SteamVR exposed to the Flatpak.
|
||||
- The binary has no OpenXR loader: no `XR_RUNTIME_JSON`,
|
||||
`xrGetInstanceProcAddr` or `XR_LOADER_DEBUG` strings. The only OpenXR
|
||||
strings are the `chrome://flags` entries.
|
||||
|
||||
**Cause (verified against Chromium source, same date).** M154 is the first
|
||||
release that compiles OpenXR on Linux:
|
||||
|
||||
- `device/vr/buildflags/buildflags.gni` adds `is_linux` to `enable_openxr`.
|
||||
- Flathub's tarball sets `checkout_openxr = true`.
|
||||
- Flathub's GN args don't turn it off.
|
||||
|
||||
But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates
|
||||
the OpenXR device under `ENABLE_OPENXR && IS_WIN`. That's true on 154, 155 and
|
||||
`main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The
|
||||
rest of the Linux port is in two unmerged CLs (bug 506004811):
|
||||
|
||||
- [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736)
|
||||
runs the XR device service in a Linux sandbox that allows SteamVR's
|
||||
sockets, `/dev/shm` and `flock`.
|
||||
- [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979)
|
||||
wires the provider to `OpenXrPlatformHelperLinux`. `kOpenXR` stays off by
|
||||
default, so it needs `--enable-features=OpenXR`.
|
||||
|
||||
8132979 sits on top of 8441736, so fetching `refs/changes/79/8132979/<ps>`
|
||||
gets both.
|
||||
|
||||
**The Frame side is ready.** `~/.config/openxr/1/active_runtime.json` names
|
||||
SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The
|
||||
Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`).
|
||||
|
||||
## Building it
|
||||
|
||||
[`scripts/build-chromium-xr.sh`](../scripts/build-chromium-xr.sh)
|
||||
cross-compiles arm64 Linux Chromium on an x64 Linux host. It doesn't need
|
||||
sudo: the arm64 sysroot comes from Chromium's own script. It needs about
|
||||
90 GB of disk. It shallow-fetches the CL ref (patchset 44), runs
|
||||
`gclient sync --no-history`, installs the sysroot, applies one extra seccomp
|
||||
fix (below), builds `chrome` with `symbol_level=0` and proprietary codecs, and
|
||||
packs `chromium-xr-arm64.tar.xz` (about 145 MB, GPU libraries included).
|
||||
Progress is logged to `~/chromium-xr/stage`. The build aborts if `/` drops
|
||||
below 12 GB free.
|
||||
|
||||
First run, 2026-09-25, on a 12-core, 31 GB x64 Linux box: 9 h 33 min for
|
||||
94,835 steps, giving Chromium 156.0.8071.0. A rebuild after a one-file change
|
||||
takes under a minute, plus about 4 minutes to repack.
|
||||
|
||||
**The extra fix.** The CL's XR seccomp policy refuses `getsockopt`. SteamVR's
|
||||
client calls `getsockopt(SOL_SOCKET, SO_PEERCRED)` inside `xrCreateInstance`,
|
||||
so the XR process died with a seccomp crash (arm64 syscall 209). The script
|
||||
allows that one option.
|
||||
|
||||
## Running it on the Frame
|
||||
|
||||
[`scripts/chromium-xr.sh`](../scripts/chromium-xr.sh):
|
||||
|
||||
```sh
|
||||
BUILD_HOST=my-linux-box scripts/chromium-xr.sh install # your build host; scp, unpack to ~/chromium-xr
|
||||
scripts/chromium-xr.sh launch [URL] # its own VR panel, --enable-features=OpenXR
|
||||
scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr')
|
||||
```
|
||||
|
||||
It runs natively, not as a Flatpak. `launch` opens it as its own panel on
|
||||
gamescope's X display, the same way as [`panel-on-frame.sh`](panels.md), so
|
||||
the Plasma desktop doesn't need to be open. It uses its own profile
|
||||
(`~/.config/chromium-xr`) and DevTools on loopback port 9223, so it doesn't
|
||||
collide with the Flatpak's 9222. When a page enters VR, Chrome asks
|
||||
**Allow VR?** in the browser panel; choose *Allow this time* or *Allow while
|
||||
visiting the site*.
|
||||
|
||||
**Seccomp is off.** `launch` passes `--disable-seccomp-filter-sandbox`. With
|
||||
the XR seccomp policy on, SteamVR's client reads `/proc/self/status` through
|
||||
Chrome's file broker and gets the broker's pid. SteamVR then binds the app to
|
||||
the wrong process ("Unable to init path manager: VRInitError_Init_Internal")
|
||||
and `xrCreateInstance` fails. The broker can't answer `/proc/self` for another
|
||||
process, so fixing this needs a change in Chromium's broker client or in the
|
||||
CL. The namespace sandbox stays on, but seccomp is off for every process, so
|
||||
use this profile for VR sites rather than everyday browsing.
|
||||
|
||||
**Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10,
|
||||
this build):
|
||||
|
||||
- `isSessionSupported('immersive-vr')` is `true`. The WebXR samples page
|
||||
shows "VR support detected".
|
||||
- `requestSession('immersive-vr')` succeeds after the prompt. With a WebGL
|
||||
layer, the first XR frame has a viewer pose with 2 views and a
|
||||
2880 × 1440 framebuffer (1440 × 1440 per eye).
|
||||
- SteamVR moves the app from `VRApplication_OpenXRInstance` to
|
||||
`VRApplication_OpenXRScene` and gives it scene focus. `xrEndFrame` submits
|
||||
both projection views, and the compositor receives the 2880 × 1440 scene.
|
||||
- The OpenXR runtime uses Vulkan (`XR_KHR_vulkan_enable2`). Chromium's own GPU
|
||||
process uses ANGLE on GL, running on zink over Turnip Vulkan (Adreno 750);
|
||||
Chromium's Vulkan backend is off. That doesn't stop the session.
|
||||
- Unprivileged user namespaces work (`unshare -Ur true`), so the namespace
|
||||
sandbox runs without the setuid `chrome_sandbox`.
|
||||
|
||||
**Not verified yet:** nobody was wearing the headset during the test, so
|
||||
SteamVR kept it in standby. The session stayed at
|
||||
`XR_SESSION_STATE_SYNCHRONIZED` (the page saw `visibilityState: "hidden"`)
|
||||
and only the first frame ran. Still open:
|
||||
|
||||
- Whether the image shows up correctly in the headset, and at what frame rate.
|
||||
- Whether VR180 or 360 video players (DeoVR, DL8 embeds) play in 3D.
|
||||
- Controller and hand input in the session.
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Frame-side: run one Android app in its own persistent Lepton instance.
|
||||
# Copied into ~/Applications/Android/<package>/launch.sh by the Mac-side
|
||||
# installer, next to app.apk, instance.id and (for 2D apps) the empty
|
||||
# lepton-show-flatscreen marker. A non-Steam shortcut points at this file.
|
||||
#
|
||||
# Why not Lepton Development: it wipes every app it installed when it exits.
|
||||
# A "steamlaunch" context (SteamAppId set) keeps app data in
|
||||
# compatdata/<id>/internal across restarts and APK updates. Pattern from
|
||||
# frame/t3code/launch.sh; see docs/apks.md.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LEPTON="$HOME/.local/share/Steam/steamapps/common/Lepton/lepton"
|
||||
[[ -x "$LEPTON" ]] || { echo "Lepton isn't installed (Steam app 3056000)" >&2; exit 1; }
|
||||
for need in "$DIR/app.apk" "$DIR/instance.id"; do
|
||||
[[ -f "$need" ]] || { echo "launch.sh: missing $need" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# A number that isn't a real Steam app; it names this app's Lepton context.
|
||||
export SteamAppId="$(cat "$DIR/instance.id")"
|
||||
export STEAM_COMPAT_INSTALL_PATH="$DIR"
|
||||
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
|
||||
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
|
||||
export STEAM_COMPAT_SHADER_PATH="$HOME/.local/share/Steam/steamapps/shadercache/$SteamAppId"
|
||||
export STEAM_FOSSILIZE_DUMP_PATH="$STEAM_COMPAT_SHADER_PATH/fozpipelinesv6/steamapp_pipeline_cache"
|
||||
mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
|
||||
|
||||
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
|
||||
# and SSH don't have; give it its own session instead.
|
||||
export IS_PARENT=true
|
||||
exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk"
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame-side: manage non-Steam shortcuts through the Steam client's CEF debug
|
||||
port (127.0.0.1:8080, target SharedJSContext), without restarting Steam.
|
||||
Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
|
||||
|
||||
steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id
|
||||
steam_shortcuts.py list -> JSON [{appid, name, exe}]
|
||||
steam_shortcuts.py remove APPID
|
||||
"""
|
||||
import base64, json, os, socket, struct, sys, urllib.request
|
||||
|
||||
DEVTOOLS = 'http://127.0.0.1:8080/json'
|
||||
|
||||
|
||||
def target_ws():
|
||||
for t in json.load(urllib.request.urlopen(DEVTOOLS, timeout=5)):
|
||||
if t.get('title') == 'SharedJSContext':
|
||||
return t['webSocketDebuggerUrl']
|
||||
sys.exit('SharedJSContext not found: is the Steam client running?')
|
||||
|
||||
|
||||
class WS:
|
||||
"""Just enough RFC 6455 for one CDP request/response on loopback."""
|
||||
|
||||
def __init__(self, url):
|
||||
host_port, path = url[len('ws://'):].split('/', 1)
|
||||
host, port = host_port.split(':')
|
||||
self.s = socket.create_connection((host, int(port)), timeout=20)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n'
|
||||
f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n'
|
||||
'Sec-WebSocket-Version: 13\r\n\r\n').encode())
|
||||
buf = b''
|
||||
while b'\r\n\r\n' not in buf:
|
||||
buf += self.s.recv(4096)
|
||||
if b' 101 ' not in buf.split(b'\r\n', 1)[0]:
|
||||
sys.exit('websocket handshake failed')
|
||||
self.rest = buf.split(b'\r\n\r\n', 1)[1]
|
||||
|
||||
def _read(self, n):
|
||||
while len(self.rest) < n:
|
||||
chunk = self.s.recv(65536)
|
||||
if not chunk:
|
||||
raise EOFError
|
||||
self.rest += chunk
|
||||
out, self.rest = self.rest[:n], self.rest[n:]
|
||||
return out
|
||||
|
||||
def send(self, text):
|
||||
data = text.encode()
|
||||
mask = os.urandom(4)
|
||||
n = len(data)
|
||||
head = bytes([0x81]) + (bytes([0x80 | n]) if n < 126 else
|
||||
bytes([0x80 | 126]) + struct.pack('>H', n) if n < 65536 else
|
||||
bytes([0x80 | 127]) + struct.pack('>Q', n))
|
||||
self.s.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
|
||||
|
||||
def recv(self):
|
||||
msg = b''
|
||||
while True:
|
||||
b0, b1 = self._read(2)
|
||||
n = b1 & 0x7f
|
||||
if n == 126:
|
||||
n = struct.unpack('>H', self._read(2))[0]
|
||||
elif n == 127:
|
||||
n = struct.unpack('>Q', self._read(8))[0]
|
||||
msg += self._read(n)
|
||||
if b0 & 0x80:
|
||||
return msg.decode()
|
||||
|
||||
|
||||
def evaluate(js):
|
||||
ws = WS(target_ws())
|
||||
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
|
||||
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
|
||||
while True:
|
||||
r = json.loads(ws.recv())
|
||||
if r.get('id') == 1:
|
||||
break
|
||||
res = r.get('result', {})
|
||||
if 'exceptionDetails' in res:
|
||||
sys.exit('JS error: ' + json.dumps(res['exceptionDetails'])[:500])
|
||||
return res.get('result', {}).get('value')
|
||||
|
||||
|
||||
def main():
|
||||
cmd, args = sys.argv[1], sys.argv[2:]
|
||||
if cmd == 'add':
|
||||
name, exe, start_dir = args[:3]
|
||||
icon = args[3] if len(args) > 3 else ''
|
||||
js = f'''(async () => {{
|
||||
const id = await SteamClient.Apps.AddShortcut({json.dumps(name)}, {json.dumps(exe)}, "", "");
|
||||
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
|
||||
SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
|
||||
if ({json.dumps(icon)}) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
|
||||
return id;
|
||||
}})()'''
|
||||
print(evaluate(js))
|
||||
elif cmd == 'list':
|
||||
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
|
||||
.map(a => ({appid: a.appid, name: a.display_name})))()'''
|
||||
print(json.dumps(evaluate(js)))
|
||||
elif cmd == 'remove':
|
||||
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
|
||||
print('removed')
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Frame-side: run T3 Code in its own Lepton (Android) instance whose data
|
||||
# survives restarts. Lepton Development wipes its apps when it exits; a
|
||||
# "steamlaunch" context (SteamAppId set) keeps them.
|
||||
#
|
||||
# Lives in ~/Applications/T3Code next to t3code.apk and the empty
|
||||
# lepton-show-flatscreen marker (without it Lepton runs the app headless).
|
||||
#
|
||||
# App data (T3's pairing) lives in compatdata/<id>/internal and survives
|
||||
# everything. Lepton rebuilds its Android system snapshot (compatdata/<id>/baked)
|
||||
# when the APK changes or the app exits within 30 seconds of starting.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LEPTON="$HOME/.local/share/Steam/steamapps/common/Lepton/lepton"
|
||||
|
||||
# Without the marker Lepton runs T3 headless: Steam says "running" but no panel
|
||||
# appears. Fail loudly instead.
|
||||
for need in "$DIR/t3code.apk" "$DIR/lepton-show-flatscreen"; do
|
||||
[[ -f "$need" ]] || { echo "launch.sh: missing $need" >&2; exit 1; }
|
||||
done
|
||||
[[ -x "$LEPTON" ]] || { echo "launch.sh: Lepton not installed at $LEPTON" >&2; exit 1; }
|
||||
|
||||
# Any fixed number that isn't a real Steam app: it names the Lepton context.
|
||||
export SteamAppId=2873873873
|
||||
export STEAM_COMPAT_INSTALL_PATH="$DIR"
|
||||
# Must sit under ~/.local/share/Steam: Lepton symlinks /data/data/<app> and
|
||||
# /data/media/0 to host paths here, and only the Steam dir is mounted inside.
|
||||
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
|
||||
# A Steam shortcut launch sets STEAM_FOSSILIZE_DUMP_PATH but not the shader
|
||||
# path Lepton derives it from (unbound under `set -u`), so set both.
|
||||
export STEAM_COMPAT_SHADER_PATH="$HOME/.local/share/Steam/steamapps/shadercache/$SteamAppId"
|
||||
export STEAM_FOSSILIZE_DUMP_PATH="$STEAM_COMPAT_SHADER_PATH/fozpipelinesv6/steamapp_pipeline_cache"
|
||||
mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
|
||||
|
||||
# Lepton re-execs itself through `setpgid --foreground`, which needs a
|
||||
# controlling terminal that Steam shortcuts and systemd don't have. Skip that
|
||||
# step and give Lepton its own session instead: its teardown kills its whole
|
||||
# process group. --wait keeps this script alive so Steam sees the app running.
|
||||
export IS_PARENT=true
|
||||
exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/t3code.apk"
|
||||
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 791 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 710 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 623 KiB |
|
After Width: | Height: | Size: 607 KiB |
|
After Width: | Height: | Size: 598 KiB |
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: refresh the Android app catalogue that Frame Control shows
|
||||
# (apk-catalog/). Fetches the latest F-Droid index, scans new or updated APKs
|
||||
# with HTTP range requests, and rebuilds apk-catalog/site/apps.js.
|
||||
# Frame Control picks up the new data on its next page load.
|
||||
#
|
||||
# Usage: scripts/apk-catalog.sh (the first full scan takes about an hour;
|
||||
# later runs only scan what changed)
|
||||
set -euo pipefail
|
||||
|
||||
CAT="${0:A:h}/../apk-catalog"
|
||||
[[ "${1:-}" == -h || "${1:-}" == --help ]] && { sed -n '2,8p' "$0"; exit 0; }
|
||||
|
||||
print "==> Fetching the F-Droid index"
|
||||
curl -fL --progress-bar -o "$CAT/data/index-v2.json.part" https://f-droid.org/repo/index-v2.json
|
||||
mv "$CAT/data/index-v2.json.part" "$CAT/data/index-v2.json"
|
||||
print "==> Scanning new or updated APKs"
|
||||
WORKERS=40 python3 "$CAT/scan.py"
|
||||
WORKERS=40 python3 "$CAT/scan2.py"
|
||||
print "==> Rebuilding"
|
||||
python3 "$CAT/build.py"
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Runs ON the Steam Frame (fallback path only; normally Developer Mode's
|
||||
# toggle + "Set User Password" is enough and this is not needed).
|
||||
# Served by scripts/serve-bootstrap.sh, which substitutes the public key.
|
||||
#
|
||||
# UNTESTED against real hardware. Idempotent.
|
||||
set -eu
|
||||
|
||||
KEY='__PUBKEY__'
|
||||
|
||||
mkdir -p "$HOME/.ssh"
|
||||
chmod 700 "$HOME/.ssh"
|
||||
touch "$HOME/.ssh/authorized_keys"
|
||||
chmod 600 "$HOME/.ssh/authorized_keys"
|
||||
if grep -qxF "$KEY" "$HOME/.ssh/authorized_keys"; then
|
||||
echo "key already present"
|
||||
else
|
||||
echo "$KEY" >> "$HOME/.ssh/authorized_keys"
|
||||
echo "key added"
|
||||
fi
|
||||
|
||||
echo "Enabling sshd. If sudo asks for a password you never set, press Ctrl-C,"
|
||||
echo "set one in Steam Settings > Developer > Set User Password (or run: passwd),"
|
||||
echo "then re-run the same one-liner."
|
||||
sudo systemctl enable --now sshd
|
||||
|
||||
echo
|
||||
echo "sshd: $(systemctl is-active sshd) user: $(id -un) host: $(hostname)"
|
||||
ip -4 -brief addr show scope global 2>/dev/null || true
|
||||
echo "Now on the Mac: scripts/connect.sh"
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
|
||||
# (8441736 + 8132979, bug 506004811), plus a one-option seccomp fix, so WebXR
|
||||
# immersive-vr works on the Frame.
|
||||
# Needs ~90 GB free, no sudo. Takes hours; run it detached on the build host:
|
||||
# scp scripts/build-chromium-xr.sh buildhost:chromium-xr/build.sh
|
||||
# ssh buildhost 'cd ~/chromium-xr && tmux new -d -s chromium-xr "./build.sh > build.log 2>&1"'
|
||||
# Progress: ~/chromium-xr/stage. Output: ~/chromium-xr/chromium-xr-arm64.tar.xz,
|
||||
# which scripts/chromium-xr.sh install copies to the Frame.
|
||||
# Re-running resumes: existing checkout and out/XR are reused.
|
||||
set -euo pipefail
|
||||
W=~/chromium-xr
|
||||
cd "$W"
|
||||
stage(){ echo "$(date -Is) $*" | tee -a "$W/stage"; }
|
||||
# Returns non-zero below 12 GB free; set -e turns that into an exit at top level.
|
||||
guard(){ avail=$(df --output=avail -BG "$W" | tail -n 1 | tr -dc 0-9); if [ "$avail" -lt 12 ]; then stage "ABORT: only ${avail}G free for $W"; return 3; fi; }
|
||||
[ -d depot_tools ] || git clone -q https://chromium.googlesource.com/chromium/tools/depot_tools.git
|
||||
export PATH="$W/depot_tools:$PATH" DEPOT_TOOLS_UPDATE=1 DEPOT_TOOLS_METRICS=0
|
||||
CL_REF=refs/changes/79/8132979/44
|
||||
if [ ! -f .gclient ]; then
|
||||
cat > .gclient <<'G'
|
||||
solutions = [{ "name": "src", "url": "https://chromium.googlesource.com/chromium/src.git",
|
||||
"managed": False, "custom_deps": {}, "custom_vars": { "checkout_nacl": False } }]
|
||||
target_os = ["linux"]
|
||||
target_cpu = ["arm64"]
|
||||
G
|
||||
fi
|
||||
# Keyed on a real commit, so an interrupted first fetch is retried on re-run.
|
||||
if ! git -C src rev-parse -q --verify HEAD >/dev/null 2>&1; then
|
||||
stage "clone src at $CL_REF"
|
||||
mkdir -p src
|
||||
[ -d src/.git ] || git -C src init -q
|
||||
git -C src remote get-url origin >/dev/null 2>&1 || git -C src remote add origin https://chromium.googlesource.com/chromium/src.git
|
||||
git -C src fetch -q --depth=1 origin "$CL_REF"
|
||||
git -C src checkout -q FETCH_HEAD
|
||||
fi
|
||||
guard
|
||||
stage "src at $(git -C src log -1 --format='%h %s')"
|
||||
stage "gclient sync"
|
||||
gclient sync --nohooks --no-history -D --shallow --revision "src@$(git -C src rev-parse HEAD)" -j 8
|
||||
guard
|
||||
stage "runhooks"
|
||||
gclient runhooks
|
||||
src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64
|
||||
guard
|
||||
cd src
|
||||
# CL 8441736's XR seccomp policy refuses getsockopt, and SteamVR's IPC client
|
||||
# calls getsockopt(SO_PEERCRED) inside xrCreateInstance, which crashes the XR
|
||||
# process (verified on the Frame 2026-09-26). Allow only that option.
|
||||
IFS= read -r -d '' PEERCRED_PATCH <<'P' || true
|
||||
diff --git a/sandbox/policy/linux/bpf_xr_policy_linux.cc b/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||
index 435e13d396..297453f582 100644
|
||||
--- a/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||
+++ b/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "sandbox/linux/system_headers/linux_syscalls.h"
|
||||
#include "sandbox/policy/linux/sandbox_linux.h"
|
||||
|
||||
+using sandbox::bpf_dsl::AllOf;
|
||||
using sandbox::bpf_dsl::Allow;
|
||||
using sandbox::bpf_dsl::Arg;
|
||||
using sandbox::bpf_dsl::Error;
|
||||
@@ -27,8 +28,8 @@ XrProcessPolicy::~XrProcessPolicy() = default;
|
||||
ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
|
||||
switch (system_call_number) {
|
||||
// The runtime reaches its compositor over an AF_UNIX socket and passes fds
|
||||
- // with SCM_RIGHTS, neither of which the GPU policy allows. get/setsockopt
|
||||
- // stay disallowed; add a narrow level/optname restriction if ever needed.
|
||||
+ // with SCM_RIGHTS, neither of which the GPU policy allows. setsockopt
|
||||
+ // stays disallowed; getsockopt is limited to SO_PEERCRED below.
|
||||
#if defined(__NR_getpeername)
|
||||
case __NR_getpeername:
|
||||
#endif
|
||||
@@ -49,6 +50,16 @@ ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
|
||||
case __NR_get_robust_list:
|
||||
#endif
|
||||
return Allow();
|
||||
+#if defined(__NR_getsockopt)
|
||||
+ case __NR_getsockopt: {
|
||||
+ // SteamVR's IPC client checks who is on the other end of its socket
|
||||
+ // with SO_PEERCRED. Nothing else is readable.
|
||||
+ const Arg<int> level(1);
|
||||
+ const Arg<int> optname(2);
|
||||
+ return If(AllOf(level == SOL_SOCKET, optname == SO_PEERCRED), Allow())
|
||||
+ .Else(Error(EPERM));
|
||||
+ }
|
||||
+#endif
|
||||
#if defined(__NR_kill)
|
||||
case __NR_kill: {
|
||||
// SteamVR probes its sibling processes for liveness with kill(pid, 0).
|
||||
P
|
||||
if ! printf '%s\n' "$PEERCRED_PATCH" | git apply --reverse --check 2>/dev/null; then
|
||||
printf '%s\n' "$PEERCRED_PATCH" | git apply
|
||||
stage "applied SO_PEERCRED patch"
|
||||
fi
|
||||
mkdir -p out/XR
|
||||
cat > out/XR/args.gn <<'A'
|
||||
target_os = "linux"
|
||||
target_cpu = "arm64"
|
||||
is_debug = false
|
||||
is_official_build = false
|
||||
is_component_build = false
|
||||
dcheck_always_on = false
|
||||
symbol_level = 0
|
||||
blink_symbol_level = 0
|
||||
v8_symbol_level = 0
|
||||
proprietary_codecs = true
|
||||
ffmpeg_branding = "Chrome"
|
||||
use_remoteexec = false
|
||||
use_siso = true
|
||||
treat_warnings_as_errors = false
|
||||
A
|
||||
stage "gn gen"
|
||||
gn gen out/XR
|
||||
gn args out/XR --list=enable_openxr --short | tee -a "$W/stage"
|
||||
stage "build"
|
||||
( while sleep 600; do guard || { pkill -u "$(id -u)" -f "siso|ninja"; exit 3; }; done ) &
|
||||
GUARD=$!
|
||||
trap 'kill $GUARD 2>/dev/null || true' EXIT
|
||||
autoninja -C out/XR chrome chrome_sandbox chrome_crashpad_handler
|
||||
stage "package"
|
||||
cd out/XR
|
||||
files=(chrome chrome_sandbox chrome_crashpad_handler *.pak *.bin icudtl.dat locales)
|
||||
# GPU libraries aren't produced by every config; pack the ones that exist.
|
||||
for f in libEGL.so libGLESv2.so libvk_swiftshader.so libvulkan.so.1 vk_swiftshader_icd.json; do
|
||||
[ -e "$f" ] && files+=("$f")
|
||||
done
|
||||
tar -cJf "$W/chromium-xr-arm64.tar.xz" "${files[@]}"
|
||||
stage "DONE $(ls -la $W/chromium-xr-arm64.tar.xz)"
|
||||