mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-27 12:01:55 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aefb5adc07 |
No files matched your search
@@ -225,6 +225,23 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
|
||||
- **Tracking only works with the headset worn.** SSH/headless launches never reach VISIBLE/FOCUSED and poses have
|
||||
flags 0x3. So automated tests prove startup (process alive, instance/session created, frames paced), never visuals.
|
||||
|
||||
**Setup / pairing (2026-10-02, verified on the device):** Developer Mode = `"DevModeEnabled"` in
|
||||
`~/.local/share/Steam/config/config.vdf` (InstallConfigStore/developer), applied by Valve's
|
||||
`/usr/bin/steamos-polkit-helpers/steamos-devkit-mode --enable|--disable` (polkit allow_any: no password; enables/
|
||||
disables sshd, xrdp, steamos-devkit-service, debug port forwards; sentinel `/etc/steamos-devkit-enabled`). Steam
|
||||
re-asserts the config value at every start and rewrites config.vdf on exit → stop Steam, edit, helper, start.
|
||||
**Desktop Mode is a nested Plasma inside steam.service** (own XDG_RUNTIME_DIR `/run/user/1000/nested_plasma` + private
|
||||
D-Bus): `systemd-run --user` from Konsole fails ("Failed to connect to user scope bus") unless XDG_RUNTIME_DIR/
|
||||
DBUS_SESSION_BUS_ADDRESS point at `/run/user/$UID`, and stopping Steam ends the desktop and everything started in
|
||||
it → bootstrap.sh runs the Dev Mode job + Lepton request + `/paired` as a user unit (log `~/.cache/frameport-setup.log`);
|
||||
no sudo/password anywhere (verified: naive Frame → connected, password never set). Valve's devkit pairing (fallback,
|
||||
PC → Frame only): `POST :32000/register` with an **ssh-rsa** key (`connection.devkit_key`, `frame/devkit.py`) works only
|
||||
while Steam is in pairing mode (Settings → Developer → Pair new host), else 403 at once; approve hook waits 30 s.
|
||||
The Frame runs firewalld (22 and 32000 open). PC side: the setup server needs inbound TCP 8765–8767 — WSL's Hyper-V
|
||||
firewall blocks it silently (`DefaultInboundAction Block`): `pairing.ensure_reachable` adds a temporary rule via one
|
||||
UAC prompt, removed when the server stops (flag file in %TEMP%, max 35 min); hints per OS after 45 s without a request
|
||||
(`pairing.firewall_hint`). Flet 1.0 patches aren't thread-safe → `app.serialize_flet_updates()` (a dialog shown while a
|
||||
scan redraw ran never closed: "dropped a patch for unknown control").
|
||||
**Lepton:** needs an activity with category **LAUNCHER** (Quest apps often only have INFO → "APP_ACTIVITY is empty").
|
||||
**2D apps:** Lepton runs every app headless (`lepton.headless=true`, only OpenXR output reaches the headset) unless the app folder (`<base>/lepton-app/`) has a `lepton-show-flatscreen` file (liblepton/app_metadata.sh) → Waydroid window on gamescope; agent v28 `set_flatscreen` at finalize for `vr_kind == "none"`. Android 11's navbar covered the
|
||||
app's controls → patch `device.hide_navbar` (default on for vr_kind none, migration `flat_hide_navbar`) exports
|
||||
|
||||
@@ -17,6 +17,36 @@ FramePort is **not a piracy tool**. It does not download, share or unlock games,
|
||||
entitlement checks. Use it only with games you own. Games that check their licence through the Oculus Platform SDK are
|
||||
marked as not runnable on the Frame.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Painless setup, no root.** One command in the Frame's Konsole connects it: it turns on Developer Mode with Valve's
|
||||
own helper and lets FramePort in. No `sudo`, no password, no packages, and SteamOS's read-only system stays
|
||||
untouched. Nothing is installed system-wide on your computer either.
|
||||
- **One click from game backup to Steam library.** FramePort converts, patches, signs, checks, uploads and adds the
|
||||
game to the Frame's Steam library with full artwork, then runs a short launch test and reads the logs for known
|
||||
problems.
|
||||
- **Knows what each game needs.** Recipes come from a catalog of games tested on the Frame, plus detection rules for
|
||||
everything else. Every patch is explained in plain words, with **Show technical details** for the exact change.
|
||||
- **Fills in what the Frame lacks.** FramePort's own OpenXR adapter (FrameBridge) emulates what Quest games expect
|
||||
and the Frame doesn't provide: passthrough, a room for mixed-reality games, controller models, curved screens,
|
||||
360° video layers. **Game settings** (sharpness, refresh rate, controllers, menus) are switches and sliders,
|
||||
shown only when they matter for that game.
|
||||
- **Long queues that finish.** Installs queue up and keep the Frame awake. If the Frame sleeps or drops off the
|
||||
Wi-Fi, the queue pauses and resumes, and uploads continue where they stopped. A USB cable or the Frame's own
|
||||
hotspot is used automatically for faster uploads.
|
||||
- **Your files stay yours.** Your game files are never modified: the converted copy is temporary. Each game keeps
|
||||
its own signing key, so updates keep your saves.
|
||||
- **Looks at home in Steam.** Store artwork, descriptions and genres become Steam art and tags. Apps without store
|
||||
art get generated artwork from their icon.
|
||||
- **More than Quest games.** Ordinary Android apps open as windows in the headset, with Android's navigation bar
|
||||
hidden. PC VR games run through Proton on the Frame or through Revive and SteamVR on your PC. The **Files** tab
|
||||
sends videos and other files into the Frame's shared folders or a game's own storage, with drag and drop.
|
||||
- **Easy to keep current and to report.** Signed releases update themselves. Diagnostics are redacted (no IP
|
||||
addresses, user names or paths), and problem reports and working configs open as prefilled GitHub issues. The
|
||||
interface is ready for translation.
|
||||
|
||||
What runs, and how well, is listed in [What works](#what-works).
|
||||
|
||||
## Getting started
|
||||
|
||||
1. **Download** the archive for your computer from the
|
||||
@@ -32,9 +62,11 @@ marked as not runnable on the Frame.
|
||||
macOS → right-click the app → **Open**. Details: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
2. **Get the tools**: on first start FramePort downloads what it needs (Java runtime, OVRPort, apksigner) into its own
|
||||
folder. Nothing is installed system-wide.
|
||||
3. **Connect the Frame**: turn on Developer Mode on the Frame (Settings → System → Developer). In FramePort open
|
||||
**Steam Frame**; if the Frame isn't listed, switch it to Desktop mode, open Konsole and paste the one command
|
||||
FramePort shows. This is needed once.
|
||||
3. **Connect the Frame**: in FramePort open **Steam Frame** → **Show setup command**, then on the Frame switch to
|
||||
Desktop mode, open Konsole and type that one short command. It turns on Developer Mode and lets FramePort in:
|
||||
no root, no `sudo`, no password. This is needed once. Everything it changes is listed in
|
||||
[What FramePort changes on the Frame](#what-frameport-changes-on-the-frame); firewall notes are in
|
||||
[Network and firewalls](#network-and-firewalls).
|
||||
4. **Add games**: **Add games → Scan a folder** with your game backups: Android/Quest games (APK + OBB) or PC VR
|
||||
game folders.
|
||||
FramePort identifies each game and fetches its artwork and store details.
|
||||
@@ -53,6 +85,55 @@ reality), showing only what matters for that game. Changes are kept with the gam
|
||||
|
||||
FramePort updates itself: when a new version is released, the Library shows **Update now**.
|
||||
|
||||
### Network and firewalls
|
||||
|
||||
During first-time setup the Frame downloads the setup script from FramePort on your computer: TCP ports 8765–8767,
|
||||
open only while the setup command is shown and for at most 30 minutes. After that, every connection goes from your
|
||||
computer to the Frame (SSH), and nothing needs to reach your computer.
|
||||
|
||||
- **Windows:** allow FramePort when Windows asks (Private networks). If Windows treats your network as Public, allow
|
||||
Public too or switch the network to Private.
|
||||
- **macOS:** if the macOS firewall is on, allow incoming connections when it asks.
|
||||
- **Linux:** if firewalld or ufw blocks the port, FramePort shows the command that opens it (firewalld: until the next
|
||||
restart).
|
||||
- **WSL:** FramePort adds a temporary Hyper-V firewall rule (one admin prompt) and removes it when setup is done. WSL
|
||||
needs mirrored networking (`networkingMode=mirrored` in `.wslconfig`).
|
||||
- If nothing reaches FramePort within about 45 seconds, the setup page says what is likely blocking it on your system.
|
||||
- **No inbound connection at all:** turn on Developer Mode in the Frame's settings first. The Frame then appears under
|
||||
**On your network**. On the Frame open Settings → Developer → **Pair new host**, click **Connect** in FramePort and
|
||||
approve it (Valve's own devkit pairing). FramePort sets up the rest over SSH.
|
||||
|
||||
## What FramePort changes on the Frame
|
||||
|
||||
The setup command (step 3) runs [`bootstrap/bootstrap.sh`](bootstrap/bootstrap.sh), served by the app over your
|
||||
local network. Everything it changes:
|
||||
|
||||
| Change | Where | How to undo |
|
||||
|---|---|---|
|
||||
| Turns on **Developer Mode** (only if it's off). Steam restarts once, which closes Desktop Mode; the rest of the setup finishes on its own as a user service (log: `~/.cache/frameport-setup.log`). | `"DevModeEnabled" "1"` in `~/.local/share/Steam/config/config.vdf` (old file kept as `config.vdf.before-frameport`), then Valve's own `steamos-polkit-helpers/steamos-devkit-mode --enable`. That helper enables the SSH server (`sshd`), the devkit service that makes the Frame findable on the network, the remote-desktop and debug services, and system crash dumps. | Settings → System → Developer → Developer Mode off. Valve's helper switches all of those services off again. |
|
||||
| Lets the app's SSH key in. | One line ending in `frameport` in `~/.ssh/authorized_keys`. The folder and file are created if missing. | Delete that line. |
|
||||
| Configures podman for Lepton. Rootless podman leaks one kernel keyring per container start, and after about 200 game starts every game fails. | `[containers]` / `keyring = false` in `~/.config/containers/containers.conf`. | Remove those lines. |
|
||||
| Asks Steam to install **Lepton** (Valve's Android runtime, Steam app 3029110) if it's missing. You confirm it in Steam. | Steam library | Uninstall it in Steam. |
|
||||
|
||||
The script runs as your user: no root, no `sudo`, no password. The only system-level change, Developer Mode, is
|
||||
made by Valve's own helper, the same one the Settings switch uses. If Developer Mode can't be turned on
|
||||
automatically, the script asks you to turn it on in Settings → System → Developer and run the command again.
|
||||
|
||||
Nothing else on the system is touched: no packages, no read-only-filesystem changes, no polkit rules. The script
|
||||
also leaves two files: `~/.cache/frameport-setup.sh` (the part that runs on its own) and its log.
|
||||
|
||||
**Later, the app adds** (all as your user, no root, no `sudo`):
|
||||
- FramePort's helper in `~/.local/share/frameport/`;
|
||||
- the games, each with a launcher and its data in `~/Applications/quest-frame/<package>/`;
|
||||
- their Steam library entries and artwork (`shortcuts.vdf` + `config/grid/`);
|
||||
- for PC VR games: an OpenXR layer (`~/.local/share/openxr/1/api_layers/explicit.d/XR_APILAYER_FRAMEPORT_timefix.json`)
|
||||
and, when the first PC VR game is installed, Valve's ARM64 Proton and its Steam Linux Runtime (Steam downloads them;
|
||||
Steam restarts once).
|
||||
|
||||
**Settings → Uninstall FramePort → Also remove from the Frame** deletes the games, their Steam entries, the helper
|
||||
folder, the OpenXR layer and the setup script's files. Developer Mode, the SSH key line, the podman setting, Lepton
|
||||
and Proton stay. Undo them as shown above or in Steam.
|
||||
|
||||
## What works
|
||||
|
||||
The built-in catalog has tested settings for 38 games (27 work, 5 work with known issues, 6 can't run on the Frame).
|
||||
|
||||
+51
-12
@@ -33,7 +33,7 @@ import sys
|
||||
import time
|
||||
import zlib
|
||||
|
||||
AGENT_VERSION = 30
|
||||
AGENT_VERSION = 31
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -761,6 +761,28 @@ def backup_vdf(vdf_path):
|
||||
pass
|
||||
|
||||
|
||||
def remove_tree(path):
|
||||
"""Remove a file or folder tree, including what Lepton's containers leave in a game's data: overlayfs work dirs
|
||||
with mode 000 (and whiteout device files in them), which shutil.rmtree(ignore_errors=True) silently skipped, so
|
||||
"remove saves" left lepton-data behind. Last resort: podman unshare (files owned by the container's user ids)."""
|
||||
if not os.path.lexists(path):
|
||||
return
|
||||
if os.path.islink(path) or not os.path.isdir(path):
|
||||
os.remove(path)
|
||||
return
|
||||
for root, dirs, _files in os.walk(path): # top-down: fix a folder's mode before walking into it
|
||||
for d in dirs:
|
||||
p = os.path.join(root, d)
|
||||
if not os.path.islink(p):
|
||||
try:
|
||||
os.chmod(p, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
if os.path.lexists(path) and shutil.which("podman"):
|
||||
run(["podman", "unshare", "rm", "-rf", path])
|
||||
|
||||
|
||||
def grid_files(grid, appid):
|
||||
"""A shortcut's grid artwork (<appid>p.jpg, <appid>_hero.png, …): exact names, never another appid that merely
|
||||
starts with the same digits."""
|
||||
@@ -1887,22 +1909,22 @@ def cmd_uninstall(args):
|
||||
for name in names:
|
||||
p = os.path.join(base, name)
|
||||
if os.path.isdir(p):
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
remove_tree(p)
|
||||
elif os.path.exists(p):
|
||||
os.remove(p)
|
||||
if not keep_data:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
remove_tree(base)
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
removed_sc = False
|
||||
if args.get("remove_shortcut") and len(steam_users()) == 1:
|
||||
# Steam keeps its own copy of shortcuts.vdf and writes it back: change it only with Steam closed (worker)
|
||||
removed_sc = cmd_shortcuts({"remove": [{"exe": f'"{anchor}/launch.sh"', "appid": dep.get("appid")}]})["started"]
|
||||
if not keep_data or base != anchor:
|
||||
shutil.rmtree(anchor, ignore_errors=True)
|
||||
remove_tree(anchor)
|
||||
else: # saves live next to the launcher (Quest games): keep them, drop what marks the game as installed
|
||||
for name in ("deployment.json", "launch.sh", "artwork", "launch.log", "launch-test.log"):
|
||||
p = os.path.join(anchor, name)
|
||||
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else (os.path.exists(p) and os.remove(p))
|
||||
remove_tree(p)
|
||||
return {"removed": True, "kept_saves": keep_data, "shortcut_removed": removed_sc}
|
||||
|
||||
|
||||
@@ -2183,6 +2205,16 @@ def purge_worker(payload):
|
||||
os.remove(art)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{d.get('title')}: {exc}")
|
||||
try: # shortcuts left from games without an install record (older versions, interrupted removals)
|
||||
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
|
||||
for sc in list(root.get("shortcuts", {}).values()):
|
||||
exe = sc.get("Exe", "") if isinstance(sc, dict) else ""
|
||||
if exe.startswith(f'"{ANCHORS}/') and remove_shortcut(vdf, exe):
|
||||
result["removed"].append(f"Steam shortcut: {sc.get('AppName') or sc.get('appname')}")
|
||||
for art in grid_files(grid, sc.get("appid", 0) & 0xFFFFFFFF):
|
||||
os.remove(art)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"shortcuts: {exc}")
|
||||
for d in games:
|
||||
base = d["base"]
|
||||
saves = [os.path.join(base, n) for n in ("lepton-data", "compatdata")]
|
||||
@@ -2190,23 +2222,30 @@ def purge_worker(payload):
|
||||
for name in os.listdir(base) if os.path.isdir(base) else []:
|
||||
p = os.path.join(base, name)
|
||||
if p not in saves:
|
||||
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else os.remove(p)
|
||||
remove_tree(p)
|
||||
result["kept"].append(base)
|
||||
else:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
remove_tree(base)
|
||||
if not keep and os.path.lexists(base):
|
||||
result["errors"].append(f"couldn't remove {base}")
|
||||
result["removed"].append(d.get("title") or d["package"])
|
||||
if not keep or not result["kept"]:
|
||||
shutil.rmtree(ANCHORS, ignore_errors=True)
|
||||
remove_tree(ANCHORS)
|
||||
else: # anchors hold only launchers/artwork; saves live in the bases
|
||||
for d in games:
|
||||
anchor = os.path.join(ANCHORS, d["package"])
|
||||
if os.path.realpath(anchor) not in [os.path.realpath(k) for k in result["kept"]]:
|
||||
shutil.rmtree(anchor, ignore_errors=True)
|
||||
shutil.rmtree(AGENT_HOME, ignore_errors=True)
|
||||
remove_tree(anchor)
|
||||
remove_tree(AGENT_HOME)
|
||||
result["removed"].append(AGENT_HOME)
|
||||
if os.path.exists(XR_LAYER_MANIFEST):
|
||||
os.remove(XR_LAYER_MANIFEST)
|
||||
result["removed"].append(XR_LAYER_MANIFEST)
|
||||
for name in ("frameport-setup.sh", "frameport-setup.log"): # left by bootstrap.sh
|
||||
p = os.path.join(HOME, ".cache", name)
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
result["removed"].append(p)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["state"] = "failed"
|
||||
result["errors"].append(str(exc))
|
||||
@@ -2235,7 +2274,7 @@ def cmd_cleanup(args):
|
||||
p = os.path.join(base, name)
|
||||
if os.path.isdir(p):
|
||||
freed += sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(p) for f in fs)
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
remove_tree(p)
|
||||
removed.append(p)
|
||||
elif os.path.exists(p):
|
||||
freed += os.path.getsize(p)
|
||||
@@ -2250,7 +2289,7 @@ def cmd_cleanup(args):
|
||||
if os.path.exists(p):
|
||||
freed += (sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(p) for f in fs)
|
||||
if os.path.isdir(p) else os.path.getsize(p))
|
||||
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else os.remove(p)
|
||||
remove_tree(p)
|
||||
removed.append(p)
|
||||
return {"removed": removed, "freed_bytes": freed}
|
||||
|
||||
|
||||
+109
-35
@@ -1,59 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
# FramePort one-time Steam Frame setup. Run in the Frame's Desktop Mode terminal (Konsole):
|
||||
# curl -fsSL http://<pc>:<port>/bootstrap.sh | bash
|
||||
# curl -fsS <pc-ip>:<port>/<code> | bash
|
||||
# (the FramePort app shows the exact line and serves this script while its Setup page is open).
|
||||
#
|
||||
# What it does (asks before anything that needs sudo):
|
||||
# 1. sets a password for the 'steamos' user if it has none (sudo needs one)
|
||||
# 2. enables the SSH server (sshd)
|
||||
# 3. authorizes the FramePort app's SSH key (no password needed afterwards)
|
||||
# 4. announces this Frame on the local network (avahi service "_frameport._tcp") so the app finds it
|
||||
# 5. checks for Valve's Lepton Android runtime and asks Steam to install it if missing
|
||||
# What it does (no password, no sudo):
|
||||
# 1. authorizes the FramePort app's SSH key (no password needed afterwards)
|
||||
# 2. configures podman for Lepton (avoids a kernel keyring leak after ~200 game starts)
|
||||
# 3. turns on Developer Mode if it's off (Valve's own helper; it also enables the SSH server and announces the
|
||||
# Frame on the network as a SteamOS devkit). Steam restarts for that, which closes Desktop Mode: the rest
|
||||
# runs on its own as a user service and the Frame returns to its normal view.
|
||||
# 4. asks Steam to install Valve's Lepton Android runtime if it's missing, then tells the app it's done
|
||||
# Log of the part that runs on its own: ~/.cache/frameport-setup.log
|
||||
set -euo pipefail
|
||||
PC_URL="__PC_URL__" # filled in by the app when serving the script
|
||||
PAIR_CODE="__PAIR_CODE__"
|
||||
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
|
||||
|
||||
DEVKIT_HELPER=/usr/bin/steamos-polkit-helpers/steamos-devkit-mode
|
||||
STEAM_CONFIG=~/.local/share/Steam/config/config.vdf
|
||||
JOB=~/.cache/frameport-setup.sh
|
||||
LOG=~/.cache/frameport-setup.log
|
||||
|
||||
# Desktop Mode on SteamOS is a nested Plasma session inside steam.service, with its own XDG_RUNTIME_DIR and D-Bus:
|
||||
# systemctl/systemd-run --user only reach the user's systemd through the real runtime dir.
|
||||
user_systemd() {
|
||||
XDG_RUNTIME_DIR="/run/user/$(id -u)" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus" "$@"
|
||||
}
|
||||
|
||||
say "FramePort setup for $(hostname) ($(. /etc/os-release; echo "$NAME $VERSION_ID"))"
|
||||
|
||||
if ! passwd -S "$USER" 2>/dev/null | grep -q ' P '; then
|
||||
say "Your user has no password yet. SteamOS needs one for sudo (and as an SSH fallback)."
|
||||
passwd
|
||||
fi
|
||||
|
||||
say "Enabling the SSH server (sudo)"
|
||||
sudo systemctl enable --now sshd
|
||||
|
||||
say "Authorizing the FramePort app's key"
|
||||
key=$(curl -fsSL "$PC_URL/key?code=$PAIR_CODE")
|
||||
key=$(curl -fsS "$PC_URL/key?code=$PAIR_CODE")
|
||||
[[ "$key" == ssh-ed25519\ * ]] || { echo "Could not fetch the app's key from $PC_URL (is the app still open?)"; exit 1; }
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
|
||||
grep -qxF "$key" ~/.ssh/authorized_keys || echo "$key" >> ~/.ssh/authorized_keys
|
||||
|
||||
say "Announcing this Frame on the network (avahi)"
|
||||
svc="<?xml version=\"1.0\" standalone='no'?>
|
||||
<!DOCTYPE service-group SYSTEM \"avahi-service.dtd\">
|
||||
<service-group>
|
||||
<name replace-wildcards=\"yes\">FramePort on %h</name>
|
||||
<service><type>_frameport._tcp</type><port>22</port><txt-record>user=$USER</txt-record><txt-record>pair=$PAIR_CODE</txt-record></service>
|
||||
</service-group>"
|
||||
echo "$svc" | sudo tee /etc/avahi/services/frameport.service >/dev/null || true
|
||||
sudo systemctl enable --now avahi-daemon >/dev/null 2>&1 || true
|
||||
sudo systemctl reload avahi-daemon >/dev/null 2>&1 || true
|
||||
|
||||
say "Configuring podman for Lepton"
|
||||
# rootless podman leaks one kernel keyring per container start; ~200 game launches would exhaust the quota
|
||||
mkdir -p ~/.config/containers
|
||||
grep -qs '^ *keyring *=' ~/.config/containers/containers.conf || printf '[containers]\nkeyring = false\n' >> ~/.config/containers/containers.conf
|
||||
|
||||
say "Checking for Lepton (Valve's Android runtime)"
|
||||
if ls ~/.local/share/Steam/steamapps/common/Lepton/lepton >/dev/null 2>&1; then
|
||||
echo "Lepton found."
|
||||
# The rest: Developer Mode (if needed), Lepton, telling the app. As a file, so it can run as its own user service:
|
||||
# stopping Steam ends Desktop Mode and every program started in it, this terminal included.
|
||||
mkdir -p ~/.cache
|
||||
cat >"$JOB" <<'JOB'
|
||||
set -u
|
||||
MODE=$1 PC_URL=$2 PAIR_CODE=$3 CONFIG=$4 HELPER=$5
|
||||
log() { echo "$(date +%T) $*"; }
|
||||
if [[ $MODE == devmode ]]; then
|
||||
sleep 5 # let the user read the terminal before Desktop Mode closes
|
||||
# Steam re-applies "DevModeEnabled" from config.vdf at every start (calling Valve's helper) and rewrites
|
||||
# config.vdf when it exits: stop Steam, set the value, run the helper (pkexec, allowed without a password), start.
|
||||
log "stopping Steam"
|
||||
systemctl --user stop steam.service
|
||||
for _ in $(seq 40); do pgrep -x steam >/dev/null || break; sleep 1; done
|
||||
if pgrep -x steam >/dev/null; then
|
||||
log "Steam didn't stop"
|
||||
systemctl --user start steam.service
|
||||
exit 1
|
||||
fi
|
||||
cp "$CONFIG" "$CONFIG.before-frameport"
|
||||
python3 - "$CONFIG" <<'PY'
|
||||
import re, sys
|
||||
path = sys.argv[1]
|
||||
text = open(path, encoding="utf-8").read()
|
||||
if re.search(r'"DevModeEnabled"\s+"\d+"', text):
|
||||
text = re.sub(r'("DevModeEnabled"\s+)"\d+"', r'\1"1"', text)
|
||||
else: # Steam keeps it in InstallConfigStore/developer
|
||||
lines, keys, key, at = text.splitlines(keepends=True), [], None, None
|
||||
for i, line in enumerate(lines):
|
||||
s = line.strip()
|
||||
if s == "{":
|
||||
keys.append((key or "").lower())
|
||||
if keys == ["installconfigstore", "developer"]:
|
||||
at = ("in", i, line[:len(line) - len(line.lstrip())] + "\t")
|
||||
break
|
||||
elif s == "}":
|
||||
if keys == ["installconfigstore"]: # end of the top section without a developer block
|
||||
at = ("new", i, line[:len(line) - len(line.lstrip())] + "\t")
|
||||
break
|
||||
keys.pop()
|
||||
elif re.fullmatch(r'"[^"]*"', s):
|
||||
key = s.strip('"')
|
||||
if at is None:
|
||||
sys.exit("unexpected config.vdf layout")
|
||||
kind, i, ind = at
|
||||
if kind == "in":
|
||||
lines.insert(i + 1, f'{ind}"DevModeEnabled"\t\t"1"\n')
|
||||
else:
|
||||
lines[i:i] = [f'{ind}"developer"\n', f"{ind}{{\n", f'{ind}\t"DevModeEnabled"\t\t"1"\n', f"{ind}}}\n"]
|
||||
text = "".join(lines)
|
||||
open(path, "w", encoding="utf-8").write(text)
|
||||
PY
|
||||
rc=$?
|
||||
[[ $rc == 0 ]] && { "$HELPER" --enable; rc=$?; }
|
||||
log "Developer Mode: $([[ $rc == 0 && -f /etc/steamos-devkit-enabled ]] && echo on || echo "failed ($rc)")"
|
||||
systemctl --user start steam.service
|
||||
[[ $rc == 0 ]] || exit 1
|
||||
for _ in $(seq 90); do pgrep -x steam >/dev/null && break; sleep 1; done
|
||||
sleep 20 # let Steam finish starting before asking it for anything
|
||||
fi
|
||||
if [[ -e ~/.local/share/Steam/steamapps/common/Lepton/lepton ]]; then
|
||||
log "Lepton found"
|
||||
else
|
||||
echo "Lepton is missing. Make sure Developer Mode is on (Settings > System > Developer)."
|
||||
echo "Asking Steam to install it now; confirm in Steam, or launch 'Lepton Development' from your library once."
|
||||
(steam steam://install/3029110 >/dev/null 2>&1 &) || true
|
||||
log "asking Steam to install Lepton (confirm it in Steam)"
|
||||
steam steam://install/3029110 >/dev/null 2>&1 &
|
||||
fi
|
||||
if curl -fsS "$PC_URL/paired?code=$PAIR_CODE&user=$(id -un)&host=$(hostname)" >/dev/null; then
|
||||
log "told FramePort: done"
|
||||
else
|
||||
log "couldn't reach FramePort at $PC_URL"
|
||||
fi
|
||||
JOB
|
||||
|
||||
if [[ -f /etc/steamos-devkit-enabled ]]; then
|
||||
say "Developer Mode is on"
|
||||
bash "$JOB" finish "$PC_URL" "$PAIR_CODE" "$STEAM_CONFIG" "$DEVKIT_HELPER" 2>&1 | tee "$LOG"
|
||||
say "Done. Return to FramePort on your PC: this Frame should now appear as connected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl -fsS "$PC_URL/paired?code=$PAIR_CODE&user=$USER&host=$(hostname)" >/dev/null 2>&1 || true
|
||||
say "Done. Return to FramePort on your PC: this Frame should now appear as connected."
|
||||
say "Turning on Developer Mode"
|
||||
if [[ ! -x "$DEVKIT_HELPER" || ! -f "$STEAM_CONFIG" ]] || \
|
||||
! user_systemd systemd-run --user --collect --quiet --unit="frameport-setup-$$" \
|
||||
bash -c 'bash "$0" "$@" >"$HOME/.cache/frameport-setup.log" 2>&1' \
|
||||
"$JOB" devmode "$PC_URL" "$PAIR_CODE" "$STEAM_CONFIG" "$DEVKIT_HELPER"; then
|
||||
echo "Couldn't turn it on automatically. Turn it on in Settings > System > Developer, then run this command again."
|
||||
exit 1
|
||||
fi
|
||||
echo "Steam restarts to turn it on. That closes Desktop Mode in a few seconds and the Frame returns to its"
|
||||
echo "normal view; setup finishes on its own. If Steam asks to install Lepton, confirm it."
|
||||
echo "Then return to FramePort on your PC: the Frame appears as connected within a minute."
|
||||
+34
-6
@@ -26,12 +26,40 @@ start shows a warning:
|
||||
|
||||
## Connecting the Steam Frame
|
||||
|
||||
1. On the Frame: Settings → System → Developer → turn on **Developer Mode**. The Frame and the computer must be on the
|
||||
same network.
|
||||
2. In FramePort open **Steam Frame**. A Frame in Developer Mode appears in the list.
|
||||
3. First time only: on the Frame switch to Desktop mode (Steam button → Power → Switch to Desktop), open Konsole and
|
||||
run the command FramePort shows. It enables SSH, authorises this computer and installs Valve's Android runtime
|
||||
(Lepton) if needed.
|
||||
The Frame and the computer must be on the same network.
|
||||
|
||||
1. In FramePort open **Steam Frame** and click **Show setup command**.
|
||||
2. First time only: on the Frame switch to Desktop mode (Steam button → Power → Switch to Desktop), open Konsole and
|
||||
run the command FramePort shows. It authorises this computer, turns on **Developer Mode** (which includes
|
||||
SSH) and asks Steam to install Valve's Android runtime (Lepton) if needed: confirm that download. Turning on
|
||||
Developer Mode restarts Steam, which closes Desktop Mode and returns to the normal view; the setup finishes on its
|
||||
own. No password is needed. The app connects by itself when it's done. The full list of changes is in the
|
||||
[README](../README.md#what-frameport-changes-on-the-frame).
|
||||
3. Later starts: a Frame in Developer Mode appears in the list and FramePort connects to it automatically. (If you
|
||||
turn Developer Mode off in Settings → System → Developer, turn it on again there.)
|
||||
|
||||
**Without Konsole:** turn on Developer Mode yourself (Settings → System → Developer). The Frame then appears under
|
||||
**On your network**. On the Frame open Settings → Developer → **Pair new host**, then click **Connect** in FramePort
|
||||
and approve it on the Frame (Valve's own devkit pairing; it only sends this computer's key to the Frame). Install Lepton from the Steam Frame page afterwards if it's missing.
|
||||
|
||||
### Firewalls
|
||||
|
||||
The setup command is the only time the Frame connects to your computer: it downloads the script from FramePort on
|
||||
TCP port 8765 (8766/8767 if taken), only while the setup command is shown and for at most 30 minutes. Everything
|
||||
else goes from the computer to the Frame. If the command just says "timed out", the setup page shows what is likely
|
||||
blocking it after about 45 seconds:
|
||||
|
||||
- **Windows:** allow FramePort (or Python, when running from source) when Windows asks. On a network Windows treats as
|
||||
**Public** it stays blocked unless you allow public networks; set your home network to Private in Windows'
|
||||
network settings instead.
|
||||
- **macOS:** with the firewall on (System Settings → Network → Firewall), allow incoming connections for FramePort
|
||||
when asked.
|
||||
- **Linux:** firewalld: `sudo firewall-cmd --add-port=8765/tcp` (until the next restart). ufw:
|
||||
`sudo ufw allow 8765/tcp`, afterwards `sudo ufw delete allow 8765/tcp`.
|
||||
- **WSL:** Windows' Hyper-V firewall blocks connections into WSL without asking. FramePort adds a temporary rule for
|
||||
the setup ports (one admin prompt) and removes it again when setup is done or after 35 minutes. WSL must use
|
||||
mirrored networking: `networkingMode=mirrored` under `[wsl2]` in `%UserProfile%\.wslconfig`, then `wsl --shutdown`.
|
||||
- Or skip the setup command and use the devkit pairing above: it needs no connection into your computer.
|
||||
|
||||
## Installing games
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.5.0"
|
||||
@@ -287,3 +287,93 @@ def oculus_platform_dir() -> Path | None:
|
||||
if (d / "LibOVRPlatform64_1.dll").is_file():
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ firewall (pairing)
|
||||
WSL_VM_CREATOR = "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" # Hyper-V firewall id of WSL's VM
|
||||
|
||||
|
||||
def powershell(command: str, timeout: float = 30) -> subprocess.CompletedProcess:
|
||||
"""Run Windows PowerShell 5.1 (from Windows or WSL). PSModulePath is dropped: inherited from PowerShell 7 it keeps
|
||||
5.1 from loading its own modules (see updates._powershell)."""
|
||||
if is_windows():
|
||||
exe = str(Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe")
|
||||
cwd = None
|
||||
else:
|
||||
exe = system32("WindowsPowerShell/v1.0/powershell.exe")
|
||||
cwd = "/mnt/c" if Path("/mnt/c").is_dir() else None # a \\wsl$ working dir makes Windows programs complain
|
||||
env = {k: v for k, v in os.environ.items() if k.upper() != "PSMODULEPATH"}
|
||||
return subprocess.run([exe, "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, text=True,
|
||||
errors="replace", timeout=timeout, cwd=cwd, env=env, **_no_window())
|
||||
|
||||
|
||||
def wsl_networking_mode() -> str | None:
|
||||
""""mirrored" or "nat" (WSL's default, where nothing on the network can reach WSL at all); None if unknown."""
|
||||
if not is_wsl():
|
||||
return None
|
||||
try:
|
||||
out = subprocess.run(["wslinfo", "--networking-mode"], capture_output=True, text=True, timeout=10).stdout
|
||||
if out.strip():
|
||||
return out.strip().lower()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def wsl_inbound_blocked(rule: str) -> bool:
|
||||
"""WSL with mirrored networking: Windows' Hyper-V firewall blocks connections from the network to WSL by default
|
||||
(no "Allow access?" prompt as for Windows programs), so a Frame's request to our pairing server just times out.
|
||||
True when that block is on and our allow rule `rule` isn't there."""
|
||||
if not is_wsl():
|
||||
return False
|
||||
try:
|
||||
out = powershell(
|
||||
f"$s = Get-NetFirewallHyperVVMSetting -PolicyStore ActiveStore -Name '{WSL_VM_CREATOR}';"
|
||||
f"$r = Get-NetFirewallHyperVRule -PolicyStore ActiveStore -Name '{rule}' -ErrorAction SilentlyContinue;"
|
||||
"\"$($s.Enabled) $($s.DefaultInboundAction) $([bool]$r)\"").stdout.split()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return out[:3] == ["True", "Block", "False"]
|
||||
|
||||
|
||||
def open_wsl_inbound(rule: str, title: str, ports: str, flag: Path, minutes: int = 35) -> bool:
|
||||
"""Let the network reach WSL on TCP `ports` only while `flag` exists (at most `minutes`): one admin prompt (UAC)
|
||||
starts a hidden elevated PowerShell that adds a Hyper-V firewall rule, waits, and removes the rule again (only if
|
||||
it added it). Returns True once the rule is in place."""
|
||||
import base64
|
||||
|
||||
script = f"""
|
||||
$name = '{rule}'
|
||||
$added = $false
|
||||
if (-not (Get-NetFirewallHyperVRule -PolicyStore ActiveStore -Name $name -ErrorAction SilentlyContinue)) {{
|
||||
New-NetFirewallHyperVRule -Name $name -DisplayName '{title}' -Direction Inbound -VMCreatorId '{WSL_VM_CREATOR}' `
|
||||
-Protocol TCP -LocalPorts {ports} -Action Allow | Out-Null
|
||||
$added = $true
|
||||
}}
|
||||
$end = (Get-Date).AddMinutes({minutes})
|
||||
while ((Test-Path -LiteralPath '{to_windows(flag)}') -and ((Get-Date) -lt $end)) {{ Start-Sleep -Seconds 2 }}
|
||||
if ($added) {{ Remove-NetFirewallHyperVRule -Name $name -ErrorAction SilentlyContinue }}
|
||||
"""
|
||||
encoded = base64.b64encode(script.encode("utf-16-le")).decode()
|
||||
try:
|
||||
powershell("Start-Process powershell -Verb RunAs -WindowStyle Hidden "
|
||||
f"-ArgumentList '-NoProfile','-NonInteractive','-EncodedCommand','{encoded}'", timeout=300)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
for _ in range(20): # the elevated script needs a moment to add the rule
|
||||
if not wsl_inbound_blocked(rule):
|
||||
return True
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def network_category(ip: str) -> str | None:
|
||||
"""Windows: the firewall profile of the network that has `ip` ("Public", "Private", "DomainAuthenticated")."""
|
||||
if not is_windows():
|
||||
return None
|
||||
try:
|
||||
out = powershell(f"(Get-NetConnectionProfile -InterfaceIndex (Get-NetIPAddress -IPAddress '{ip}' "
|
||||
"-ErrorAction Stop).InterfaceIndex).NetworkCategory").stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return out or None
|
||||
@@ -1,7 +1,8 @@
|
||||
"""SSH connection to a Steam Frame and the remote agent protocol.
|
||||
|
||||
Authentication: FramePort's own key (<user data>/ssh/id_ed25519, installed by the bootstrap script) first, then
|
||||
the user's SSH agent/keys, then a password if given.
|
||||
Authentication: FramePort's own keys first (<user data>/ssh/id_ed25519, installed by the bootstrap script, and
|
||||
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then the user's SSH agent/keys, then a
|
||||
password if given.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -45,6 +46,37 @@ def app_public_key() -> str:
|
||||
return (ssh_dir() / "id_ed25519.pub").read_text().strip()
|
||||
|
||||
|
||||
def devkit_key() -> paramiko.RSAKey:
|
||||
"""FramePort's RSA key for Valve's devkit pairing (frame/devkit.py): its service accepts only ssh-rsa keys."""
|
||||
path = ssh_dir() / "id_rsa"
|
||||
if not path.exists():
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
|
||||
data = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH,
|
||||
serialization.NoEncryption())
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
pub = key.public_key().public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH)
|
||||
(ssh_dir() / "id_rsa.pub").write_text(pub.decode() + " frameport\n")
|
||||
return paramiko.RSAKey.from_private_key_file(str(path))
|
||||
|
||||
|
||||
def devkit_public_key() -> str:
|
||||
devkit_key()
|
||||
return (ssh_dir() / "id_rsa.pub").read_text().strip()
|
||||
|
||||
|
||||
def app_keys() -> list[paramiko.PKey]:
|
||||
"""The keys FramePort logs in with: the Ed25519 key, plus the RSA key once devkit pairing created it."""
|
||||
keys: list[paramiko.PKey] = [app_key()]
|
||||
if (ssh_dir() / "id_rsa").exists():
|
||||
keys.append(devkit_key())
|
||||
return keys
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameTarget:
|
||||
host: str
|
||||
@@ -233,7 +265,8 @@ class Frame:
|
||||
kwargs = dict(hostname=self.target.host, port=self.target.port, username=self.target.user, timeout=timeout,
|
||||
banner_timeout=timeout, auth_timeout=timeout)
|
||||
errors = []
|
||||
for attempt in ("app_key", "agent", "password"):
|
||||
attempts = [("app_key", k) for k in app_keys()] + [("agent", None), ("password", None)]
|
||||
for attempt, pkey in attempts:
|
||||
if attempt == "password" and not self.password:
|
||||
continue
|
||||
client = paramiko.SSHClient() # a fresh client per attempt: a failed one keeps its transport otherwise
|
||||
@@ -241,7 +274,7 @@ class Frame:
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # trust on first use (pairing)
|
||||
try:
|
||||
if attempt == "app_key":
|
||||
client.connect(pkey=app_key(), allow_agent=False, look_for_keys=False, **kwargs)
|
||||
client.connect(pkey=pkey, allow_agent=False, look_for_keys=False, **kwargs)
|
||||
elif attempt == "agent":
|
||||
client.connect(allow_agent=True, look_for_keys=True, **kwargs)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Valve's devkit pairing: with Developer Mode on, the Frame's steamos-devkit-service (TCP 32000) accepts an SSH public
|
||||
key at POST /register while Steam is in pairing mode (Settings → Developer → Pair new host; otherwise it refuses at
|
||||
once), asks in Steam to approve it, and Valve's root hook installs the key for the steamos user and enables sshd.
|
||||
Every connection goes from the PC to the Frame, so no firewall on the PC is involved: the fallback when the Frame
|
||||
can't reach FramePort's setup server. The service only accepts ssh-rsa keys
|
||||
(connection.devkit_key)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .connection import devkit_public_key
|
||||
|
||||
PORT = 32000
|
||||
APPROVE_TIMEOUT = 30 # Valve's approve hook waits this long for the answer in the headset
|
||||
|
||||
|
||||
class PairingRefused(RuntimeError): # not a ConnectionError: the Frame is reachable, explain() keeps the message
|
||||
pass
|
||||
|
||||
|
||||
def available(host: str, timeout: float = 5) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://{host}:{PORT}/login-name", timeout=timeout) as r:
|
||||
return r.status == 200
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def register(host: str) -> None:
|
||||
"""Send FramePort's RSA key; returns once it was approved in the headset and installed, raises PairingRefused."""
|
||||
req = urllib.request.Request(f"http://{host}:{PORT}/register", data=devkit_public_key().encode(), method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=APPROVE_TIMEOUT + 30) as r:
|
||||
body = r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", "replace") # Valve's service mixes the hook's log lines into it
|
||||
m = re.search(r'"error":\s*"([^"]*)"', body)
|
||||
msg = (m.group(1) if m else (body.strip() or str(exc))).replace("\\n", " ").strip()
|
||||
if "pairing mode" in msg.lower(): # Steam takes requests only while "Pair new host" is open
|
||||
msg = "on the Frame open Settings → Developer → Pair new host first, then click Connect again"
|
||||
elif "timeout" in msg.lower():
|
||||
msg = f"it wasn't approved in the headset within {APPROVE_TIMEOUT} seconds"
|
||||
elif "steam is not running" in msg.lower():
|
||||
msg = "Steam isn't running on the Frame"
|
||||
raise PairingRefused(f"The Frame didn't pair: {msg}") from None
|
||||
except OSError as exc:
|
||||
raise PairingRefused(f"Couldn't reach the Frame's pairing service (Developer Mode on?): {exc}") from None
|
||||
if "Registered" not in body:
|
||||
raise PairingRefused(f"The Frame didn't pair: {body.strip()}")
|
||||
@@ -2,7 +2,9 @@
|
||||
the LAN. The user types one line on the Frame; the script calls back /paired so the UI knows who connected.
|
||||
|
||||
Every request needs the pairing code, a random secret that is only in that one line. The server stops after a
|
||||
successful pairing, after MAX_FAILURES wrong codes (someone guessing) and after LIFETIME seconds."""
|
||||
successful pairing, after MAX_FAILURES wrong codes (someone guessing) and after LIFETIME seconds, so a short code
|
||||
(32 bits) is plenty. The line is typed by hand on the Frame, so it's kept short: no scheme (curl defaults to http),
|
||||
the code is the path, and a fixed port when it's free."""
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
@@ -10,6 +12,7 @@ import secrets
|
||||
import threading
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.paths import bootstrap_dir
|
||||
from .connection import app_public_key
|
||||
@@ -17,15 +20,88 @@ from .discovery import local_ip_towards
|
||||
|
||||
MAX_FAILURES = 20
|
||||
LIFETIME = 30 * 60
|
||||
PORTS = (8765, 8766, 8767, 0) # 0 = any free port
|
||||
FIREWALL_RULE = "FramePort-Pairing"
|
||||
HINT_AFTER = 45 # seconds without any request from the Frame before the UI suggests what may block it
|
||||
|
||||
|
||||
def ensure_reachable(server: PairingServer) -> str:
|
||||
"""Under WSL, let the Frame reach the pairing ports through Windows' Hyper-V firewall while `server` runs (one
|
||||
admin prompt; the rule is removed again when the server stops, see winhost.open_wsl_inbound).
|
||||
Returns "ok" (nothing blocks), "opened" or "failed" (declined / no admin)."""
|
||||
from ..core import winhost
|
||||
|
||||
if not winhost.wsl_inbound_blocked(FIREWALL_RULE):
|
||||
return "ok"
|
||||
temp = winhost.env_path("TEMP")
|
||||
if temp is None:
|
||||
return "failed"
|
||||
server.flag = temp / f"frameport-pairing-{server.code}.flag"
|
||||
try:
|
||||
server.flag.write_text("FramePort's setup page is open: the firewall rule stays while this file exists\n")
|
||||
except OSError:
|
||||
return "failed"
|
||||
ports = f"{PORTS[0]}-{PORTS[-2]}"
|
||||
return "opened" if winhost.open_wsl_inbound(FIREWALL_RULE, "FramePort setup (WSL, temporary)", ports,
|
||||
server.flag) else "failed"
|
||||
|
||||
|
||||
def firewall_hint(port: int) -> str:
|
||||
"""What may keep the Frame from reaching this PC, for this OS (shown when no request came in after HINT_AFTER).
|
||||
Empty when nothing specific is known."""
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from ..core import winhost
|
||||
from ..i18n import tr
|
||||
|
||||
def out(*cmd) -> str:
|
||||
try:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.lower()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
if winhost.is_wsl():
|
||||
if winhost.wsl_networking_mode() == "nat":
|
||||
return tr("FramePort runs in WSL with its default NAT network, which other devices can't reach. Set "
|
||||
"networkingMode=mirrored under [wsl2] in %UserProfile%\\.wslconfig, run wsl --shutdown and "
|
||||
"start FramePort again.")
|
||||
if winhost.wsl_inbound_blocked(FIREWALL_RULE):
|
||||
return tr("Windows' firewall for WSL blocks the Frame. Open the setup command again and allow the "
|
||||
"change when Windows asks (admin).")
|
||||
return ""
|
||||
if winhost.is_windows():
|
||||
if winhost.network_category(local_ip_towards()) == "Public":
|
||||
return tr("Windows treats this network as public and its firewall blocks the Frame. In Windows' "
|
||||
"network settings set this network to Private (or allow FramePort on public networks when "
|
||||
"Windows asks).")
|
||||
return tr("If Windows asked whether FramePort may use the network, allow it (Private networks).")
|
||||
if platform.system() == "Darwin":
|
||||
fw = out("/usr/libexec/ApplicationFirewall/socketfilterfw", "--getglobalstate")
|
||||
if "enabled" in fw:
|
||||
return tr("macOS' firewall is on: allow incoming connections when macOS asks about FramePort (or "
|
||||
"System Settings → Network → Firewall → Options).")
|
||||
return ""
|
||||
if shutil.which("firewall-cmd") and "running" in out("firewall-cmd", "--state"):
|
||||
return tr("firewalld is active. Allow the setup port until the next restart with: "
|
||||
"sudo firewall-cmd --add-port={port}/tcp").format(port=port)
|
||||
if "active" == out("systemctl", "is-active", "ufw").strip():
|
||||
return tr("ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (and afterwards: "
|
||||
"sudo ufw delete allow {port}/tcp)").format(port=port)
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairingServer:
|
||||
port: int = 0
|
||||
code: str = field(default_factory=lambda: secrets.token_hex(8))
|
||||
code: str = field(default_factory=lambda: secrets.token_hex(4))
|
||||
failures: int = 0
|
||||
paired: list[dict] = field(default_factory=list)
|
||||
on_paired: object = None
|
||||
requests: int = 0 # requests from the network (right or wrong code): the Frame can reach us
|
||||
flag: Path | None = None # WSL: the temporary firewall rule stays while this file exists
|
||||
hint: str = "" # set by the UI when nothing reached us after HINT_AFTER seconds: what may block the Frame
|
||||
_httpd: http.server.ThreadingHTTPServer | None = None
|
||||
|
||||
@property
|
||||
@@ -34,7 +110,7 @@ class PairingServer:
|
||||
|
||||
@property
|
||||
def one_liner(self) -> str:
|
||||
return f"curl -fsSL {self.url}/bootstrap.sh?code={self.code} | bash"
|
||||
return f"curl -fsS {self.url.removeprefix('http://')}/{self.code} | bash"
|
||||
|
||||
def start(self) -> PairingServer:
|
||||
server = self
|
||||
@@ -51,20 +127,24 @@ class PairingServer:
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
server.requests += 1
|
||||
url = urllib.parse.urlparse(self.path)
|
||||
q = dict(urllib.parse.parse_qsl(url.query))
|
||||
if not secrets.compare_digest(q.get("code", ""), server.code):
|
||||
path, code = url.path, q.get("code", "")
|
||||
if not code and path.count("/") == 1: # the short form: /<code> = the script
|
||||
path, code = "/bootstrap.sh", path[1:]
|
||||
if not secrets.compare_digest(code, server.code):
|
||||
server.failures += 1
|
||||
if server.failures >= MAX_FAILURES:
|
||||
server.stop_soon()
|
||||
return self._send(b"wrong or missing pairing code\n", status=403)
|
||||
if url.path == "/bootstrap.sh":
|
||||
if path == "/bootstrap.sh":
|
||||
text = (bootstrap_dir() / "bootstrap.sh").read_text()
|
||||
text = text.replace("__PC_URL__", server.url).replace("__PAIR_CODE__", server.code)
|
||||
return self._send(text.encode(), "text/x-shellscript")
|
||||
if url.path == "/key":
|
||||
if path == "/key":
|
||||
return self._send((app_public_key() + "\n").encode())
|
||||
if url.path == "/paired":
|
||||
if path == "/paired":
|
||||
info = {"host": self.client_address[0], "user": q.get("user", "steamos"), "name": q.get("host", "")}
|
||||
server.paired.append(info)
|
||||
if callable(server.on_paired):
|
||||
@@ -73,7 +153,13 @@ class PairingServer:
|
||||
return self._send(b"ok\n")
|
||||
return self._send(b"not found\n", status=404)
|
||||
|
||||
self._httpd = http.server.ThreadingHTTPServer(("0.0.0.0", self.port), Handler)
|
||||
for port in ((self.port,) if self.port else PORTS):
|
||||
try:
|
||||
self._httpd = http.server.ThreadingHTTPServer(("0.0.0.0", port), Handler)
|
||||
break
|
||||
except OSError:
|
||||
if port == PORTS[-1]:
|
||||
raise
|
||||
self.port = self._httpd.server_address[1]
|
||||
threading.Thread(target=self._httpd.serve_forever, daemon=True).start()
|
||||
self._timer = threading.Timer(LIFETIME, self.stop)
|
||||
@@ -97,3 +183,5 @@ class PairingServer:
|
||||
timer = getattr(self, "_timer", None)
|
||||
if timer:
|
||||
timer.cancel()
|
||||
if self.flag is not None: # lets the temporary firewall rule go
|
||||
self.flag.unlink(missing_ok=True)
|
||||
@@ -147,6 +147,7 @@
|
||||
"Check for new versions automatically": "",
|
||||
"Check for updates": "",
|
||||
"Check game files": "",
|
||||
"Check that the Frame and this computer are on the same network.": "",
|
||||
"Checking this PC…": "",
|
||||
"Checking tools…": "",
|
||||
"Chooses which Proton version runs the game on the Frame.": "",
|
||||
@@ -207,7 +208,7 @@
|
||||
"Delete {names}?": "",
|
||||
"Deletes the rollback copies kept from each game's previous install and leftover uploads. The games and saves stay.": "",
|
||||
"Details": "",
|
||||
"Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on your network and connect to it over SSH.": "",
|
||||
"Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on your network and connect to it over SSH. The first-time setup command turns it on for you.": "",
|
||||
"Didn't finish ({value}): {error}": "",
|
||||
"Disable application space warp if used": "",
|
||||
"Disable controller tracking offset": "",
|
||||
@@ -266,6 +267,7 @@
|
||||
"Find artwork and store details": "",
|
||||
"Find artwork…": "",
|
||||
"Find automatically": "",
|
||||
"firewalld is active. Allow the setup port until the next restart with: sudo firewall-cmd --add-port={port}/tcp": "",
|
||||
"First-time setup": "",
|
||||
"Fix launcher icon entry": "",
|
||||
"Fix minimal Android SDK": "",
|
||||
@@ -298,6 +300,7 @@
|
||||
"FramePort installs games on the Frame over your network.": "",
|
||||
"FramePort manages its own copies; nothing is installed system-wide": "",
|
||||
"FramePort picked {value} to start this game, but there are other candidates. Check it before installing.": "",
|
||||
"FramePort runs in WSL with its default NAT network, which other devices can't reach. Set networkingMode=mirrored under [wsl2] in %UserProfile%\\.wslconfig, run wsl --shutdown and start FramePort again.": "",
|
||||
"FramePort saves a diagnostics zip (logs, recipe, device info; no game files, personal data removed) and opens a prefilled GitHub issue. Drag the zip into it, check the text, submit.": "",
|
||||
"FramePort updates and restarts after the current job": "",
|
||||
"FramePort was removed": "",
|
||||
@@ -360,6 +363,7 @@
|
||||
"How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" come from recipes tested on a real Frame. \"Untested\" means FramePort suggested a recipe from the game's engine and VR API that nobody has confirmed yet. \"Can't run\" means there's a known blocker (for example a 32-bit-only game).": "",
|
||||
"I played it in the headset with this recipe": "",
|
||||
"If the pointer doesn't hit what you aim at, tilt it up or down.": "",
|
||||
"If Windows asked whether FramePort may use the network, allow it (Private networks).": "",
|
||||
"If you know the Frame's address.": "",
|
||||
"In your Steam library": "",
|
||||
"In your Steam library · launch settings changed — update it": "",
|
||||
@@ -392,7 +396,7 @@
|
||||
"It has a known blocker on the Steam Frame.": "",
|
||||
"It has issues: share…": "",
|
||||
"it stopped at '{value}'": "",
|
||||
"It turns on SSH, trusts this app, makes the Frame findable on your network and installs Lepton if needed. You only do this once.": "",
|
||||
"It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on Developer Mode closes Desktop Mode; the setup finishes on its own and FramePort connects by itself. You only do this once.": "",
|
||||
"It was removed from the library.": "",
|
||||
"It works: share…": "",
|
||||
"Java runtime": "",
|
||||
@@ -439,6 +443,7 @@
|
||||
"Looking for your Frame…": "",
|
||||
"Looking up the store description and screenshots…": "",
|
||||
"Low": "",
|
||||
"macOS' firewall is on: allow incoming connections when macOS asks about FramePort (or System Settings → Network → Firewall → Options).": "",
|
||||
"Makes Meta's 3D audio library work outside Quest headsets.": "",
|
||||
"Makes sure the game has an entry that can be started.": "",
|
||||
"Makes the game start from the Frame's Steam library.": "",
|
||||
@@ -475,7 +480,7 @@
|
||||
"No activity": "",
|
||||
"No artwork could be downloaded from that {source} result; the current artwork stays. Try another one.": "",
|
||||
"No folders to rescan yet: add games with Scan a folder": "",
|
||||
"No Frames found. Turn on Developer Mode on the Frame (Settings → System → Developer) and make sure it's on the same network, or use first-time setup below.": "",
|
||||
"No Frames found. New Frame? Use first-time setup below. Otherwise make sure Developer Mode is on (Settings → System → Developer) and the Frame is on the same network.": "",
|
||||
"No games found in that folder": "",
|
||||
"No limit": "",
|
||||
"No matching files": "",
|
||||
@@ -494,6 +499,7 @@
|
||||
"Not verified on many games yet: try it if the game doesn't work without it.": "",
|
||||
"Notes (what you checked, known issues)": "",
|
||||
"Nothing found. Try a shorter or different name.": "",
|
||||
"Nothing has reached FramePort from the Frame yet (curl says “timed out”)?": "",
|
||||
"Nothing installed yet. Pick a game in the Library and click Install.": "",
|
||||
"Nothing running. Installs, launch tests and downloads show up here.": "",
|
||||
"Nothing to install": "",
|
||||
@@ -526,6 +532,7 @@
|
||||
"Opens a prefilled GitHub issue with this game's recipe, so it can join the built-in catalog (no account token needed; you review and submit it on GitHub). No game files or personal data are sent.": "",
|
||||
"Opens this player's storage on the Frame (Files tab): upload videos into the folder it lists": "",
|
||||
"OpenXR runtime": "",
|
||||
"Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows up above. Open Settings → Developer → Pair new host on the Frame, click Connect here and approve FramePort. That way needs no connection into this computer.": "",
|
||||
"Override the guardian depth (0 = use guardian, min 1.5 m).": "",
|
||||
"Override the guardian width (0 = use guardian, min 1.5 m).": "",
|
||||
"OVRPort converts Quest games from Meta's own VR APIs to standard OpenXR. These are its optional patches.": "",
|
||||
@@ -535,6 +542,7 @@
|
||||
"OVRPort's own converter for games that use Meta's oldest VR interface (experimental).": "",
|
||||
"OVRPort's own VrApi→OpenXR adapter for engines that call libvrapi.so directly: the same upstream code as FramePort's 'VrApi → OpenXR bridge' without its Frame-specific changes. Only in OVRPort's experimental CLI builds (the stable CLI lists it but can't apply it).": "",
|
||||
"Package": "",
|
||||
"Pairing: on the Frame open Settings → Developer → Pair new host, then approve FramePort.": "",
|
||||
"Password (first time only)": "",
|
||||
"Patch Meta XR Audio": "",
|
||||
"Patch Oculus detection for Unity": "",
|
||||
@@ -780,7 +788,7 @@
|
||||
"Test Proton on the Frame": "",
|
||||
"Text and layout size": "",
|
||||
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
|
||||
"The command fetches a small setup script from this app over your local network. It turns on SSH, lets this app's key in, makes the Frame findable and installs Lepton if needed.": "",
|
||||
"The command fetches a small setup script from this app over your local network. It turns on Developer Mode (which includes SSH), lets this app's key in and installs Lepton if needed. No password needed.": "",
|
||||
"The connection to the Frame is busy. Try again in a moment.": "",
|
||||
"The copy on your Frame differs from what FramePort would install now (the recipe or your game files changed). Update to install the new build; saves are kept.": "",
|
||||
"The CPU types the game ships code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games can't run on it.": "",
|
||||
@@ -837,6 +845,7 @@
|
||||
"Turns the controllers' pointing ray left (+) or right (−).": "",
|
||||
"Turns the pointer left or right.": "",
|
||||
"Type": "",
|
||||
"ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (and afterwards: sudo ufw delete allow {port}/tcp)": "",
|
||||
"Uninstall": "",
|
||||
"Uninstall FramePort": "",
|
||||
"Uninstall FramePort?": "",
|
||||
@@ -911,6 +920,9 @@
|
||||
"Which program starts {get}?": "",
|
||||
"Which Proton build runs the game on the Frame (a Steam compat tool name from the Frame's ARM64 compat list, e.g. proton_11-arm64 or proton-experimental-arm64). Empty = newest installed.": "",
|
||||
"Windows not detected": "",
|
||||
"Windows treats this network as public and its firewall blocks the Frame. In Windows' network settings set this network to Private (or allow FramePort on public networks when Windows asks).": "",
|
||||
"Windows' firewall for WSL blocks the Frame from reaching FramePort. Open the setup command again and allow the change when Windows asks (admin).": "",
|
||||
"Windows' firewall for WSL blocks the Frame. Open the setup command again and allow the change when Windows asks (admin).": "",
|
||||
"Working": "",
|
||||
"Working…": "",
|
||||
"Works": "",
|
||||
|
||||
+33
-2
@@ -1346,7 +1346,9 @@ class FramePortApp:
|
||||
self._awake_busy = False
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def connect(self, target, password=None, quiet=False):
|
||||
def connect(self, target, password=None, quiet=False, devkit=False):
|
||||
"""devkit: the Frame was found in Developer Mode; if FramePort's keys aren't on it yet, pair through Valve's
|
||||
devkit service (approve in the headset) instead of failing."""
|
||||
from ..frame.connection import save_target
|
||||
from ..targets.frame_lepton import FrameLeptonTarget
|
||||
|
||||
@@ -1355,7 +1357,17 @@ class FramePortApp:
|
||||
|
||||
def work():
|
||||
try:
|
||||
t = FrameLeptonTarget(target, password).connect()
|
||||
try:
|
||||
t = FrameLeptonTarget(target, password).connect()
|
||||
except ConnectionError as exc:
|
||||
if not devkit or "authentication failed" not in str(exc).lower():
|
||||
raise
|
||||
from ..frame import devkit as dk
|
||||
|
||||
self.toast(tr("Pairing: on the Frame open Settings → Developer → Pair new host, then approve "
|
||||
"FramePort."))
|
||||
dk.register(target.host)
|
||||
t = FrameLeptonTarget(target, password).connect()
|
||||
if password:
|
||||
t.frame.install_key()
|
||||
info = t.describe()
|
||||
@@ -1612,8 +1624,27 @@ def assets_dir() -> str:
|
||||
return str(user_data_dir())
|
||||
|
||||
|
||||
def serialize_flet_updates() -> None:
|
||||
"""Send page updates one at a time. Flet 1.0 diffs and sends a control's patch without a lock, and FramePort
|
||||
updates the page from several threads (jobs, connection checks, the library loader, dialogs): two patches computed
|
||||
at once desynchronised the window ("dropped a patch for unknown control … needs a reload"), e.g. a dialog shown
|
||||
while a finished scan redrew the view never closed again."""
|
||||
from flet.messaging.session import Session
|
||||
|
||||
if getattr(Session.patch_control, "_serialized", False):
|
||||
return
|
||||
original, lock = Session.patch_control, threading.RLock() # re-entrant: did_mount() may update again
|
||||
|
||||
def patch_control(self, *args, **kwargs):
|
||||
with lock:
|
||||
return original(self, *args, **kwargs)
|
||||
patch_control._serialized = True
|
||||
Session.patch_control = patch_control
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
applog.setup("gui")
|
||||
serialize_flet_updates()
|
||||
from ..core import library
|
||||
from .updater import apply_pending_at_start
|
||||
|
||||
|
||||
@@ -77,9 +77,10 @@ HELP: dict[str, str] = _Translated({
|
||||
"cat_device": "Files and environment variables placed next to the game on the Frame.",
|
||||
# ---- Frame
|
||||
"developer_mode": "Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on "
|
||||
"your network and connect to it over SSH.",
|
||||
"your network and connect to it over SSH. The first-time setup command turns it on for you.",
|
||||
"first_time_setup": "The command fetches a small setup script from this app over your local network. It turns on "
|
||||
"SSH, lets this app's key in, makes the Frame findable and installs Lepton if needed.",
|
||||
"Developer Mode (which includes SSH), lets this app's key in and installs Lepton if needed. "
|
||||
"No password needed.",
|
||||
"password": "The Frame's desktop password, only needed the first time so FramePort can add its own key. After "
|
||||
"that it connects with the key.",
|
||||
"lepton": "Lepton is Valve's Android container on the Frame. Quest games run inside it, one container per game. "
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Frame page: the connected device with a readiness checklist and installed games, or a connect wizard."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
@@ -163,12 +164,13 @@ class FrameView:
|
||||
color=T.ACCENT),
|
||||
ft.Column([C.body(f.name if f.source != "scan" else f.host, T.TEXT, weight=ft.FontWeight.W_500),
|
||||
C.meta(f"{f.host} · {label}")], spacing=T.px(2), expand=True),
|
||||
C.primary(tr("Connect"), on_click=lambda e, f=f: app.connect(parse_target(f"{f.user}@{f.host}"))),
|
||||
C.primary(tr("Connect"), on_click=lambda e, f=f: app.connect(parse_target(f"{f.user}@{f.host}"),
|
||||
devkit=f.source == "devkit")),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S3, T.px(8), T.S2, T.px(8)), bgcolor=T.SURFACE_2,
|
||||
border_radius=T.RADIUS_SM))
|
||||
found.controls = items or [C.body(tr("No Frames found. Turn on Developer Mode on the Frame (Settings → "
|
||||
"System → Developer) and make sure it's on the same network, or use "
|
||||
"first-time setup below."))]
|
||||
found.controls = items or [C.body(tr("No Frames found. New Frame? Use first-time setup below. Otherwise "
|
||||
"make sure Developer Mode is on (Settings → System → Developer) and "
|
||||
"the Frame is on the same network."))]
|
||||
found.controls.append(C.ghost(tr("Search again"), ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover)))
|
||||
C.update(found)
|
||||
|
||||
@@ -183,7 +185,29 @@ class FrameView:
|
||||
|
||||
def on_paired(info):
|
||||
app.connect(FrameTarget(info["host"], info["user"], 22, info["name"]))
|
||||
app.pairing = PairingServer(on_paired=on_paired).start()
|
||||
server = app.pairing = PairingServer(on_paired=on_paired).start()
|
||||
show_command()
|
||||
|
||||
def check_network():
|
||||
from ...core import winhost
|
||||
from ...frame.pairing import HINT_AFTER, ensure_reachable, firewall_hint
|
||||
|
||||
if winhost.is_wsl() and ensure_reachable(server) == "failed":
|
||||
app.toast(tr("Windows' firewall for WSL blocks the Frame from reaching FramePort. Open the setup "
|
||||
"command again and allow the change when Windows asks (admin)."), error=True)
|
||||
for _ in range(HINT_AFTER):
|
||||
time.sleep(1)
|
||||
if server.requests or not server.running or app.pairing is not server:
|
||||
return
|
||||
server.hint = firewall_hint(server.port) or tr(
|
||||
"Check that the Frame and this computer are on the same network.")
|
||||
if app.route[0] == "frame" and app.pairing is server:
|
||||
show_command()
|
||||
app.run_bg(check_network)
|
||||
|
||||
def show_command(update=True):
|
||||
"""The setup command of the running pairing server: also when the page is redrawn (connection checks,
|
||||
discovery), which used to close it."""
|
||||
line = app.pairing.one_liner
|
||||
pair_box.controls = [
|
||||
C.body(tr("On the Frame: Steam button → Power → Switch to Desktop, open Konsole and run:"), T.TEXT),
|
||||
@@ -195,10 +219,25 @@ class FrameView:
|
||||
ft.Row([ft.ProgressRing(width=T.px(14), height=T.px(14), stroke_width=T.px(2), color=T.ACCENT),
|
||||
C.meta(tr("Waiting for your Frame… (code {code})").format(code=app.pairing.code))],
|
||||
spacing=T.S2),
|
||||
C.meta(tr("It turns on SSH, trusts this app, makes the Frame findable on your network and installs "
|
||||
"Lepton if needed. You only do this once.")),
|
||||
C.meta(tr("It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on "
|
||||
"Developer Mode closes Desktop Mode; the setup finishes on its own and FramePort connects "
|
||||
"by itself. You only do this once.")),
|
||||
]
|
||||
pair_box.update()
|
||||
hint = getattr(app.pairing, "hint", "")
|
||||
if hint and not app.pairing.requests: # nothing reached us yet: what may block it, and the other way in
|
||||
pair_box.controls.append(C.callout(ft.Column([
|
||||
C.body(tr("Nothing has reached FramePort from the Frame yet (curl says “timed out”)?"), T.TEXT,
|
||||
weight=ft.FontWeight.W_600),
|
||||
C.body(hint, T.TEXT),
|
||||
C.meta(tr("Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows "
|
||||
"up above. Open Settings → Developer → Pair new host on the Frame, click Connect here "
|
||||
"and approve FramePort. That way needs no connection into this computer.")),
|
||||
], spacing=T.S2), "warn"))
|
||||
if update:
|
||||
pair_box.update()
|
||||
|
||||
if app.pairing and app.pairing.running:
|
||||
show_command(update=False)
|
||||
|
||||
style = dict(dense=True, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT, content_padding=ft.Padding(T.px(12), T.px(10), T.px(12), T.px(10)),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
# modules that read settings at import time (UI) run during collection, before the per-test fixture below
|
||||
os.environ["FRAMEPORT_HOME"] = tempfile.mkdtemp(prefix="frameport-tests-")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""The Frame-side agent (stdlib only) — pieces that don't need a Frame."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -702,3 +703,33 @@ def test_keep_awake_falls_back_to_idle_when_sleep_is_refused(monkeypatch, tmp_pa
|
||||
calls.clear()
|
||||
assert a.cmd_keep_awake({"on": False}) == {"awake": False}
|
||||
assert calls and all(c[0] == "systemctl" for c in calls) # only stops the unit
|
||||
|
||||
|
||||
def test_purge_without_saves_removes_container_workdirs_and_stale_shortcuts(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
fake_steam_tools(a, tmp_path)
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
||||
monkeypatch.setattr(a, "stop_steam", lambda: True)
|
||||
monkeypatch.setattr(a, "start_steam", lambda s: None)
|
||||
users = tmp_path / ".local/share/Steam/userdata/42/config"
|
||||
(users / "grid").mkdir(parents=True)
|
||||
q = Path(a.ANCHORS) / "com.x.y"
|
||||
work = q / "lepton-data" / "baked" / "data_workdir" / "work" # overlayfs leaves these with mode 000
|
||||
(work / "inner").mkdir(parents=True)
|
||||
(work / "inner" / "f").write_text("x")
|
||||
(q / "launch.sh").write_text("#!/bin/sh")
|
||||
(q / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 7, "base": str(q), "title": "Q"}))
|
||||
os.chmod(work / "inner", 0)
|
||||
os.chmod(work, 0)
|
||||
vdf = str(users / "shortcuts.vdf")
|
||||
a.upsert_shortcut(vdf, f'"{q}/launch.sh"', "Q", str(q))
|
||||
stale_exe = f'"{a.ANCHORS}/com.gone/launch.sh"' # no install record any more
|
||||
stale = a.upsert_shortcut(vdf, stale_exe, "Gone", "/x")
|
||||
a.upsert_shortcut(vdf, '"/usr/bin/other"', "Not ours", "/x")
|
||||
(users / "grid" / f"{stale}p.jpg").write_bytes(b"x")
|
||||
a.purge_worker(json.dumps({"keep_saves": False, "status": str(tmp_path / "st.json")}))
|
||||
st = json.loads((tmp_path / "st.json").read_text())
|
||||
assert st["state"] == "done" and not st["errors"], st
|
||||
assert not Path(a.ANCHORS).exists()
|
||||
names = [s["appname"] for s in a.vdf_decode((users / "shortcuts.vdf").read_bytes())["shortcuts"].values()]
|
||||
assert names == ["Not ours"] and not (users / "grid" / f"{stale}p.jpg").exists()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""bootstrap.sh: valid bash, placeholders for the pairing server, and the config.vdf edit that turns on Developer
|
||||
Mode."""
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core.paths import bootstrap_dir
|
||||
|
||||
SCRIPT = (bootstrap_dir() / "bootstrap.sh").read_text()
|
||||
|
||||
|
||||
def devmode_edit() -> str:
|
||||
return re.search(r"python3 - \"\$CONFIG\" <<'PY'[^\n]*\n(.*?)\nPY\n", SCRIPT, re.S).group(1)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash")
|
||||
def test_bash_syntax():
|
||||
subprocess.run(["bash", "-n"], input=SCRIPT, text=True, check=True)
|
||||
|
||||
|
||||
def test_placeholders_and_steps():
|
||||
assert "__PC_URL__" in SCRIPT and "__PAIR_CODE__" in SCRIPT
|
||||
assert "steamos-devkit-mode" in SCRIPT and "/etc/steamos-devkit-enabled" in SCRIPT
|
||||
assert "sudo" not in SCRIPT.split("set -euo pipefail", 1)[1] and "passwd" not in SCRIPT # no password, ever
|
||||
# Desktop Mode is a nested session inside steam.service: the Developer Mode job (it stops Steam) must run as a
|
||||
# user unit reached through the real user bus, logging to a file that outlives the terminal
|
||||
assert 'DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus"' in SCRIPT
|
||||
assert "user_systemd systemd-run --user" in SCRIPT and ".cache/frameport-setup.log" in SCRIPT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("before", [
|
||||
'"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t\t"Valve"\n\t\t{\n\t\t}\n\t}\n'
|
||||
'\t"developer"\n\t{\n\t\t"Other"\t\t"x"\n\t}\n}\n',
|
||||
'"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t}\n}\n',
|
||||
'"InstallConfigStore"\n{\n\t"developer"\n\t{\n\t\t"DevModeEnabled"\t\t"0"\n\t}\n}\n',
|
||||
])
|
||||
def test_devmode_config_edit(tmp_path, before):
|
||||
cfg = tmp_path / "config.vdf"
|
||||
cfg.write_text(before)
|
||||
subprocess.run([sys.executable, "-", str(cfg)], input=devmode_edit(), text=True, check=True)
|
||||
after = cfg.read_text()
|
||||
assert re.search(r'\t"developer"\n\t\{\n(?:.*\n)*?\t\t"DevModeEnabled"\t\t"1"\n', after)
|
||||
assert after.count("DevModeEnabled") == 1 and after.count("{") == after.count("}")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Valve's devkit pairing (frame/devkit.py) against a stand-in for the Frame's steamos-devkit-service."""
|
||||
import http.server
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.frame import connection, devkit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_service(monkeypatch):
|
||||
seen, reply = [], {"status": 200, "body": b"Registered\n"}
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
seen.append(self.rfile.read(int(self.headers["Content-Length"])).decode())
|
||||
self.send_response(reply["status"])
|
||||
self.end_headers()
|
||||
self.wfile.write(reply["body"])
|
||||
|
||||
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
monkeypatch.setattr(devkit, "PORT", httpd.server_address[1])
|
||||
yield seen, reply
|
||||
httpd.shutdown()
|
||||
|
||||
|
||||
def test_register_sends_the_rsa_key_and_accepts_registered(fake_service):
|
||||
seen, _ = fake_service
|
||||
devkit.register("127.0.0.1")
|
||||
assert seen[0].startswith("ssh-rsa ") and seen[0].endswith(" frameport") # the service only takes ssh-rsa
|
||||
assert any(k.get_name() == "ssh-rsa" for k in connection.app_keys()) # and FramePort logs in with it
|
||||
|
||||
|
||||
def test_register_explains_a_missed_approval(fake_service):
|
||||
_, reply = fake_service
|
||||
# Valve's service mixes the hook's log lines into the JSON answer
|
||||
reply.update(status=403, body=b'approve-ssh-key:Sending pairing request to Steam\n'
|
||||
b'{"error": "timeout - Steam did not respond to the pairing request"}')
|
||||
with pytest.raises(devkit.PairingRefused, match="wasn't approved in the headset"):
|
||||
devkit.register("127.0.0.1")
|
||||
|
||||
|
||||
def test_ed25519_key_is_tried_first_and_rsa_only_once_created():
|
||||
assert [k.get_name() for k in connection.app_keys()] == ["ssh-ed25519"]
|
||||
connection.devkit_key()
|
||||
assert [k.get_name() for k in connection.app_keys()] == ["ssh-ed25519", "ssh-rsa"]
|
||||
|
||||
|
||||
def test_register_explains_pairing_mode(fake_service):
|
||||
_, reply = fake_service
|
||||
reply.update(status=403, body=b'{"error": "devkit approve-ssh-key: please put the Steam client in pairing mode: '
|
||||
b'Settings -> Developer -> Pair new host\\n"}')
|
||||
with pytest.raises(devkit.PairingRefused, match="Pair new host first"):
|
||||
devkit.register("127.0.0.1")
|
||||
+50
-1
@@ -229,9 +229,11 @@ def test_refresh_keeps_a_working_connection_when_a_status_query_fails():
|
||||
|
||||
|
||||
def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monkeypatch):
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from frameport.frame import pairing
|
||||
|
||||
@@ -248,7 +250,16 @@ def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monke
|
||||
|
||||
seen = []
|
||||
s = pairing.PairingServer(on_paired=seen.append).start()
|
||||
assert len(s.code) == 16
|
||||
other = pairing.PairingServer().start() # a second server (port taken) falls back to another port
|
||||
assert other.port != s.port
|
||||
other.stop()
|
||||
assert len(s.code) == 8
|
||||
assert s.one_liner == f"curl -fsS 127.0.0.1:{s.port}/{s.code} | bash" # typed by hand on the Frame: short
|
||||
assert s.requests == 0
|
||||
assert get(s, f"/{s.code}") == 200 and get(s, "/deadbeef") == 403 # the short form serves the script
|
||||
assert s.requests == 2 # any request (even a wrong code) proves the Frame can reach us: no firewall hint
|
||||
script = urllib.request.urlopen(f"http://127.0.0.1:{s.port}/{s.code}", timeout=5).read().decode()
|
||||
assert f"http://127.0.0.1:{s.port}" in script and s.code in script and "__PAIR_CODE__" not in script
|
||||
assert get(s, "/key?code=000000") == 403
|
||||
assert get(s, f"/paired?code={s.code}&user=steamos&host=frame") == 200 and seen
|
||||
for _ in range(50):
|
||||
@@ -256,6 +267,12 @@ def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monke
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert not s.running
|
||||
flag = Path(tempfile.mkdtemp()) / "pairing.flag" # WSL: the temporary firewall rule lives while this exists
|
||||
flag.write_text("x")
|
||||
f = pairing.PairingServer().start()
|
||||
f.flag = flag
|
||||
f.stop()
|
||||
assert not flag.exists()
|
||||
g = pairing.PairingServer().start()
|
||||
for _ in range(3):
|
||||
get(g, "/key?code=guess")
|
||||
@@ -440,3 +457,35 @@ def test_activity_progress_ticks_only_touch_the_progress_controls(monkeypatch):
|
||||
bar = panel._live[run.id][0]
|
||||
assert bar.value == 0.5 and panel.root not in updated[-1] and bar in updated[-1]
|
||||
assert panel.list.controls == first_list # nothing rebuilt
|
||||
|
||||
|
||||
def test_flet_updates_are_serialized():
|
||||
import threading
|
||||
|
||||
from flet.messaging.session import Session
|
||||
|
||||
from frameport.ui.app import serialize_flet_updates
|
||||
|
||||
original = Session.patch_control
|
||||
try:
|
||||
active, peak = [0], [0]
|
||||
guard = threading.Lock()
|
||||
|
||||
def fake(self, *a, **k):
|
||||
with guard:
|
||||
active[0] += 1
|
||||
peak[0] = max(peak[0], active[0])
|
||||
threading.Event().wait(0.01)
|
||||
with guard:
|
||||
active[0] -= 1
|
||||
Session.patch_control = fake
|
||||
serialize_flet_updates()
|
||||
serialize_flet_updates() # idempotent
|
||||
threads = [threading.Thread(target=Session.patch_control, args=(None,)) for _ in range(8)]
|
||||
for th in threads:
|
||||
th.start()
|
||||
for th in threads:
|
||||
th.join()
|
||||
assert peak[0] == 1
|
||||
finally:
|
||||
Session.patch_control = original
|
||||
Reference in new issue
Block a user