diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 6ac075f..654da64 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -34,7 +34,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 0d5f56c..bc763b1 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ Frame's software fits together, all checked against a real headset and labelled | [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 | | [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection | +| [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 5df9f53..230b2e6 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 a075c51..bd5701a 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 d1686a1..f21551d 100644 --- a/app/preload.js +++ b/app/preload.js @@ -2,9 +2,16 @@ // to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells // the page where a dropped file or folder lives, so a folder can be sideloaded // as a title without zipping it (the local server reads it from there). +// 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, webUtils } = require("electron"); contextBridge.exposeInMainWorld("frameApp", { readClipboard: () => ipcRenderer.invoke("clipboard:read"), pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } }, + 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 a75c0b0..a1a7898 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -164,6 +164,27 @@ class ServerGuards(unittest.TestCase): self.assertEqual(self.request("GET", "/api/titles/job?token=nope", headers={"X-Frame-UI": "1"})[0], 404) self.assertEqual(self.request("POST", "/api/titles", {"action": "list"})[0], 403) + 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..bae552b --- /dev/null +++ b/ui/frame_webinstall.py @@ -0,0 +1,465 @@ +"""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): + # urllib's default context, so the app's bundled CA list (frame_host.trust_bundled_cas) applies too. + super().__init__(host, port, timeout=timeout, context=ssl._create_default_https_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 b2fb580..81cb7d8 100644 --- a/ui/index.html +++ b/ui/index.html @@ -221,10 +221,10 @@ .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, #titleDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; + #repDlg, #titleDlg, #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, #titleDlg::backdrop { background: rgba(0,0,0,.55); } - #repDlg h2, #titleDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); } + #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop { background: rgba(0,0,0,.55); } + #repDlg h2, #titleDlg h2, #wiDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); } #repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; } #repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; } #titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; @@ -233,6 +233,11 @@ #titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; } #titleForm .note.warn { color: #d9a23a; } #titleList { margin-top: 8px; } + #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 fieldset { border: 0; padding: 0; margin: 12px 0 0; } #repForm legend { font-size: 12.5px; color: var(--muted); padding: 0; margin-bottom: 4px; } #repForm label.opt { display: inline-flex; align-items: center; gap: 6px; margin: 4px 14px 0 0; color: var(--text); font-size: 13.5px; } @@ -592,6 +597,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.

+ +
+ +
+