From a90ffeda5feb22e1144e16990f3e6ba4a3eed7e7 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:03:48 +1000 Subject: [PATCH 01/13] Pair through the SteamOS devkit service before asking for a password connect.sh and frame_connect.py now try Valve's steamos-devkit-service first: GET /properties.json for the login user, then POST /register with a new RSA key (~/.ssh/id_rsa_frame_devkit, the only type it accepts), so the user approves a prompt in the headset instead of typing a password. Port 32000 closed, a timeout or a 403 falls back to the existing password copy. The Host frame block lists both keys; a host counts as found if port 22 or 32000 answers; with no host given, dns-sd or avahi-browse look for _steamos-devkit._tcp. Inferred from Valve's source, not yet verified on a Frame. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 8 +- docs/scripts.md | 6 +- docs/ssh.md | 35 ++++- scripts/connect.sh | 219 +++++++++++++++++++++++++------ tests/test_connect.py | 192 ++++++++++++++++++++++++++++ ui/frame_connect.py | 290 ++++++++++++++++++++++++++++++++++++++---- 6 files changed, 676 insertions(+), 74 deletions(-) create mode 100644 tests/test_connect.py diff --git a/README.md b/README.md index 573e1ae..4756d45 100644 --- a/README.md +++ b/README.md @@ -147,13 +147,19 @@ computer. 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. + + Before asking for the password it tries Valve's SteamOS devkit pairing: if + the headset shows a pairing request, approve it and no password is needed. + (**Inferred from Valve's source** ([steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service)), + not yet verified on a Frame; see [SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).) 3. That's it. The app now reaches the headset whenever it's awake and on the same network. For anywhere else, see [Tailscale](docs/tailscale.md). **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`. +entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and +`~/.ssh/id_rsa_frame_devkit` (the pairing service only takes RSA keys). ## Feedback diff --git a/docs/scripts.md b/docs/scripts.md index a1a1e0f..464c5a6 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -36,9 +36,11 @@ 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`) +- creates dedicated keys (`~/.ssh/id_ed25519_frame`, plus `~/.ssh/id_rsa_frame_devkit` for pairing) - adds a `Host frame` block to `~/.ssh/config` -- runs `ssh-copy-id`, which asks for the Developer Mode password once +- tries SteamOS devkit pairing (approve on the headset, no password; **inferred**, + see [SSH](ssh.md#password-free-pairing-steamos-devkit-service)), else runs + `ssh-copy-id`, which asks for the Developer Mode password once Run `./scripts/connect.sh --harden` later if you want to turn off SSH password logins. diff --git a/docs/ssh.md b/docs/ssh.md index 0b25cce..aa11407 100644 --- a/docs/ssh.md +++ b/docs/ssh.md @@ -33,7 +33,8 @@ unless your router's DNS registers DHCP client names. - **Verified on device (2026-09-25):** `avahi-daemon` is running on the Frame and `frame.local` resolves from the Mac over mDNS. -- `scripts/connect.sh` tries `frame.local`, then `frame`. If neither works, it tells you to re-run it with the IP. +- `scripts/connect.sh` tries `frame.local`, then `frame`, then an mDNS browse for + the devkit service (below). If none works, it tells you to re-run it with the IP. Once you have a working address, the `Host frame` alias means you just type `ssh frame`. - To check discovery yourself: `dns-sd -G v4 frame.local` (Ctrl-C to stop), or @@ -55,10 +56,42 @@ Host frame HostName frame.local User steamos IdentityFile ~/.ssh/id_ed25519_frame + IdentityFile ~/.ssh/id_rsa_frame_devkit IdentitiesOnly yes ServerAliveInterval 30 ``` +The script only asks for the password if the pairing below doesn't work. + +## Password-free pairing (SteamOS devkit service) + +**Inferred from Valve's source ([steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service), +[steamos-devkit](https://gitlab.steamos.cloud/devkit/steamos-devkit) client); not yet +verified on a Frame.** SteamOS's devkit service is what Valve's Devkit Client +uses to pair. `scripts/connect.sh` and `ui/frame_connect.py` try it first: + +- The headset serves HTTP on port **32000** and advertises mDNS + `_steamos-devkit._tcp`. `GET /properties.json` gives the `login` user; the + script uses it as `User` (unless you set `FRAME_USER`, or it says `root`), + for the password fallback too, and keeps it on re-runs. +- `POST /register` with `ssh-rsa 900b919520e4cf601998a71eec318fec` + (a fixed token from Valve's client) shows an approve prompt inside the + headset naming the comment (`frame-control@`). It waits 30 s, + then installs the key for the device user and turns `sshd` on. The reply is + `200 Registered`, or `403` with `{"error": ...}` (declined, timed out, Steam + not running). +- It only accepts **RSA** keys, hence the second key, + `~/.ssh/id_rsa_frame_devkit` (3072-bit). +- A host counts as found if port 22 **or** 32000 answers. With no host given, + and `frame.local`/`frame` unreachable, it browses `_steamos-devkit._tcp` with + `dns-sd` (macOS) or `avahi-browse` (Linux) for a few seconds if installed. +- Port 32000 closed, a timeout, or an error: the script says why and falls back + to copying the ed25519 key with the Developer Mode password, as before. + +Anyone on your network can send the request, so only approve a prompt you +started. Whether the Frame runs this service with Developer Mode on is the +unverified part: `curl http://frame.local:32000/properties.json` answers the question. + `~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS updates (inferred from Deck; the Frame uses the same A/B image scheme). diff --git a/scripts/connect.sh b/scripts/connect.sh index 9ce6cd4..0be75a5 100755 --- a/scripts/connect.sh +++ b/scripts/connect.sh @@ -1,8 +1,10 @@ #!/usr/bin/env zsh -# Mac-side: find the Steam Frame, create a key, add a `Host frame` alias to -# ~/.ssh/config, copy the key, and optionally disable SSH password logins. +# Mac-side: find the Steam Frame, create keys, add a `Host frame` alias to +# ~/.ssh/config, get a key onto the headset, and optionally disable SSH password +# logins. It first pairs through Valve's SteamOS devkit service (port 32000: +# approve on the headset, no password), else copies the key with the password. # -# Verified on a Frame 2026-09-25 (except --harden). Idempotent: safe to re-run. +# Verified on a Frame 2026-09-25 (except --harden and devkit pairing). Idempotent. # # Usage: # scripts/connect.sh [HOST_OR_IP] # set up key + alias @@ -11,93 +13,226 @@ # Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame). set -euo pipefail +user_from_env=${+FRAME_USER} FRAME_USER=${FRAME_USER:-steamos} FRAME_ALIAS=${FRAME_ALIAS:-frame} KEY="$HOME/.ssh/id_ed25519_frame" +# The devkit service only accepts ssh-rsa keys, so pairing uses a second key. +DEVKIT_KEY="$HOME/.ssh/id_rsa_frame_devkit" CONFIG="$HOME/.ssh/config" BEGIN_MARK="# >>> steam-frame ($FRAME_ALIAS) >>>" END_MARK="# <<< steam-frame ($FRAME_ALIAS) <<<" +DEVKIT_PORT=32000 +DEVKIT_SERVICE=_steamos-devkit._tcp +MAGIC_PHRASE=900b919520e4cf601998a71eec318fec # fixed token Valve's client appends +NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$' +HOST_RE='^[A-Za-z0-9][A-Za-z0-9.:%-]*$' harden=0 host_arg="" for arg in "$@"; do case "$arg" in --harden) harden=1 ;; - -h|--help) sed -n '2,11p' "$0"; exit 0 ;; + -h|--help) sed -n '2,13p' "$0"; exit 0 ;; *) host_arg="$arg" ;; esac done port_open() { # nc resolves through the system resolver (including mDNS for .local). - nc -z -G 3 "$1" 22 >/dev/null 2>&1 + nc -z -G 3 "$1" "$2" >/dev/null 2>&1 +} + +# sshd, or the devkit service, which turns sshd on once a pairing is approved. +reachable() { + port_open "$1" 22 || port_open "$1" $DEVKIT_PORT +} + +# What a command printed within $1 seconds; dns-sd never exits by itself. +run_for() { + local secs=$1; shift + "$@" 2>/dev/null & + local pid=$! + sleep "$secs" + kill $pid 2>/dev/null || true + wait $pid 2>/dev/null || true +} + +# Hosts advertising the devkit service over mDNS (dns-sd -B, then -L each). +discover_devkit() { + local name target + run_for 3 dns-sd -B $DEVKIT_SERVICE local. \ + | sed -n "s/.* Add .*${DEVKIT_SERVICE//./\\.}\\.[[:space:]]*//p" | awk '!seen[$0]++' | head -n 4 \ + | while IFS= read -r name; do + target=$(run_for 2 dns-sd -L "$name" $DEVKIT_SERVICE local. \ + | sed -n 's/.* can be reached at \([^ :]*\):[0-9].*/\1/p' | head -n 1) + [[ -n "$target" ]] && print -r -- "${target%.}" + done | awk '!seen[$0]++' } pick_host() { local candidates=() [[ -n "$host_arg" ]] && candidates+=("$host_arg") - candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS") + [[ -z "$host_arg" ]] && candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS") local h for h in "${candidates[@]}"; do - if port_open "$h"; then + if reachable "$h"; then print -r -- "$h"; return 0 fi - print -u2 " - $h: not resolvable or port 22 closed" + print -u2 " - $h: not resolvable, or ports 22 and $DEVKIT_PORT closed" + done + [[ -n "$host_arg" ]] && return 1 + print -u2 " - asking mDNS for $DEVKIT_SERVICE" + for h in ${(f)"$(discover_devkit)"}; do + if [[ "$h" =~ $HOST_RE ]] && reachable "$h"; then + print -r -- "$h"; return 0 + fi + print -u2 " - $h: advertised, but not reachable" done return 1 } +make_key() { # path type comment [extra ssh-keygen args] + if [[ ! -f "$1" ]]; then + ssh-keygen -q -t "$2" "${@:4}" -N '' -C "$3" -f "$1" + print " created $1" + else + print " exists: $1" + fi +} + +# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off. +write_config() { + touch "$CONFIG" && chmod 600 "$CONFIG" || return 1 + local tmp + tmp=$(mktemp) || return 1 + # Drop any previous managed block, then PREPEND a fresh one: ssh uses the first + # value it sees per option, so this block must precede any other "Host frame" + # or "Host *". The trailing "Host *" returns the rest of the file to global scope. + awk -v b="$BEGIN_MARK" -v e="$END_MARK" ' + $0==b {skip=1; next} + $0==e {skip=0; next} + !skip {print} + ' "$CONFIG" > "$tmp" || { rm -f "$tmp"; return 1; } + { + print -r -- "$BEGIN_MARK" + print -r -- "Host $FRAME_ALIAS" + print -r -- " HostName $HOST" + print -r -- " User $FRAME_USER" + print -r -- " IdentityFile $KEY" + print -r -- " IdentityFile $DEVKIT_KEY" + print -r -- " IdentitiesOnly yes" + print -r -- " ServerAliveInterval 30" + print -r -- "Host *" + print -r -- "$END_MARK" + cat "$tmp" + } > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; } + rm -f "$tmp" +} + +# accept-new: after pairing, this is the first contact, so trust a first-seen host +# key (as ssh-copy-id's prompt would); a changed one still fails. +key_login_works() { + ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new "$FRAME_ALIAS" true 2>/dev/null +} + +# The User in our managed block, so a re-run keeps one the headset named earlier. +configured_user() { + [[ -f "$CONFIG" ]] || return 0 + awk -v b="$BEGIN_MARK" -v e="$END_MARK" ' + $0==b {inside=1; next} + $0==e {exit} + inside && $1=="User" {print $2; exit} + ' "$CONFIG" +} + +devkit_url() { + if [[ "$HOST" == *:* ]]; then print -r -- "http://[$HOST]:$DEVKIT_PORT$1" + else print -r -- "http://$HOST:$DEVKIT_PORT$1"; fi +} + +# Valve's steamos-devkit-service: GET /properties.json names the login user; POST +# /register with "ssh-rsa " shows an approve prompt in the +# headset (the comment is what it displays, 30 s to answer), then installs the key +# and turns sshd on. Returns non-zero with the reason in $devkit_why to fall back. +devkit_why="" +pair_with_devkit() { + local props login comment body resp code text err + print "==> Pairing through the headset's SteamOS devkit service (no password)" + if [[ ! -r "$DEVKIT_KEY.pub" ]]; then + devkit_why="can't read the pairing key $DEVKIT_KEY.pub"; return 1 + fi + if ! props=$(curl -fsS --noproxy '*' -m 5 "$(devkit_url /properties.json)" 2>&1); then + devkit_why="devkit service not reachable on port $DEVKIT_PORT: ${${props##*curl: }%%$'\n'*}"; return 1 + fi + # properties.json is Valve's json.dumps(indent=2): "login" sits on its own line. + login=$(print -r -- "$props" | sed -n 's/.*"login"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + [[ "$login" =~ $NAME_RE && "$login" != root ]] || login="" + # Before the prompt, so the password fallback uses this user too. + if [[ -n "$login" && "$login" != "$FRAME_USER" ]]; then + if (( user_from_env )); then + print " the headset logs in as '$login'; keeping FRAME_USER=$FRAME_USER" + else + FRAME_USER=$login + print " the headset logs in as '$FRAME_USER'" + write_config || { print -u2 "Could not rewrite $CONFIG."; exit 1; } + fi + fi + # One word: the headset splits the body on spaces and shows the third field. + comment="frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')" + [[ "$comment" == "frame-control@" ]] && comment="frame-control@computer" + body="ssh-rsa $(awk '{print $2}' "$DEVKIT_KEY.pub") $comment $MAGIC_PHRASE" + print " Approve the pairing request in the headset (it waits about 30 seconds)" + if ! resp=$(print -r -- "$body" | curl -sS --noproxy '*' -m 60 -H 'Content-Type: text/plain' \ + --data-binary @- -w '\n%{http_code}' "$(devkit_url /register)" 2>&1); then + devkit_why="devkit pairing failed: no answer (${${resp##*curl: }%%$'\n'*})"; return 1 + fi + code=${resp##*$'\n'} + text=${resp%$'\n'*} + if [[ "$code" != 2* ]]; then + err=$(print -r -- "$text" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + devkit_why="devkit pairing failed: ${err:-${text:-HTTP $code}}"; return 1 + fi + # The approval is what turns sshd on, so it may take a moment to answer. + local i + for i in {1..10}; do + key_login_works && return 0 + sleep 1 + done + devkit_why="paired, but key login still fails"; return 1 +} + print "==> Looking for the Steam Frame" if ! HOST=$(pick_host); then - print -u2 "Could not reach the Frame on port 22." + print -u2 "Could not reach the Frame on port 22 or $DEVKIT_PORT." print -u2 "Check: Developer Mode on + user password set; same Wi-Fi; no client isolation." print -u2 "Then re-run with the IP from Quick Settings: scripts/connect.sh 192.168.x.y" exit 1 fi print " found: $HOST" -print "==> SSH key" +print "==> SSH keys" mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" -if [[ ! -f "$KEY" ]]; then - ssh-keygen -q -t ed25519 -N '' -C "mac->steam-frame" -f "$KEY" - print " created $KEY" -else - print " exists: $KEY" -fi +make_key "$KEY" ed25519 "mac->steam-frame" +make_key "$DEVKIT_KEY" rsa "frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')" -b 3072 +if (( ! user_from_env )); then + prev_user=$(configured_user) + if [[ "$prev_user" =~ $NAME_RE ]]; then FRAME_USER=$prev_user; fi +fi print "==> ~/.ssh/config alias '$FRAME_ALIAS' -> $HOST" -touch "$CONFIG" && chmod 600 "$CONFIG" -tmp=$(mktemp) -# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first -# value it sees per option, so this block must precede any other "Host frame" -# or "Host *". The trailing "Host *" returns the rest of the file to global scope. -awk -v b="$BEGIN_MARK" -v e="$END_MARK" ' - $0==b {skip=1; next} - $0==e {skip=0; next} - !skip {print} -' "$CONFIG" > "$tmp" -{ - print -r -- "$BEGIN_MARK" - print -r -- "Host $FRAME_ALIAS" - print -r -- " HostName $HOST" - print -r -- " User $FRAME_USER" - print -r -- " IdentityFile $KEY" - print -r -- " IdentitiesOnly yes" - print -r -- " ServerAliveInterval 30" - print -r -- "Host *" - print -r -- "$END_MARK" - cat "$tmp" -} > "$CONFIG" -rm -f "$tmp" +write_config print "==> Checking key login" -if ssh -o BatchMode=yes -o ConnectTimeout=5 "$FRAME_ALIAS" true 2>/dev/null; then +if key_login_works; then print " key login already works" +elif pair_with_devkit; then + print " paired; key login OK" else + print " $devkit_why; falling back to the password" print " copying key (enter the Developer Mode password once)" ssh-copy-id -i "$KEY.pub" -o IdentitiesOnly=yes "$FRAME_USER@$HOST" - ssh -o BatchMode=yes -o ConnectTimeout=5 "$FRAME_ALIAS" true \ - || { print -u2 "Key login still failing after ssh-copy-id."; exit 1; } + key_login_works || { print -u2 "Key login still failing after ssh-copy-id."; exit 1; } print " key login OK" fi diff --git a/tests/test_connect.py b/tests/test_connect.py new file mode 100644 index 0000000..d2b8187 --- /dev/null +++ b/tests/test_connect.py @@ -0,0 +1,192 @@ +"""Setup-script checks that need no headset: devkit pairing against a stub of Valve's +steamos-devkit-service, the ~/.ssh/config block, and the mDNS output parsers. + +Run: python3 -m unittest discover -s tests +""" +import json +import socket +import sys +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) + +import frame_connect as fc # noqa: E402 + +PUB = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC+/x= frame-control@old\n" + + +class StubDevkit(BaseHTTPRequestHandler): + """Answers like steamos-devkit-service; `reply` picks the /register outcome.""" + reply = (200, b"Registered\n") + properties = {"txtvers": 1, "login": "steamos", "settings": "{}", "devkit1": ["devkit-1"]} + bodies = [] + + def log_message(self, *args): + pass + + def do_GET(self): + if self.path == "/properties.json": + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(self.properties).encode()) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + body = self.rfile.read(int(self.headers["Content-Length"])) + StubDevkit.bodies.append((self.path, self.headers["Content-Type"], body)) + code, text = self.reply + self.send_response(code) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(text) + + +class DevkitPairing(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), StubDevkit) + cls.port = cls.server.server_address[1] + threading.Thread(target=cls.server.serve_forever, daemon=True).start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + + def setUp(self): + StubDevkit.reply = (200, b"Registered\n") + StubDevkit.bodies = [] + self.said = [] + self._say, fc.say = fc.say, self.said.append + + def tearDown(self): + fc.say = self._say + + def test_register_body(self): + body = fc.register_body(PUB, "frame-control@mac") + self.assertEqual(body, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC+/x= frame-control@mac " + "900b919520e4cf601998a71eec318fec\n") + # approve-ssh-key shows split(' ')[2] as the key name. + self.assertEqual(body.split(" ")[2], "frame-control@mac") + with self.assertRaises(ValueError): + fc.register_body("ssh-ed25519 AAAAC3Nz x", "c") + + def test_key_comment_is_one_word(self): + self.assertEqual(fc.key_comment("Alex's MacBook Pro.local"), "frame-control@Alex-s-MacBook-Pro") + self.assertEqual(fc.key_comment(""), "frame-control@computer") + self.assertNotIn(" ", fc.key_comment(" a b\nc ")) + + def test_parse_login(self): + self.assertEqual(fc.parse_login(b'{"login": "steamos", "txtvers": 1}'), "steamos") + for raw in (b'{"txtvers": 1}', b'{"login": "root"}', b'{"login": "x\\nHost *"}', b'{"login": 5}'): + self.assertIsNone(fc.parse_login(raw), raw) + for raw in (b"not json", b"[1]"): + with self.assertRaises(ValueError): + fc.parse_login(raw) + + def test_devkit_error(self): + self.assertEqual(fc.devkit_error(403, b'{"error": "Steam is not running"}\n'), "Steam is not running") + self.assertEqual(fc.devkit_error(500, b"install-ssh-key:\nboom"), "install-ssh-key:\nboom") + self.assertEqual(fc.devkit_error(403, b""), "HTTP 403") + + def test_pair_ok(self): + logins = [] + reason = fc.devkit_pair("127.0.0.1", PUB, "frame-control@test", self.port, logins.append) + self.assertIsNone(reason) + self.assertEqual(logins, ["steamos"]) + path, ctype, body = StubDevkit.bodies[0] + self.assertEqual((path, ctype), ("/register", "text/plain")) + self.assertEqual(body.decode(), fc.register_body(PUB, "frame-control@test")) + self.assertTrue(any("Approve the pairing request" in s for s in self.said)) + + def test_pair_refused_falls_back(self): + StubDevkit.reply = (403, b'{"error": "timeout - Steam did not respond to the pairing request"}') + logins = [] + reason = fc.devkit_pair("127.0.0.1", PUB, "c", self.port, logins.append) + self.assertIn("timeout - Steam did not respond", reason) + # The login is still reported, so the password fallback uses the right user. + self.assertEqual(logins, ["steamos"]) + + def test_pair_without_service_falls_back(self): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + closed = s.getsockname()[1] + logins = [] + reason = fc.devkit_pair("127.0.0.1", PUB, "c", closed, logins.append) + self.assertIn("not reachable", reason) + self.assertEqual((logins, StubDevkit.bodies), ([], [])) + + def test_pair_times_out(self): + # Accepts the connection but never answers, like a prompt nobody taps. + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + s.listen() + ok, msg = fc.register("127.0.0.1", "x", s.getsockname()[1], timeout=0.5) + self.assertFalse(ok) + self.assertIn("no answer", msg) + + +class ConfigBlock(unittest.TestCase): + def test_both_keys(self): + block = fc.config_block("frame.local", 22, "steamos") + self.assertEqual(block[0], fc.BEGIN) + self.assertEqual(block[-1], fc.END) + self.assertIn(" User steamos", block) + self.assertNotIn(" Port 22", block) + files = [line for line in block if line.startswith(" IdentityFile")] + self.assertEqual(files, [" IdentityFile ~/.ssh/id_ed25519_frame", " IdentityFile ~/.ssh/id_rsa_frame_devkit"]) + self.assertIn(" IdentitiesOnly yes", block) + self.assertEqual(block[-2], "Host *") + self.assertIn(" Port 2222", fc.config_block("10.0.0.5", 2222)) + + def test_write_config_replaces_block(self): + import tempfile + with tempfile.TemporaryDirectory() as d: + saved = fc.SSH_DIR, fc.CONFIG + fc.SSH_DIR, fc.CONFIG = Path(d), Path(d) / "config" + try: + fc.CONFIG.write_text("Host other\n User me\n", encoding="utf-8") + fc.write_config("frame.local") + self.assertEqual(fc.configured_user(), "steamos") + fc.write_config("10.0.0.5", 22, "deck") + text = fc.CONFIG.read_text(encoding="utf-8") + self.assertEqual(fc.configured_user(), "deck") # not "me" from Host other + finally: + fc.SSH_DIR, fc.CONFIG = saved + self.assertEqual(text.count(fc.BEGIN), 1) + self.assertIn("HostName 10.0.0.5", text) + self.assertIn("User deck", text) + self.assertNotIn("frame.local", text) + self.assertTrue(text.endswith("Host other\n User me\n")) + + +class MdnsParsers(unittest.TestCase): + def test_dns_sd(self): + browse = ("Browsing for _steamos-devkit._tcp\n" + "Timestamp A/R Flags if Domain Service Type Instance Name\n" + "19:34:35.419 Add 3 15 local. _steamos-devkit._tcp. frame\n" + "19:34:35.611 Add 2 1 local. _steamos-devkit._tcp. frame\n" + "19:34:35.700 Add 2 15 local. _steamos-devkit._tcp. My Frame\n" + "19:34:36.000 Rmv 0 15 local. _steamos-devkit._tcp. gone\n") + self.assertEqual(fc.parse_dns_sd_browse(browse), ["frame", "My Frame"]) + resolve = ("Lookup frame._steamos-devkit._tcp.local.\n" + "19:34:44.601 frame._steamos-devkit._tcp.local. can be reached at frame.local.:32000 (interface 15)\n") + self.assertEqual(fc.parse_dns_sd_resolve(resolve), "frame.local") + self.assertIsNone(fc.parse_dns_sd_resolve("Lookup frame\n")) + + def test_avahi(self): + out = ('+;wlan0;IPv4;frame;_steamos-devkit._tcp;local\n' + '=;wlan0;IPv6;frame;_steamos-devkit._tcp;local;frame.local;fe80::1;32000;"login=steamos"\n' + '=;wlan0;IPv4;frame;_steamos-devkit._tcp;local;frame.local;192.168.1.50;32000;"login=steamos"\n') + self.assertEqual(fc.parse_avahi(out), ["frame.local", "192.168.1.50"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/frame_connect.py b/ui/frame_connect.py index bdf93ec..bc3be68 100644 --- a/ui/frame_connect.py +++ b/ui/frame_connect.py @@ -1,29 +1,41 @@ -"""Connect this computer to the Steam Frame: find it, create a key, add a `Host frame` -alias to ~/.ssh/config and copy the key over, asking for the Developer Mode -password once. The Linux and Windows twin of scripts/connect.sh (which the Mac -app uses); same config block, so either can re-run over the other. Idempotent. +"""Connect this computer to the Steam Frame: find it, create keys, add a `Host frame` +alias to ~/.ssh/config and get a key onto the headset. It first asks Valve's +SteamOS devkit service (port 32000) to pair, which needs only a tap on the +headset; if that service isn't there or says no, it copies the key over SSH, +asking for the Developer Mode password once. The Linux and Windows twin of +scripts/connect.sh (which the Mac app uses); same config block, so either can +re-run over the other. Idempotent. Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]] Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame) """ import base64 +import json import os import platform import re +import shutil import socket import subprocess import sys import time +import urllib.error +import urllib.request from pathlib import Path FRAME_USER = os.environ.get("FRAME_USER", "steamos") +USER_FROM_ENV = "FRAME_USER" in os.environ FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame") SSH_DIR = Path.home() / ".ssh" KEY = SSH_DIR / "id_ed25519_frame" +# The devkit service only accepts ssh-rsa keys (write_key in Valve's +# steamos-devkit-service), so pairing uses a second key next to the ed25519 one. +DEVKIT_KEY = SSH_DIR / "id_rsa_frame_devkit" CONFIG = SSH_DIR / "config" +NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") # Both go into ~/.ssh/config, so nothing that could add a line or a directive. for _name, _value in (("FRAME_ALIAS", FRAME_ALIAS), ("FRAME_USER", FRAME_USER)): - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", _value): + if not NAME_RE.fullmatch(_value): sys.exit(f"{_name} must be a plain name, not {_value!r}") BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>" END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<" @@ -42,6 +54,98 @@ def say(msg): print(msg, flush=True) +# --- Valve's SteamOS devkit pairing (steamos-devkit-service on the headset). HTTP on +# port 32000: GET /properties.json names the user to log in as; POST /register with +# "ssh-rsa " shows an approve prompt in the headset (the +# comment is what it displays, 30 s to answer), then installs the key and turns sshd on. + +DEVKIT_PORT = 32000 +DEVKIT_SERVICE = "_steamos-devkit._tcp" +MAGIC_PHRASE = "900b919520e4cf601998a71eec318fec" # fixed token Valve's client appends +REGISTER_TIMEOUT = 60 +# A LAN host: never go through an HTTP(S)_PROXY from the environment. +_opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def key_comment(node): + """"frame-control@" as one word: the headset splits the body on spaces.""" + name = re.sub(r"[^A-Za-z0-9._-]+", "-", (node or "").split(".")[0]).strip("-.") or "computer" + return f"frame-control@{name}" + + +def register_body(pub, comment): + fields = pub.split() + if len(fields) < 2 or fields[0] != "ssh-rsa": + raise ValueError("the devkit service only takes ssh-rsa keys") + return f"ssh-rsa {fields[1]} {comment} {MAGIC_PHRASE}\n" + + +def parse_login(raw): + """The `login` from /properties.json if it's a plain user name, else None. "root" + means several users are configured and Valve's client switches between them; we + can't, so it counts as no answer.""" + props = json.loads(raw) + if not isinstance(props, dict): + raise ValueError("properties.json isn't a JSON object") + login = props.get("login") + if isinstance(login, str) and NAME_RE.fullmatch(login) and login != "root": + return login + return None + + +def devkit_error(status, raw): + """A readable reason from a failed /register: its {"error": ...} JSON, or the text.""" + text = raw.decode("utf-8", "replace").strip() + try: + err = json.loads(text).get("error") + except (ValueError, AttributeError): + err = None + return str(err or text or f"HTTP {status}")[:300] + + +def devkit_url(host, port, path): + return f"http://[{host}]:{port}{path}" if ":" in host else f"http://{host}:{port}{path}" + + +def why(e): + return str(getattr(e, "reason", None) or e) + + +def fetch_login(host, port=DEVKIT_PORT, timeout=5): + """GET /properties.json. Raises OSError (HTTP errors included) or ValueError.""" + with _opener.open(devkit_url(host, port, "/properties.json"), timeout=timeout) as r: + return parse_login(r.read()) + + +def register(host, body, port=DEVKIT_PORT, timeout=REGISTER_TIMEOUT): + """POST /register, which waits while someone answers the prompt. -> (ok, message)""" + req = urllib.request.Request(devkit_url(host, port, "/register"), data=body.encode("ascii"), + headers={"Content-Type": "text/plain"}, method="POST") + try: + with _opener.open(req, timeout=timeout) as r: + return True, r.read().decode("utf-8", "replace").strip() + except urllib.error.HTTPError as e: + with e: + return False, devkit_error(e.code, e.read()) + except OSError as e: + return False, f"no answer ({why(e)})" + + +def devkit_pair(host, pub, comment, port=DEVKIT_PORT, on_login=None): + """The password-free route. -> None once paired, else the reason, which means: fall + back to copying the key with the password. on_login(user) runs before the prompt + with the login properties.json names, so the fallback uses that user too.""" + try: + login = fetch_login(host, port) + except (OSError, ValueError) as e: + return f"devkit service not reachable on port {port}: {why(e)}" + if login and on_login: + on_login(login) + say(" Approve the pairing request in the headset (it waits about 30 seconds)") + ok, msg = register(host, register_body(pub, comment), port) + return None if ok else f"devkit pairing failed: {msg}" + + def split_port(arg): """"host:2222" -> ("host", 2222); anything else (IPv6 too) keeps port 22.""" host, sep, port = arg.rpartition(":") @@ -58,6 +162,70 @@ def port_open(host, port=22): return False +def reachable(host, port): + """sshd, or the devkit service, which turns sshd on once a pairing is approved.""" + try: + socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + except OSError: + return False + return port_open(host, port) or port_open(host, DEVKIT_PORT) + + +# --- mDNS. There's no stdlib client, so this borrows dns-sd (macOS; Bonjour for +# Windows) or avahi-browse (Linux) when present, with short timeouts. + +def run_for(args, seconds): + """What a command printed within `seconds`; dns-sd never exits by itself.""" + try: + out = subprocess.run(args, capture_output=True, timeout=seconds).stdout + except subprocess.TimeoutExpired as e: + out = e.stdout + except OSError: + out = b"" + return (out or b"").decode("utf-8", "replace") + + +def parse_dns_sd_browse(text): + """Instance names from `dns-sd -B _steamos-devkit._tcp`, deduplicated, in order.""" + pat = re.compile(r"\sAdd\s+\d+\s+\d+\s+\S+\s+" + re.escape(DEVKIT_SERVICE) + r"\.\s+(.+?)\s*$") + names = [] + for line in text.splitlines(): + m = pat.search(line) + if m and m.group(1) not in names: + names.append(m.group(1)) + return names + + +def parse_dns_sd_resolve(text): + """The target host from `dns-sd -L` ("... can be reached at frame.local.:32000").""" + m = re.search(r"can be reached at (\S+?)\.?:\d+", text) + return m.group(1) if m else None + + +def parse_avahi(text): + """Host names, then IPv4 addresses, from `avahi-browse -rpt` resolved ("=") lines.""" + names, addrs = [], [] + for line in text.splitlines(): + f = line.split(";") + if len(f) >= 9 and f[0] == "=" and f[2] == "IPv4": + names.append(f[6]) + addrs.append(f[7]) + return list(dict.fromkeys(names + addrs)) + + +def discover_devkit(): + if shutil.which("dns-sd"): + hosts = [] + for name in parse_dns_sd_browse(run_for(["dns-sd", "-B", DEVKIT_SERVICE, "local."], 3))[:4]: + host = parse_dns_sd_resolve(run_for(["dns-sd", "-L", name, DEVKIT_SERVICE, "local."], 2)) + if host and host not in hosts: + hosts.append(host) + return hosts + if shutil.which("avahi-browse"): + return parse_avahi(run_for(["avahi-browse", "-rpt", DEVKIT_SERVICE], 5)) + return [] + + HOST_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9.:%-]*") @@ -67,9 +235,16 @@ def pick_host(arg): return None for cand in [arg] if arg else [f"{FRAME_ALIAS}.local", FRAME_ALIAS]: host, port = split_port(cand) - if port_open(host, port): + if reachable(host, port): return host, port - say(f" - {cand}: not resolvable or port {port} closed") + say(f" - {cand}: not resolvable, or ports {port} and {DEVKIT_PORT} closed") + if arg: + return None + say(f" - asking mDNS for {DEVKIT_SERVICE}") + for host in discover_devkit(): + if HOST_RE.fullmatch(host) and reachable(host, 22): + return host, 22 + say(f" - {host}: advertised, but not reachable") return None @@ -82,7 +257,23 @@ def make_ssh_dir(): SSH_DIR.mkdir(mode=0o700, exist_ok=True) -def write_config(host, port=22): +def make_key(path, kind, comment): + if path.exists(): + say(f" exists: {path}") + return + bits = ["-b", "3072"] if kind == "rsa" else [] + subprocess.run(["ssh-keygen", "-q", "-t", kind, *bits, "-N", "", "-C", comment, "-f", str(path)], check=True) + say(f" created {path}") + + +def config_block(host, port=22, user=FRAME_USER): + return [BEGIN, f"Host {FRAME_ALIAS}", f" HostName {host}", *([f" Port {port}"] if port != 22 else []), + f" User {user}", + " IdentityFile ~/.ssh/id_ed25519_frame", " IdentityFile ~/.ssh/id_rsa_frame_devkit", + " IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END] + + +def write_config(host, port=22, user=FRAME_USER): """Replace our managed block and put it first: ssh uses the first value it sees per option. The trailing "Host *" returns the rest of the file to global scope.""" make_ssh_dir() @@ -95,10 +286,7 @@ def write_config(host, port=22): skip = False elif not skip: kept.append(line) - block = [BEGIN, f"Host {FRAME_ALIAS}", f" HostName {host}", *([f" Port {port}"] if port != 22 else []), - f" User {FRAME_USER}", - " IdentityFile ~/.ssh/id_ed25519_frame", " IdentitiesOnly yes", - " ServerAliveInterval 30", "Host *", END] + block = config_block(host, port, user) tmp = CONFIG.with_name("config.frame-control.tmp") tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8") if os.name != "nt": @@ -125,6 +313,50 @@ def key_login_works(): capture_output=True).returncode == 0 +def configured_user(): + """The User in our managed block, so a re-run keeps one the headset named earlier.""" + if not CONFIG.exists(): + return None + inside = False + for line in CONFIG.read_text(encoding="utf-8").splitlines(): + if line in (BEGIN, END): + inside = line == BEGIN + elif inside and line.startswith(" User "): + name = line[7:].strip() + return name if NAME_RE.fullmatch(name) else None + return None + + +def pair_with_devkit(host, port, user): + """Try devkit pairing and confirm key login. -> (user, None) or (user, reason to fall back).""" + say("==> Pairing through the headset's SteamOS devkit service (no password)") + chosen = [user] + + def use_login(login): + if login == chosen[0]: + return + if USER_FROM_ENV: + say(f" the headset logs in as '{login}'; keeping FRAME_USER={user}") + else: + chosen[0] = login + say(f" the headset logs in as '{login}'") + write_config(host, port, login) + + try: + pub = DEVKIT_KEY.with_suffix(".pub").read_text(encoding="utf-8") + except OSError as e: + return user, f"can't read the pairing key: {e}" + reason = devkit_pair(host, pub, key_comment(platform.node()), on_login=use_login) + if reason: + return chosen[0], reason + # The approval is what turns sshd on, so it may take a moment to answer. + for _ in range(10): + if key_login_works(): + return chosen[0], None + time.sleep(1) + return chosen[0], "paired, but key login still fails" + + def main(argv): if argv and argv[0] in ("-h", "--help"): sys.exit(__doc__) @@ -143,30 +375,32 @@ def main(argv): host, port = found say(f" found: {host}" + (f" port {port}" if port != 22 else "")) - say("==> SSH key") + say("==> SSH keys") make_ssh_dir() - if KEY.exists(): - say(f" exists: {KEY}") - else: - subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", - f"{platform.node() or 'computer'}->steam-frame", "-f", str(KEY)], check=True) - say(f" created {KEY}") + make_key(KEY, "ed25519", f"{platform.node() or 'computer'}->steam-frame") + make_key(DEVKIT_KEY, "rsa", key_comment(platform.node())) + user = FRAME_USER if USER_FROM_ENV else (configured_user() or FRAME_USER) say(f"==> ~/.ssh/config alias '{FRAME_ALIAS}' -> {host}") - write_config(host, port) + write_config(host, port, user) say("==> Checking key login") if key_login_works(): say(" key login already works") else: - say(" copying the key: enter the Developer Mode password when asked") - pub = KEY.with_suffix(".pub").read_text(encoding="utf-8").strip() - r = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "PubkeyAuthentication=no", - "-p", str(port), f"{FRAME_USER}@{host}", ADD_KEY_CMD], input=pub + "\n", text=True) - if r.returncode != 0 or not key_login_works(): - say("Key login still isn't working. Check the password and run this again.") - return 1 - say(" key login OK") + user, reason = pair_with_devkit(host, port, user) + if not reason: + say(" paired; key login OK") + else: + say(f" {reason}; falling back to the password") + say(" copying the key: enter the Developer Mode password when asked") + pub = KEY.with_suffix(".pub").read_text(encoding="utf-8").strip() + r = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "PubkeyAuthentication=no", + "-p", str(port), f"{user}@{host}", ADD_KEY_CMD], input=pub + "\n", text=True) + if r.returncode != 0 or not key_login_works(): + say("Key login still isn't working. Check the password and run this again.") + return 1 + say(" key login OK") say(f"\nDone. Frame Control can reach the Frame now. In a terminal: ssh {FRAME_ALIAS}") return 0 From dd9c009206e5a9bf1c4f7442be46e7f967a136e4 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:18:39 +1000 Subject: [PATCH 02/13] Install links for websites: frame-control://install A site can link to frame-control://install?manifest=URL (or ?url=URL) to install a title with Frame Control. Manifests use FrameDrop's format, so framedrop.install/v1 is accepted as well as frame-control.install/v1. - app/install-link.js parses links; main.js registers the scheme (plus electron-builder protocols for Info.plist and the .desktop file), takes links from open-url, second-instance argv and the first argv, and holds them until the page asks for them through preload's onInstallLink. - ui/frame_webinstall.py checks the URLs (HTTPS only; localhost over http only when the link itself is local; no userinfo; every address public, rechecked on redirects and pinned for the connection), reads the manifest, downloads with a size cap and sha256 check, and dispatch() sends .apk to frame_android and .zip/.exe to frame_titles when present. - server.py adds /api/webinstall/check, start, job and cancel behind the existing Host and X-Frame-UI guards; a start needs a one-time id from check. Downloads stop on cancel and on shutdown, and leftovers from a killed server are swept by PID. - index.html asks before anything downloads (name, source host, file, type, size, whether a sha256 was given) and shows progress. - docs/web-install.md, docs/install.html (landing page, unpublished). Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/checks.yml | 2 +- README.md | 1 + app/install-link.js | 33 +++ app/main.js | 65 ++++- app/package.json | 9 + app/preload.js | 7 + docs/install.html | 63 +++++ docs/web-install.md | 146 +++++++++++ tests/test_server.py | 21 ++ tests/test_webinstall.py | 423 +++++++++++++++++++++++++++++++ ui/frame_webinstall.py | 464 +++++++++++++++++++++++++++++++++++ ui/index.html | 125 +++++++++- ui/server.py | 192 ++++++++++++++- 13 files changed, 1541 insertions(+), 10 deletions(-) create mode 100644 app/install-link.js create mode 100644 docs/install.html create mode 100644 docs/web-install.md create mode 100644 tests/test_webinstall.py create mode 100644 ui/frame_webinstall.py diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 4f6738f..143a1f0 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -31,7 +31,7 @@ jobs: - 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-deps.js && node --check app/preload.js + run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js # The server runs on each desktop OS the app ships for, on the Python version # the app bundles (app/build/fetch-deps.js) and, on Ubuntu, a newer one. diff --git a/README.md b/README.md index 573e1ae..b21f984 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ Frame's software fits together, all checked against a real headset and labelled | [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 | +| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste | | [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build | | [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes | | [Open questions](docs/open-questions.md) | What's still unchecked | diff --git a/app/install-link.js b/app/install-link.js new file mode 100644 index 0000000..e64cce1 --- /dev/null +++ b/app/install-link.js @@ -0,0 +1,33 @@ +// Parses frame-control://install?manifest=URL and frame-control://install?url=URL +// (see docs/web-install.md). Pure, so it runs under plain node for the tests. +// This is only a first filter: ui/frame_webinstall.py applies the full URL rules +// (HTTPS, no private addresses, redirects) before anything is fetched. +const SCHEME = "frame-control"; +const MAX_LINK = 4096; +const MAX_URL = 2048; + +// {kind: "manifest" | "url", target} or null if raw isn't a usable install link. +function parseInstallLink(raw) { + if (typeof raw !== "string" || raw.length > MAX_LINK || !raw.toLowerCase().startsWith(`${SCHEME}:`)) return null; + let link; + try { link = new URL(raw); } catch { return null; } + // frame-control://install?… puts "install" in the host; accept a trailing slash too. + if (link.protocol !== `${SCHEME}:` || link.hostname !== "install" || !["", "/"].includes(link.pathname)) return null; + const keys = [...new Set(link.searchParams.keys())]; + if (keys.length !== 1 || !["manifest", "url"].includes(keys[0])) return null; + const values = link.searchParams.getAll(keys[0]); + if (values.length !== 1) return null; + const target = values[0]; + if (!target || target.length > MAX_URL) return null; + let parsed; + try { parsed = new URL(target); } catch { return null; } + if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password) return null; + return { kind: keys[0], target }; +} + +// The link among command-line arguments (Windows and Linux pass it there). +function linkFromArgv(argv) { + return (argv || []).find((a) => typeof a === "string" && a.toLowerCase().startsWith(`${SCHEME}:`)) || null; +} + +module.exports = { SCHEME, parseInstallLink, linkFromArgv }; diff --git a/app/main.js b/app/main.js index 1bfdc48..afabafc 100644 --- a/app/main.js +++ b/app/main.js @@ -9,6 +9,7 @@ const http = require("http"); const net = require("net"); const os = require("os"); const path = require("path"); +const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link"); const run = promisify(execFile); @@ -233,11 +234,55 @@ async function firstRunCheck() { if (response === 0) setUpConnection(); } -ipcMain.handle("clipboard:read", (e) => { - if (!win || e.sender !== win.webContents || !url || new URL(e.senderFrame.url).origin !== new URL(url).origin) return ""; - return clipboard.readText(); +// IPC only from our own page in our own window. +function fromUi(e) { + return !!(win && e.sender === win.webContents && url && e.senderFrame + && new URL(e.senderFrame.url).origin === new URL(url).origin); +} + +ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : ""); + +// frame-control://install links from websites (docs/web-install.md). They can +// arrive before the window or server exists (macOS open-url on a cold launch), +// so they wait here until the page asks for them. The page checks the link with +// the server and installs nothing until the user confirms in its dialog. +const pendingLinks = []; +let linkPage = null; // the webContents whose current page is listening + +function openInstallLink(raw) { + const req = parseInstallLink(raw); + if (!req) { + app.whenReady().then(() => dialog.showErrorBox("Frame Control can't use this link", + "Install links look like frame-control://install?manifest=https://… or frame-control://install?url=https://…")); + return; + } + pendingLinks.push(req); + if (pendingLinks.length > 5) pendingLinks.shift(); // a page opening links in a loop + deliverLinks(); + if (win) { if (win.isMinimized()) win.restore(); win.focus(); } +} + +function deliverLinks() { + if (!win || !linkPage || linkPage !== win.webContents) return; + while (pendingLinks.length) win.webContents.send("install-link", pendingLinks.shift()); +} + +ipcMain.on("install-link:ready", (e) => { + if (!fromUi(e)) return; + linkPage = e.sender; + deliverLinks(); }); +function registerScheme() { + // A checkout runs as `electron .`, so the OS must be told the script too. + // (macOS takes the scheme from Info.plist, which only the built app has.) + if (process.defaultApp) { + if (process.argv.length >= 2) app.setAsDefaultProtocolClient(SCHEME, process.execPath, [path.resolve(process.argv[1])]); + } else { + app.setAsDefaultProtocolClient(SCHEME); + } +} + function createWindow() { win = new BrowserWindow({ width: 1400, height: 950, minWidth: 760, minHeight: 560, @@ -257,7 +302,9 @@ function createWindow() { win.webContents.on("will-navigate", (e, target) => { if (!url || new URL(target).origin !== new URL(url).origin) e.preventDefault(); }); - win.on("closed", () => { win = null; }); + // A reload or a new page must ask for links again before it gets any. + win.webContents.on("did-start-loading", () => { linkPage = null; }); + win.on("closed", () => { win = null; linkPage = null; }); load(); } @@ -323,10 +370,18 @@ function buildMenu() { if (!app.requestSingleInstanceLock()) { app.quit(); } else { - app.on("second-instance", () => { + // macOS delivers install links here, even before the app is ready. + app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); }); + // Windows and Linux start a second instance with the link as an argument. + app.on("second-instance", (_e, argv) => { if (win) { if (win.isMinimized()) win.restore(); win.focus(); } + const link = linkFromArgv(argv); + if (link) openInstallLink(link); }); + const firstLink = IS_MAC ? null : linkFromArgv(process.argv); + if (firstLink) openInstallLink(firstLink); app.whenReady().then(() => { + registerScheme(); buildMenu(); createWindow(); }); diff --git a/app/package.json b/app/package.json index 944d660..8356381 100644 --- a/app/package.json +++ b/app/package.json @@ -21,6 +21,14 @@ "build": { "appId": "com.saphid.frame-control", "productName": "Frame Control", + "protocols": [ + { + "name": "Frame Control install link", + "schemes": [ + "frame-control" + ] + } + ], "directories": { "output": "dist", "buildResources": "build" @@ -28,6 +36,7 @@ "files": [ "main.js", "preload.js", + "install-link.js", "package.json", "build/icon.png" ], diff --git a/app/preload.js b/app/preload.js index b921279..dffcdd0 100644 --- a/app/preload.js +++ b/app/preload.js @@ -1,7 +1,14 @@ // Lets the page read this computer's clipboard through Electron, so sending it // to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. +// It also receives frame-control://install links (docs/web-install.md): only +// what the link asked for, never an install; the page asks the user first. const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("frameApp", { readClipboard: () => ipcRenderer.invoke("clipboard:read"), + onInstallLink: (cb) => { + ipcRenderer.removeAllListeners("install-link"); + ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target })); + ipcRenderer.send("install-link:ready"); + }, }); diff --git a/docs/install.html b/docs/install.html new file mode 100644 index 0000000..4948d26 --- /dev/null +++ b/docs/install.html @@ -0,0 +1,63 @@ + + + + + + +Install with Frame Control + + + + +
+

Install with Frame Control

+

+ + + +
+ + + diff --git a/docs/web-install.md b/docs/web-install.md new file mode 100644 index 0000000..98c0365 --- /dev/null +++ b/docs/web-install.md @@ -0,0 +1,146 @@ +# Install links for websites + +A website can put an "Install with Frame Control" button next to its download. +Clicking it opens Frame Control, which shows what the link wants to install and +asks the user. Only after they click **Install** does it download the file and +install it on the Frame. + +What's verified: the link parsing, URL rules, manifest parsing, download, +size cap and sha256 check, by `tests/test_webinstall.py` and +`tests/test_server.py` (no network: a stub server on 127.0.0.1). Installing on +the headset is the same code as dropping a file on Frame Control: `.apk` files go +to the APK installer ([apks.md](apks.md)), `.zip` and `.exe` files to the +Linux/Windows title installer. A link hasn't been clicked through to a headset +install yet. + +## The link + +``` +frame-control://install?manifest= +frame-control://install?url= +``` + +Use `manifest` when you can: it carries the title's name and a sha256, which +Frame Control checks before installing. `url` is for a file on its own; the +dialog then names the title after the file. + +The manifest is FrameDrop's format, so one manifest serves both apps. The +schema may be `framedrop.install/v1` or `frame-control.install/v1`: + +```json +{ + "schema": "framedrop.install/v1", + "name": "My Game", + "files": [ + { "url": "https://cdn.example.com/mygame-arm64.apk", "sha256": "optional-but-better" } + ] +} +``` + +| Field | | +|---|---| +| `schema` | Required, one of the two above | +| `name` | Shown in the confirm dialog (at most 120 characters). Defaults to the file name. APKs are still named in the Steam library by their own label | +| `files` | Exactly one entry for now; more is refused with a message | +| `files[0].url` | Required. The file to install | +| `files[0].sha256` | Optional, 64 hex digits. The download must match or nothing is installed | +| `files[0].size` | Optional (Frame Control extension), bytes. Shown up front; the download must match | +| `files[0].exe` | Optional (Frame Control extension), for a `.zip` title: the program inside it to run | + +What gets installed depends on the file name's extension: + +| File | Installed as | +|---|---| +| `.apk` | An Android app in its own Lepton instance with a Steam shortcut ([apks.md](apks.md)) | +| `.zip`, `.exe` | A Linux or Windows title. Versions of Frame Control without the title installer say "Linux/Windows titles need a newer Frame Control" | +| anything else | Refused | + +## Rules + +Frame Control refuses a link, and downloads nothing, unless: + +- Every URL (the manifest's, the file's and each redirect) is `https://`. + `http://` works only for `localhost` or `127.0.0.1`, for testing, and only + when the link itself points there: a public manifest can't send Frame + Control to your own computer. +- No URL has a user name or password in it (`https://user:pw@…`). +- No host is, or resolves to, a private, loopback, link-local, CGNAT + (100.64.0.0/10), multicast or otherwise non-public address. Every address + the name has must be public, it's checked again on every redirect (at most + 5), and the download connects to the address that was checked. +- The file URL ends in a file name with one of the extensions above + (`https://example.com/games/` is refused). +- The manifest is JSON of at most 256 KB, and the file at most 4 GiB + (`MAX_MANIFEST` and `MAX_FILE` in `ui/frame_webinstall.py`). +- The user confirms. The dialog shows the title's name, the site the link came + from (and the file's host if different), the file name and type, the size if + known, and whether a sha256 was given. + +A web page can't install anything itself: it can only open the link. Frame +Control's local server refuses requests from web pages, so the only way in is +the operating system handing the link to the app, then the user's click. + +## Button for your site + +Paste this where the download is, with your manifest's URL in `MANIFEST`: + +```html +Install with Frame Control + +``` + +For a single file, use `"frame-control://install?url=" + encodeURIComponent(FILE_URL)`. + +`docs/install.html` is a landing page that does the same from a plain link: +`install.html?manifest=` tries the app and shows a "Get Frame +Control" link. It isn't published anywhere yet; host a copy to use it. + +## Testing locally + +Serve the manifest and file from your own computer: + +```sh +cd mygame && python3 -m http.server 8000 +open 'frame-control://install?manifest=http%3A%2F%2Flocalhost%3A8000%2Fmanifest.json' # xdg-open on Linux, start "" on Windows +``` + +The manifest's file URL must then be `http://localhost:8000/…` or +`http://127.0.0.1:8000/…` too. + +## How it works + +- `app/install-link.js` parses the link (only `frame-control://install` with + exactly one `manifest` or `url`); `app/main.js` registers the scheme + (`app.setAsDefaultProtocolClient`, and electron-builder's `protocols` for the + macOS Info.plist and the Linux `.desktop` file). macOS delivers links through + `open-url`, Windows and Linux as an argument to a second instance. Links + wait in the main process until the page has loaded and asked for them + (`frameApp.onInstallLink` in `app/preload.js`). `framedrop://` is left alone. +- The page posts the link to `/api/webinstall/check`, which reads the manifest, + applies the rules, asks the file's size with a HEAD request and returns a + one-time id. Nothing is downloaded. +- **Install** posts the id to `/api/webinstall/start`. The server downloads to + a temporary folder (progress at `/api/webinstall/job`, cancellable with + `/api/webinstall/cancel`), checks size and sha256, hands the file to + `frame_webinstall.dispatch()` and deletes the folder. +- The app registers the scheme each time it starts, so the last Frame Control + started (e.g. a development checkout) handles the links. diff --git a/tests/test_server.py b/tests/test_server.py index 5511184..942969e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -130,6 +130,27 @@ class ServerGuards(unittest.TestCase): status, _ = self.post("/api/launch", ["not", "an", "object"]) self.assertEqual(status, 400) + def test_web_install_needs_the_app_page(self): + # A website can only open frame-control:// links; it can't call these itself. + link = {"url": "https://cdn.example.com/game.apk"} + self.assertEqual(self.request("POST", "/api/webinstall/check", link)[0], 403) + self.assertEqual(self.request("POST", "/api/webinstall/start", {"id": "x"})[0], 403) + status, _, _ = self.request("POST", "/api/webinstall/check", link, + {"X-Frame-UI": "1", "Host": f"evil.example:{self.port}"}) + self.assertEqual(status, 403) + + def test_web_install_validation(self): + for body in ({}, {"url": 5}, {"url": "http://cdn.example.com/game.apk"}, {"url": "https://10.0.0.2/game.apk"}, + {"url": "https://u:p@example.com/game.apk"}, {"url": "https://example.com/"}, + {"url": "https://1.1.1.1/game.sh"}, {"manifest": "file:///etc/passwd"}, + {"manifest": "https://example.com/m.json", "url": "https://example.com/g.apk"}): + status, payload = self.post("/api/webinstall/check", body) + self.assertEqual(status, 400, f"{body} -> {payload}") + # Only an id from /check starts an install, and only once. + self.assertEqual(self.post("/api/webinstall/start", {"id": "made-up"})[0], 400) + self.assertEqual(self.request("GET", "/api/webinstall/job?id=x", headers={"X-Frame-UI": "1"})[0], 404) + self.assertEqual(self.post("/api/webinstall/cancel", {"job": "x"})[0], 404) + def test_unknown_routes(self): self.assertEqual(self.request("GET", "/nope")[0], 404) self.assertEqual(self.post("/api/nope", {})[0], 404) diff --git a/tests/test_webinstall.py b/tests/test_webinstall.py new file mode 100644 index 0000000..0199e17 --- /dev/null +++ b/tests/test_webinstall.py @@ -0,0 +1,423 @@ +"""Install links from websites (ui/frame_webinstall.py, app/install-link.js). No network: +name lookups are stubbed and downloads come from a server on 127.0.0.1, which +the localhost-testing rule allows. + +Run: python3 -m unittest discover -s tests +""" +import hashlib +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) + +import frame_webinstall as wi # noqa: E402 + +E = wi.WebInstallError +PAYLOAD = b"not really an apk, but bytes are bytes\n" * 1000 + + +def fake_dns(*ips): + return lambda host, port, **_: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port)) for ip in ips] + + +class Urls(unittest.TestCase): + def test_https_ok(self): + self.assertEqual(wi.check_url("https://cdn.example.com/g/mygame.apk"), ("https", "cdn.example.com", 443, False)) + self.assertEqual(wi.file_name("https://cdn.example.com/g/my%20game.apk?sig=1"), "my game.apk") + + def test_http_only_for_localhost(self): + with self.assertRaises(E): + wi.check_url("http://cdn.example.com/mygame.apk") + self.assertTrue(wi.check_url("http://localhost:8000/mygame.apk", allow_local=True)[3]) + self.assertTrue(wi.check_url("http://127.0.0.1:8000/mygame.apk", allow_local=True)[3]) + + def test_localhost_only_when_the_link_starts_there(self): + for url in ("http://localhost/x.apk", "https://127.0.0.1/x.apk"): + with self.assertRaises(E): + wi.check_url(url, allow_local=False) + + def test_other_schemes_rejected(self): + for url in ("file:///etc/passwd", "ftp://example.com/x.apk", "javascript:alert(1)", "//example.com/x.apk", ""): + with self.assertRaises(E, msg=url): + wi.check_url(url) + + def test_private_and_local_addresses_rejected(self): + for host in ("10.0.0.5", "192.168.1.20", "172.16.3.4", "127.0.0.2", "169.254.169.254", "100.64.1.1", + "0.0.0.0", "[::1]", "[fe80::1]", "[fd00::1]", "[fec0::1]", "[::ffff:192.168.1.1]", + "[2002:c0a8:101::1]", "224.0.0.1"): + with self.assertRaises(E, msg=host): + wi.check_url(f"https://{host}/x.apk") + wi.check_url("https://93.184.216.34/x.apk") + + def test_names_resolving_to_private_addresses_rejected(self): + with mock.patch.object(wi, "_getaddrinfo", fake_dns("192.168.1.9")): + with self.assertRaises(E): + wi._resolve("sneaky.example.com", 443, False) + # Every address counts, not just the first. + with mock.patch.object(wi, "_getaddrinfo", fake_dns("93.184.216.34", "10.1.2.3")): + with self.assertRaises(E): + wi._resolve("mixed.example.com", 443, False) + with mock.patch.object(wi, "_getaddrinfo", fake_dns("93.184.216.34")): + self.assertEqual(wi._resolve("cdn.example.com", 443, False), "93.184.216.34") + + def test_credentials_rejected(self): + for url in ("https://user:pw@example.com/x.apk", "https://user@example.com/x.apk", "https://:pw@example.com/x.apk"): + with self.assertRaises(E, msg=url): + wi.check_url(url) + + def test_directory_urls_rejected(self): + for url in ("https://example.com/", "https://example.com", "https://example.com/games/", + "https://example.com/%2e%2e", "https://example.com/.hidden.apk", "https://example.com/a%2Fb.apk"): + with self.assertRaises(E, msg=url): + wi.file_name(url) + + def test_file_types(self): + self.assertEqual(wi.file_kind("Game.APK"), "apk") + self.assertEqual(wi.file_kind("game.zip"), "title") + self.assertEqual(wi.file_kind("setup.exe"), "title") + for name in ("game.sh", "game.tar.gz", "game"): + with self.assertRaises(E, msg=name): + wi.file_kind(name) + + +class Manifests(unittest.TestCase): + FILE = {"url": "https://cdn.example.com/mygame-arm64.apk"} + + def test_both_schemas(self): + for schema in ("framedrop.install/v1", "frame-control.install/v1"): + m = wi.parse_manifest({"schema": schema, "name": "My Game", "files": [dict(self.FILE, sha256="AB" * 32)]}) + self.assertEqual(m["name"], "My Game") + self.assertEqual(m["file"]["url"], self.FILE["url"]) + self.assertEqual(m["file"]["sha256"], "ab" * 32) + + def test_bad_schema(self): + for schema in (None, "framedrop.install/v2", "something"): + with self.assertRaises(E, msg=schema): + wi.parse_manifest({"schema": schema, "files": [self.FILE]}) + + def test_missing_or_bad_fields(self): + base = {"schema": "framedrop.install/v1"} + for obj in ([], base, dict(base, files=[]), dict(base, files="x"), dict(base, files=[{}]), + dict(base, files=[{"url": ""}]), dict(base, files=[dict(self.FILE, sha256="abc")]), + dict(base, files=[dict(self.FILE, size=-1)]), dict(base, name=5, files=[self.FILE])): + with self.assertRaises(E, msg=obj): + wi.parse_manifest(obj) + + def test_name_optional_and_cleaned(self): + self.assertIsNone(wi.parse_manifest({"schema": "framedrop.install/v1", "files": [self.FILE]})["name"]) + m = wi.parse_manifest({"schema": "framedrop.install/v1", "name": " A\x1b[31mB\n ", "files": [self.FILE]}) + self.assertEqual(m["name"], "A[31mB") + + def test_multiple_files_refused_clearly(self): + with self.assertRaisesRegex(E, "2 files"): + wi.parse_manifest({"schema": "framedrop.install/v1", "files": [self.FILE, self.FILE]}) + + +class Stub(BaseHTTPRequestHandler): + routes = {} + + def log_message(self, *_): + pass + + def do_HEAD(self): + self.do_GET(body=False) + + def do_GET(self, body=True): + route = self.routes.get(self.path) + if route is None: + self.send_response(404) + self.end_headers() + return + status, headers, data = route + self.send_response(status) + for k, v in headers.items(): + self.send_header(k, v) + if "Content-Length" not in headers: + self.send_header("Content-Length", str(len(data))) + self.end_headers() + if body: + self.wfile.write(data) + + +class Downloads(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Stub) + cls.base = f"http://127.0.0.1:{cls.httpd.server_address[1]}" + threading.Thread(target=cls.httpd.serve_forever, daemon=True).start() + sha = hashlib.sha256(PAYLOAD).hexdigest() + Stub.routes = { + "/game.apk": (200, {}, PAYLOAD), + "/game.zip": (200, {}, PAYLOAD), + "/redirect.apk": (302, {"Location": "/game.apk"}, b""), + "/to-lan.apk": (302, {"Location": "https://192.168.1.5/game.apk"}, b""), + "/to-http.apk": (302, {"Location": "http://cdn.example.com/game.apk"}, b""), + "/loop.apk": (302, {"Location": "/loop.apk"}, b""), + "/manifest.json": (200, {}, json.dumps({"schema": "framedrop.install/v1", "name": "Stub Game", + "files": [{"url": f"{cls.base}/game.apk", "sha256": sha}]}).encode()), + "/bad-sha.json": (200, {}, json.dumps({"schema": "frame-control.install/v1", "name": "Bad", + "files": [{"url": f"{cls.base}/game.apk", "sha256": "0" * 64}]}).encode()), + "/huge.json": (200, {}, b"{" + b" " * (wi.MAX_MANIFEST + 10) + b"}"), + "/notjson.json": (200, {}, b""), + "/short.apk": (200, {"Content-Length": str(len(PAYLOAD) + 100)}, PAYLOAD), + } + + @classmethod + def tearDownClass(cls): + cls.httpd.shutdown() + cls.httpd.server_close() + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_manifest_round_trip(self): + p = wi.plan(manifest=f"{self.base}/manifest.json") + self.assertEqual((p["name"], p["file"], p["kind"], p["host"], p["size"]), + ("Stub Game", "game.apk", "apk", "127.0.0.1", len(PAYLOAD))) + seen = [] + path = wi.download(p, self.tmp, progress=lambda done, total: seen.append((done, total))) + self.assertEqual(Path(path).read_bytes(), PAYLOAD) + self.assertEqual(seen[-1], (len(PAYLOAD), len(PAYLOAD))) + self.assertEqual(os.listdir(self.tmp), ["game.apk"]) + + def test_direct_url_and_redirect(self): + p = wi.plan(url=f"{self.base}/redirect.apk") + self.assertEqual((p["name"], p["file"]), ("redirect.apk", "redirect.apk")) + self.assertEqual(Path(wi.download(p, self.tmp)).read_bytes(), PAYLOAD) + + def test_redirects_checked_again(self): + for path in ("/to-lan.apk", "/to-http.apk", "/loop.apk"): + with self.assertRaises(E, msg=path): + wi._open(f"{self.base}{path}", allow_local=True) + + def test_sha256_mismatch_leaves_nothing(self): + p = wi.plan(manifest=f"{self.base}/bad-sha.json") + with self.assertRaisesRegex(E, "sha256"): + wi.download(p, self.tmp) + self.assertEqual(os.listdir(self.tmp), []) + + def test_size_cap(self): + with mock.patch.object(wi, "MAX_FILE", 1000): + with self.assertRaisesRegex(E, "limit"): + wi.plan(url=f"{self.base}/game.apk") + p = {"url": f"{self.base}/game.apk", "file": "game.apk", "allowLocal": True, "size": None, "sha256": None} + with self.assertRaisesRegex(E, "limit"): + wi.download(p, self.tmp) + self.assertEqual(os.listdir(self.tmp), []) + + def test_bad_manifests(self): + for path in ("/huge.json", "/notjson.json", "/missing.json"): + with self.assertRaises(E, msg=path): + wi.plan(manifest=f"{self.base}{path}") + + def test_cut_off_download(self): + p = {"url": f"{self.base}/short.apk", "file": "short.apk", "allowLocal": True, "size": None, "sha256": None} + with self.assertRaises(E): + wi.download(p, self.tmp) + self.assertEqual(os.listdir(self.tmp), []) + + def test_aborted_connection_never_connects(self): + port = self.httpd.server_address[1] + for cls in (wi._HTTPConnection, wi._HTTPSConnection): + conn = cls("127.0.0.1", "127.0.0.1", port, 5) + wi.abort(conn) # before connect, e.g. cancelled while looking up the name + with self.assertRaisesRegex(OSError, "aborted"): + conn.connect() + + def test_cancel(self): + p = wi.plan(url=f"{self.base}/game.apk") + with self.assertRaises(wi.Cancelled): + wi.download(p, self.tmp, cancelled=lambda: True) + self.assertEqual(os.listdir(self.tmp), []) + + +class Dispatch(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def file(self, name): + path = os.path.join(self.tmp, name) + Path(path).write_bytes(PAYLOAD) + return path + + def test_apk_goes_to_the_android_installer(self): + import frame_android + with mock.patch.object(frame_android, "install", return_value={"label": "Stub"}) as install: + res = wi.dispatch(self.file("game.apk"), name="Ignored", source="https://example.com/game.apk") + install.assert_called_once_with(os.path.join(self.tmp, "game.apk"), source="https://example.com/game.apk") + self.assertEqual(res["kind"], "apk") + self.assertIn("Stub", res["message"]) + + def test_titles_without_the_titles_module(self): + with mock.patch.dict(sys.modules, {"frame_titles": None}): + with self.assertRaisesRegex(E, "newer Frame Control"): + wi.dispatch(self.file("game.zip")) + + def test_titles_go_to_frame_titles(self): + fake = mock.Mock() + fake.install.return_value = {"message": "Installed Stub"} + with mock.patch.dict(sys.modules, {"frame_titles": fake}): + res = wi.dispatch(self.file("game.exe"), name="Stub", exe=None) + fake.install.assert_called_once_with(os.path.join(self.tmp, "game.exe"), name="Stub", exe=None, progress=None) + self.assertEqual(res["message"], "Installed Stub") + + def test_other_files_refused(self): + with self.assertRaises(E): + wi.dispatch(self.file("game.sh")) + + +class ServerJobs(unittest.TestCase): + """The server's install worker (ui/server.py), with download and dispatch stubbed.""" + + @classmethod + def setUpClass(cls): + with mock.patch.dict(os.environ, {"FRAME_ALIAS": "frame-control-test.invalid"}): + import server + cls.server = server + + def run_job(self, download=None, mkdtemp_error=None): + s = self.server + job = {"phase": "download", "done": 0, "total": None, "detail": "", "message": None, "error": None, "cancel": False} + plan = {"name": "Stub", "exe": None, "url": "https://example.com/stub.apk"} + with mock.patch.object(s, "ensure_master"), \ + mock.patch.object(s.frame_webinstall, "download", side_effect=lambda *a, **k: download(job, a[1])), \ + mock.patch.object(s.tempfile, "mkdtemp", side_effect=mkdtemp_error or tempfile.mkdtemp), \ + mock.patch.object(s.frame_webinstall, "dispatch", return_value={"message": "ok"}) as dispatch: + s._webinstall_run(plan, job) + return job, dispatch + + def test_cancel_after_the_last_chunk_still_stops_the_install(self): + def download(job, tmp): + job["cancel"] = True # arrives after the downloader's last check + return os.path.join(tmp, "stub.apk") + job, dispatch = self.run_job(download) + dispatch.assert_not_called() + self.assertEqual(job["phase"], "error") + + def test_finished_download_is_dispatched(self): + job, dispatch = self.run_job(lambda job, tmp: os.path.join(tmp, "stub.apk")) + dispatch.assert_called_once() + self.assertEqual((job["phase"], job["message"]), ("done", "ok")) + + def stall_then_shutdown(self, scheme, reply): + """Start a download from a server that stalls after sending reply; shutdown must stop it quickly.""" + stall = socket.socket() + stall.bind(("127.0.0.1", 0)) + stall.listen(1) + port = stall.getsockname()[1] + stalled = threading.Event() + + def serve(): + c, _ = stall.accept() + if reply is not None: + c.recv(65536) + c.sendall(reply) + stalled.set() + time.sleep(20) # longer than the test may take; TIMEOUT is 30 s + c.close() + threading.Thread(target=serve, daemon=True).start() + s = self.server + pid = "shutdown-test" + s._web_plans[pid] = {"name": "Stub", "exe": None, "url": f"{scheme}://127.0.0.1:{port}/stub.apk", + "file": "stub.apk", "allowLocal": True, "size": None, "sha256": None, + "sizeFromManifest": False} + try: + s.webinstall_start({"id": pid}) + job = s._web_jobs[pid] + self.assertTrue(stalled.wait(5)) + time.sleep(0.1) # let the client block + t0 = time.time() + s.webinstall_shutdown() + self.assertLess(time.time() - t0, 3) + self.assertEqual(s._web_workers, set()) + self.assertEqual((job["phase"], job["error"]), ("error", "download cancelled")) + s._web_plans["late"] = {"size": None} + with self.assertRaises(s.Failure) as caught: # nothing new starts once quitting + s.webinstall_start({"id": "late"}) + self.assertEqual(caught.exception.status, 503) + finally: + s._web_closing = False + s._web_jobs.clear() + s._web_plans.clear() + stall.close() + + def test_shutdown_interrupts_a_stalled_body(self): + self.stall_then_shutdown("http", b"HTTP/1.0 200 OK\r\nContent-Length: 1000000\r\n\r\npartial") + + def test_shutdown_interrupts_stalled_headers(self): + self.stall_then_shutdown("http", b"HTTP/1.1 200 OK\r\n") + + def test_shutdown_interrupts_a_stalled_tls_handshake(self): + self.stall_then_shutdown("https", None) + + def test_dead_servers_leftovers_swept(self): + dead = subprocess.Popen([sys.executable, "-c", "pass"]) + dead.wait() + prefix = self.server.WEB_TMP_PREFIX + gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-") + live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-") + try: + self.server.sweep_webinstall_tmp() + self.assertFalse(os.path.exists(gone)) + self.assertTrue(os.path.exists(live)) + finally: + shutil.rmtree(gone, ignore_errors=True) + shutil.rmtree(live, ignore_errors=True) + + def test_temp_dir_failure_ends_the_job(self): + job, dispatch = self.run_job(mkdtemp_error=OSError("disk full")) + dispatch.assert_not_called() + self.assertEqual(job["phase"], "error") + self.assertIn("disk full", job["error"]) + + +@unittest.skipUnless(shutil.which("node"), "needs node") +class LinkParsing(unittest.TestCase): + def parse(self, links): + script = ("const { parseInstallLink, linkFromArgv } = require(process.argv[1]);" + "const links = JSON.parse(process.argv[2]);" + "console.log(JSON.stringify({ parsed: links.map(parseInstallLink)," + " argv: linkFromArgv(['/x/frame-control', '--flag', links[0]]) }));") + out = subprocess.run(["node", "-e", script, str(ROOT / "app" / "install-link.js"), json.dumps(links)], + capture_output=True, text=True, timeout=30) + self.assertEqual(out.returncode, 0, out.stderr) + return json.loads(out.stdout) + + def test_links(self): + m = "https://example.com/m.json" + good = ["frame-control://install?manifest=" + "https%3A%2F%2Fexample.com%2Fm.json", + "frame-control://install/?url=https%3A%2F%2Fcdn.example.com%2Fg.apk", + "FRAME-CONTROL://install?manifest=http%3A%2F%2Flocalhost%3A8000%2Fm.json"] + bad = ["framedrop://install?manifest=" + m, "frame-control://uninstall?manifest=" + m, + "frame-control://install?manifest=" + m + "&url=" + m, "frame-control://install?manifest=a&manifest=b", + "frame-control://install?manifest=file%3A%2F%2F%2Fetc%2Fpasswd", "frame-control://install?other=" + m, + "frame-control://install?url=https%3A%2F%2Fu%3Ap%40example.com%2Fg.apk", "frame-control://install", + "frame-control://install/sub?url=" + m, "https://example.com"] + res = self.parse(good + bad) + self.assertEqual(res["parsed"][0], {"kind": "manifest", "target": m}) + self.assertEqual(res["parsed"][1], {"kind": "url", "target": "https://cdn.example.com/g.apk"}) + self.assertEqual(res["parsed"][2]["kind"], "manifest") + self.assertEqual(res["parsed"][len(good):], [None] * len(bad)) + self.assertEqual(res["argv"], good[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/frame_webinstall.py b/ui/frame_webinstall.py new file mode 100644 index 0000000..f27eebb --- /dev/null +++ b/ui/frame_webinstall.py @@ -0,0 +1,464 @@ +"""Install links from websites: frame-control://install?manifest=URL or ?url=URL. + +The app hands the link to the page, the page shows what it will install and +asks the user first, and only then does this module download the file and pass +it to the installer for its type (dispatch()). See docs/web-install.md. + +A manifest is the same JSON FrameDrop uses, so one works for both tools: + {"schema": "framedrop.install/v1", "name": "My Game", + "files": [{"url": "https://cdn.example.com/mygame-arm64.apk", "sha256": "..."}]} +"frame-control.install/v1" is accepted with the same shape. + +Rules: HTTPS only, except http(s)://localhost or 127.0.0.1 for testing, and then +only when the link itself points there. No credentials in URLs, no private, +loopback, link-local or CGNAT addresses (checked on every redirect, and the +connection goes to the address that was checked, so DNS can't change it in +between). The file URL must end in a file name. + +Python stdlib only, 3.9 compatible. +""" +import hashlib +import http.client +import ipaddress +import json +import os +import errno +import re +import select +import socket +import ssl +import tempfile +import time +from urllib.parse import unquote, urljoin, urlsplit + +SCHEMAS = ("framedrop.install/v1", "frame-control.install/v1") +MAX_FILE = 4 * 1024**3 # largest download accepted +MAX_MANIFEST = 256 * 1024 # largest manifest accepted +MAX_URL = 2048 +MAX_REDIRECTS = 5 +TIMEOUT = 30 # seconds per socket operation +CHUNK = 1 << 20 +LOCAL_HOSTS = ("localhost", "127.0.0.1") +USER_AGENT = "FrameControl (+https://github.com/saphid/steam-frame)" +# What dispatch() can install, by file extension. +KINDS = {".apk": "apk", ".zip": "title", ".exe": "title"} +KIND_LABEL = {"apk": "Android app (APK)", "title": "Linux/Windows title"} +SHA256 = re.compile(r"[0-9a-fA-F]{64}") +CGNAT = ipaddress.ip_network("100.64.0.0/10") +# connect_ex() results meaning "still connecting" (the last is Windows' WSAEWOULDBLOCK). +_CONNECTING = {errno.EINPROGRESS, errno.EWOULDBLOCK, errno.EALREADY, getattr(errno, "WSAEWOULDBLOCK", 10035)} + +# Swapped out by the tests, which have no network. +_getaddrinfo = socket.getaddrinfo + + +class WebInstallError(Exception): + pass + + +class Cancelled(WebInstallError): + pass + + +# ---- URLs ------------------------------------------------------------------- + +def is_public(ip): + """True for addresses on the public internet, and nothing a LAN or this computer uses.""" + ip = ipaddress.ip_address(ip) + if ip.version == 6: + if ip.ipv4_mapped: + ip = ip.ipv4_mapped + elif ip.is_site_local: # fec0::/10: deprecated, but is_global doesn't catch it + return False + elif ip.sixtofour and not is_public(ip.sixtofour): + return False + if ip.version == 4 and ip in CGNAT: + return False + return ip.is_global and not ip.is_multicast + + +def check_url(url, allow_local=False): + """Validate a URL against the rules above; returns (scheme, host, port, is_local). + + Resolving the name is left to connect time (see _resolve), so this needs no network. + """ + if not isinstance(url, str) or not url or len(url) > MAX_URL: + raise WebInstallError("the link must be a URL of at most %d characters" % MAX_URL) + if any(c.isspace() or ord(c) < 32 for c in url): + raise WebInstallError("the URL has spaces or control characters in it") + try: + u = urlsplit(url) + port = u.port + except ValueError as e: + raise WebInstallError(f"not a valid URL: {e}") + scheme = u.scheme.lower() + if scheme not in ("https", "http"): + raise WebInstallError(f"only https:// links are allowed, not {scheme or 'a relative URL'}") + if u.username is not None or u.password is not None or "@" in u.netloc: + raise WebInstallError("URLs with a user name or password in them aren't allowed") + host = (u.hostname or "").lower().rstrip(".") + if not host: + raise WebInstallError("the URL has no host") + local = host in LOCAL_HOSTS + if local and not allow_local: + raise WebInstallError("localhost is only allowed when the link itself points there (for testing)") + if scheme == "http" and not local: + raise WebInstallError("only https:// is allowed (http:// only for localhost while testing)") + if not local: + try: + literal = ipaddress.ip_address(host) + except ValueError: + literal = None + if literal is not None and not is_public(literal): + raise WebInstallError(f"{host} is a private or local address") + return scheme, host, port or (443 if scheme == "https" else 80), local + + +def file_name(url): + """The file name the URL ends in, e.g. mygame-arm64.apk.""" + path = urlsplit(url).path + name = unquote(path.rsplit("/", 1)[-1]) + if not name or name in (".", "..") or "/" in name or "\\" in name or name.startswith(".") \ + or any(ord(c) < 32 for c in name) or len(name) > 200: + raise WebInstallError("the file URL must end in a file name, e.g. https://example.com/mygame.apk") + return name + + +def file_kind(name): + ext = os.path.splitext(name.lower())[1] + kind = KINDS.get(ext) + if not kind: + raise WebInstallError(f"{name}: Frame Control installs .apk, .zip and .exe files, not {ext or 'this type'}") + return kind + + +def _resolve(host, port, local): + """One address to connect to; every address the name has must be public.""" + if local: + return "127.0.0.1" + try: + infos = _getaddrinfo(host, port, type=socket.SOCK_STREAM) + except (OSError, UnicodeError) as e: + raise WebInstallError(f"couldn't look up {host}: {e}") + ips = [info[4][0].split("%", 1)[0] for info in infos] + if not ips: + raise WebInstallError(f"couldn't look up {host}") + for ip in ips: + if not is_public(ip): + raise WebInstallError(f"{host} points to a private or local address ({ip})") + return ips[0] + + +# ---- HTTP ------------------------------------------------------------------- + +class _Abortable: + """Connects to an address checked beforehand, whatever DNS says by then. + + raw_sock is the socket to shut down to stop the connection from another + thread (abort()): http.client drops conn.sock once a response will close + the connection, yet keeps reading the body from it. + """ + raw_sock = None + aborted = False + + def _tcp(self): + """Connect without blocking, so abort() can stop a connect that hangs.""" + sock = socket.socket(socket.AF_INET6 if ":" in self._ip else socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setblocking(False) + err = sock.connect_ex((self._ip, self.port)) + deadline = time.monotonic() + self.timeout + while err in _CONNECTING: + if self.aborted: + raise OSError("aborted") + if time.monotonic() > deadline: + raise socket.timeout(f"timed out connecting to {self.host}") + _, writable, failed = select.select([], [sock], [sock], 0.2) + if writable or failed: + err = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) + if err: + raise OSError(err, os.strerror(err)) + sock.settimeout(self.timeout) + self.raw_sock = sock + if self.aborted: # abort() ran just now and found nothing to shut down + raise OSError("aborted") + except BaseException: + sock.close() + raise + return sock + + +class _HTTPConnection(_Abortable, http.client.HTTPConnection): + def __init__(self, host, ip, port, timeout): + super().__init__(host, port, timeout=timeout) + self._ip = ip + + def connect(self): + self.sock = self._tcp() + + +class _HTTPSConnection(_Abortable, http.client.HTTPSConnection): + """As above, still verifying the certificate for the host name.""" + + def __init__(self, host, ip, port, timeout): + super().__init__(host, port, timeout=timeout, context=ssl.create_default_context()) + self._ip = ip + + def connect(self): + # Wrapping detaches the plain socket, so publish the TLS one before the handshake. + sock = self._context.wrap_socket(self._tcp(), server_hostname=self.host, do_handshake_on_connect=False) + self.raw_sock = sock + try: + if self.aborted: + raise OSError("aborted") + sock.do_handshake() + except (AttributeError, ValueError) as e: + # abort()'s shutdown() can tear down the TLS state mid-way. + sock.close() + if self.aborted: + raise OSError("aborted") + raise OSError(str(e)) + except BaseException: + sock.close() + raise + self.sock = sock + + +def _open(url, allow_local, method="GET", connected=None): + """(connection, response) for url after redirects, each hop checked. Caller closes the connection. + + connected(conn) gets each connection before it's used, for abort(). + """ + for _ in range(MAX_REDIRECTS + 1): + scheme, host, port, local = check_url(url, allow_local) + ip = _resolve(host, port, local) + cls = _HTTPSConnection if scheme == "https" else _HTTPConnection + conn = cls(host, ip, port, TIMEOUT) + if connected: + connected(conn) + u = urlsplit(url) + target = (u.path or "/") + ("?" + u.query if u.query else "") + try: + conn.request(method, target, headers={"User-Agent": USER_AGENT, "Accept-Encoding": "identity"}) + r = conn.getresponse() + except (OSError, http.client.HTTPException) as e: + conn.close() + raise WebInstallError(f"couldn't reach {host}: {e}") + if r.status in (301, 302, 303, 307, 308) and r.getheader("Location"): + url = urljoin(url, r.getheader("Location").strip()) + conn.close() + continue + if r.status != 200: + conn.close() + raise WebInstallError(f"{host} answered HTTP {r.status} {r.reason}".strip()) + return conn, r + raise WebInstallError(f"more than {MAX_REDIRECTS} redirects") + + +def _length(r): + try: + n = int(r.getheader("Content-Length") or "") + except ValueError: + return None + return n if n >= 0 else None + + +# ---- manifests -------------------------------------------------------------- + +def parse_manifest(obj): + """{"name": ..., "file": {"url", "sha256", "size", "exe"}} from a manifest object.""" + if not isinstance(obj, dict): + raise WebInstallError("the manifest must be a JSON object") + schema = obj.get("schema") + if schema not in SCHEMAS: + raise WebInstallError(f"unsupported manifest schema {schema!r} (expected {' or '.join(SCHEMAS)})") + files = obj.get("files") + if not isinstance(files, list) or not files: + raise WebInstallError("the manifest has no files") + if len(files) > 1: + raise WebInstallError(f"the manifest lists {len(files)} files; Frame Control installs one file per link for now") + entry = files[0] + if not isinstance(entry, dict) or not isinstance(entry.get("url"), str) or not entry["url"]: + raise WebInstallError("the manifest's file has no url") + sha = entry.get("sha256") + if sha is not None and (not isinstance(sha, str) or not SHA256.fullmatch(sha)): + raise WebInstallError("sha256 must be 64 hex digits") + size = entry.get("size") + if size is not None and (type(size) is not int or size <= 0): + raise WebInstallError("size must be a positive integer") + exe = entry.get("exe") + if exe is not None and (not isinstance(exe, str) or not exe or len(exe) > 300): + raise WebInstallError("exe must be a path inside the archive") + name = obj.get("name") + if name is not None and not isinstance(name, str): + raise WebInstallError("name must be a string") + return {"name": clean_name(name), "file": {"url": entry["url"], "sha256": sha.lower() if sha else None, + "size": size, "exe": exe}} + + +def clean_name(name): + name = re.sub(r"[\x00-\x1f\x7f]", "", name or "").strip() + return name[:120] or None + + +def fetch_manifest(url, allow_local): + conn, r = _open(url, allow_local) + try: + n = _length(r) + if n is not None and n > MAX_MANIFEST: + raise WebInstallError(f"the manifest is over {MAX_MANIFEST // 1024} KB") + data = r.read(MAX_MANIFEST + 1) + except (OSError, http.client.HTTPException) as e: + raise WebInstallError(f"couldn't read the manifest: {e}") + finally: + conn.close() + if len(data) > MAX_MANIFEST: + raise WebInstallError(f"the manifest is over {MAX_MANIFEST // 1024} KB") + try: + obj = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + raise WebInstallError("the manifest isn't valid JSON") + return parse_manifest(obj) + + +def _head_size(url, allow_local): + """Content-Length from a HEAD request, or None; only for showing the size up front.""" + try: + conn, r = _open(url, allow_local, method="HEAD") + except WebInstallError: + return None + try: + return _length(r) + finally: + conn.close() + + +def plan(manifest=None, url=None): + """Everything the confirm dialog shows, fetched and checked; nothing is downloaded yet. + + Exactly one of manifest (a manifest URL) or url (a direct file URL). + """ + if (manifest is None) == (url is None): + raise WebInstallError("give either manifest or url") + link = manifest if manifest is not None else url + # localhost is for testing a link on your own computer, so only a link that + # starts there may reach it: a public manifest can't point at localhost. + allow_local = check_url(link, allow_local=True)[3] + if manifest is not None: + m = fetch_manifest(manifest, allow_local) + name, f = m["name"], m["file"] + else: + name, f = None, {"url": url, "sha256": None, "size": None, "exe": None} + _, host, _, _ = check_url(f["url"], allow_local) + fname = file_name(f["url"]) + kind = file_kind(fname) + size = f["size"] or _head_size(f["url"], allow_local) + if size is not None and size > MAX_FILE: + raise WebInstallError(f"{fname} is {size / 1024**3:.1f} GB; the limit is {MAX_FILE / 1024**3:.0f} GB") + return {"name": name or fname, "url": f["url"], "file": fname, "kind": kind, "kindLabel": KIND_LABEL[kind], + "host": host, "linkHost": urlsplit(link).hostname, "size": size, "sha256": f["sha256"], + "exe": f["exe"], "source": link, "allowLocal": allow_local, "sizeFromManifest": bool(f["size"])} + + +def abort(conn): + """Stop conn from another thread (cancel, shutdown): unblocks a read, or makes the connect fail.""" + conn.aborted = True + sock = conn.raw_sock + if sock is not None: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + +def download(p, dest_dir, progress=None, cancelled=None, connected=None): + """Download plan p's file into dest_dir; returns its path. Checks the size cap and sha256. + + progress(done, total_or_None) is called as bytes arrive; cancelled() may return True to stop; + connected(conn) gets each connection before it's used, for abort(). + """ + dest = os.path.join(dest_dir, p["file"]) + try: + conn, r = _open(p["url"], p["allowLocal"], connected=connected) + except WebInstallError: + if cancelled and cancelled(): + raise Cancelled("download cancelled") + raise + fd, part = tempfile.mkstemp(prefix=".part-", dir=dest_dir) + out = os.fdopen(fd, "wb") + ok = False + try: + total = _length(r) + expected = p["size"] if p.get("sizeFromManifest") else None + if total is not None and total > MAX_FILE: + raise WebInstallError(f"the file is over the {MAX_FILE / 1024**3:.0f} GB limit") + if expected is not None and total is not None and total != expected: + raise WebInstallError(f"the server says {total} bytes; the manifest says {expected}") + digest = hashlib.sha256() + done = 0 + while True: + if cancelled and cancelled(): + raise Cancelled("download cancelled") + try: + chunk = r.read(CHUNK) + except (OSError, http.client.HTTPException) as e: + if cancelled and cancelled(): + raise Cancelled("download cancelled") + raise WebInstallError(f"download failed: {e}") + if not chunk: + if cancelled and cancelled(): # abort() makes the read end early + raise Cancelled("download cancelled") + break + done += len(chunk) + if done > MAX_FILE: + raise WebInstallError(f"the file is over the {MAX_FILE / 1024**3:.0f} GB limit") + digest.update(chunk) + out.write(chunk) + if progress: + progress(done, total or expected) + out.close() + if total is not None and done != total: + raise WebInstallError(f"download cut off at {done} of {total} bytes") + if expected is not None and done != expected: + raise WebInstallError(f"downloaded {done} bytes; the manifest says {expected}") + if p["sha256"] and digest.hexdigest() != p["sha256"]: + raise WebInstallError(f"{p['file']} doesn't match the manifest's sha256; not installing it") + os.replace(part, dest) + ok = True + return dest + finally: + out.close() + conn.close() + if not ok: + try: + os.remove(part) + except OSError: + pass + + +# ---- installing ------------------------------------------------------------- + +def dispatch(path, name=None, exe=None, progress=None, source=None): + """Install a downloaded file with the installer for its type; returns {"message", "kind", "result"}. + + .apk goes to frame_android (its own Lepton instance and Steam shortcut, named by + the APK's label); .zip and .exe to frame_titles. The caller has the SSH + connection ready. + """ + kind = file_kind(os.path.basename(path)) + if kind == "apk": + import frame_android + try: + m = frame_android.install(path, source=source or os.path.basename(path)) + except frame_android.FrameError as e: + raise WebInstallError(str(e)) + return {"message": f"Installed {m['label']} as its own app in the Steam library", "kind": kind, "result": m} + try: + import frame_titles + except ImportError as e: + if e.name != "frame_titles": + raise + raise WebInstallError("Linux/Windows titles need a newer Frame Control") + result = frame_titles.install(path, name=name, exe=exe, progress=progress) + msg = result.get("message") if isinstance(result, dict) else None + return {"message": msg or f"Installed {name or os.path.basename(path)}", "kind": kind, "result": result} diff --git a/ui/index.html b/ui/index.html index 320088c..8e8baf8 100644 --- a/ui/index.html +++ b/ui/index.html @@ -221,10 +221,15 @@ .and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; } .and-col { display: grid; gap: 22px; align-content: start; } .rep-item .s { white-space: normal; } - #repDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; + #repDlg, #wiDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); } - #repDlg::backdrop { background: rgba(0,0,0,.55); } - #repDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); } + #repDlg::backdrop, #wiDlg::backdrop { background: rgba(0,0,0,.55); } + #repDlg h2, #wiDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); } + #wiFacts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; font-size: 13.5px; } + #wiFacts dt { color: var(--muted); } + #wiFacts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; } + #wiWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 14px 0 0; } + #wiProg:not([hidden]) { display: block; } #repForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; } #repForm label input[type=text], #repForm textarea { margin-top: 5px; } #repForm fieldset { border: 0; padding: 0; margin: 12px 0 0; } @@ -567,6 +572,16 @@ + +

Install from a website

+
+

A website asked Frame Control to install this. Nothing is downloaded until you click Install. + Only install software from sites you trust.

+ +
+ +
+