Nothing happened? Frame Control isn't installed on this computer, or is older than the
+ version that handles install links. Get it, open it once, then use the link again.
+
+
+
+
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 @@
+