Install links for websites: frame-control://install

A site can link to frame-control://install?manifest=URL (or ?url=URL) to
install a title with Frame Control. Manifests use FrameDrop's format, so
framedrop.install/v1 is accepted as well as frame-control.install/v1.

- app/install-link.js parses links; main.js registers the scheme (plus
  electron-builder protocols for Info.plist and the .desktop file), takes
  links from open-url, second-instance argv and the first argv, and holds
  them until the page asks for them through preload's onInstallLink.
- ui/frame_webinstall.py checks the URLs (HTTPS only; localhost over http
  only when the link itself is local; no userinfo; every address public,
  rechecked on redirects and pinned for the connection), reads the
  manifest, downloads with a size cap and sha256 check, and dispatch()
  sends .apk to frame_android and .zip/.exe to frame_titles when present.
- server.py adds /api/webinstall/check, start, job and cancel behind the
  existing Host and X-Frame-UI guards; a start needs a one-time id from
  check. Downloads stop on cancel and on shutdown, and leftovers from a
  killed server are swept by PID.
- index.html asks before anything downloads (name, source host, file,
  type, size, whether a sha256 was given) and shows progress.
- docs/web-install.md, docs/install.html (landing page, unpublished).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-26 20:18:39 +10:00
1 parent 1a0e54d8bd
commit dd9c009206
13 files changed
+1541 -10

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Server tests - name: Server tests
run: python -m unittest discover -s tests -v run: python -m unittest discover -s tests -v
- name: App syntax - 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 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. # the app bundles (app/build/fetch-deps.js) and, on Ubuntu, a newer one.
+1
View File
@@ -177,6 +177,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels | | [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging | | [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 | | [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build | | [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 | | [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 | | [Open questions](docs/open-questions.md) | What's still unchecked |
+33
View File
@@ -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 };
+60 -5
View File
@@ -9,6 +9,7 @@ const http = require("http");
const net = require("net"); const net = require("net");
const os = require("os"); const os = require("os");
const path = require("path"); const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
const run = promisify(execFile); const run = promisify(execFile);
@@ -233,11 +234,55 @@ async function firstRunCheck() {
if (response === 0) setUpConnection(); if (response === 0) setUpConnection();
} }
ipcMain.handle("clipboard:read", (e) => { // IPC only from our own page in our own window.
if (!win || e.sender !== win.webContents || !url || new URL(e.senderFrame.url).origin !== new URL(url).origin) return ""; function fromUi(e) {
return clipboard.readText(); 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() { function createWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
width: 1400, height: 950, minWidth: 760, minHeight: 560, width: 1400, height: 950, minWidth: 760, minHeight: 560,
@@ -257,7 +302,9 @@ function createWindow() {
win.webContents.on("will-navigate", (e, target) => { win.webContents.on("will-navigate", (e, target) => {
if (!url || new URL(target).origin !== new URL(url).origin) e.preventDefault(); 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(); load();
} }
@@ -323,10 +370,18 @@ function buildMenu() {
if (!app.requestSingleInstanceLock()) { if (!app.requestSingleInstanceLock()) {
app.quit(); app.quit();
} else { } 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(); } 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(() => { app.whenReady().then(() => {
registerScheme();
buildMenu(); buildMenu();
createWindow(); createWindow();
}); });
+9
View File
@@ -21,6 +21,14 @@
"build": { "build": {
"appId": "com.saphid.frame-control", "appId": "com.saphid.frame-control",
"productName": "Frame Control", "productName": "Frame Control",
"protocols": [
{
"name": "Frame Control install link",
"schemes": [
"frame-control"
]
}
],
"directories": { "directories": {
"output": "dist", "output": "dist",
"buildResources": "build" "buildResources": "build"
@@ -28,6 +36,7 @@
"files": [ "files": [
"main.js", "main.js",
"preload.js", "preload.js",
"install-link.js",
"package.json", "package.json",
"build/icon.png" "build/icon.png"
], ],
+7
View File
@@ -1,7 +1,14 @@
// Lets the page read this computer's clipboard through Electron, so sending it // Lets the page read this computer's clipboard through Electron, so sending it
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. // to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard.
// It also receives frame-control://install links (docs/web-install.md): only
// what the link asked for, never an install; the page asks the user first.
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("frameApp", { contextBridge.exposeInMainWorld("frameApp", {
readClipboard: () => ipcRenderer.invoke("clipboard:read"), readClipboard: () => ipcRenderer.invoke("clipboard:read"),
onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link");
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
ipcRenderer.send("install-link:ready");
},
}); });
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Install with Frame Control</title>
<!-- Landing page for install links (docs/web-install.md): install.html?manifest=URL
or ?url=URL opens frame-control://install?… and offers the download if the
app doesn't open. Static, no requests of its own. Not published yet. -->
<style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0d1117; color: #e6edf3;
font: 15px/1.5 -apple-system, "Segoe UI", sans-serif; }
main { max-width: 520px; padding: 32px; }
h1 { font-size: 20px; margin: 0 0 8px; }
p { color: #8b98a8; }
code { color: #e6edf3; overflow-wrap: anywhere; }
a.btn { display: inline-block; margin: 8px 12px 0 0; padding: 9px 16px; border-radius: 3px; text-decoration: none;
background: #2d333b; color: #e6edf3; }
a.btn.go { background: #1a9fff; color: #fff; font-weight: 600; }
.err { color: #ff7b72; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main>
<h1>Install with Frame Control</h1>
<p id="what"></p>
<p id="bad" class="err" hidden>This link doesn't name an https:// manifest or file, so there's nothing to install.</p>
<div id="actions" hidden>
<a class="btn go" id="open">Open in Frame Control</a>
<a class="btn" href="https://github.com/saphid/steam-frame/releases/latest">Get Frame Control</a>
</div>
<p id="missing" hidden>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.</p>
</main>
<script>
(() => {
const q = new URLSearchParams(location.search);
const kind = q.has("manifest") ? "manifest" : q.has("url") ? "url" : null;
const target = kind && q.get(kind);
let ok = false;
try {
const u = new URL(target);
const local = ["localhost", "127.0.0.1"].includes(u.hostname);
ok = !u.username && !u.password && (u.protocol === "https:" || (u.protocol === "http:" && local));
} catch {}
if (!ok) { document.getElementById("bad").hidden = false; return; }
const link = `frame-control://install?${kind}=${encodeURIComponent(target)}`;
document.getElementById("what").textContent = `From ${new URL(target).hostname}. Frame Control shows what it will `
+ "install and asks you before downloading anything.";
document.getElementById("open").href = link;
document.getElementById("actions").hidden = false;
// If the app opens, this page loses focus or is hidden; if not, say how to get it.
let left = false;
window.addEventListener("blur", () => { left = true; });
document.addEventListener("visibilitychange", () => { if (document.hidden) left = true; });
setTimeout(() => { if (!left) document.getElementById("missing").hidden = false; }, 2000);
location.href = link;
})();
</script>
</body>
</html>
+146
View File
@@ -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=<URL-encoded manifest URL>
frame-control://install?url=<URL-encoded file 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
<a id="frame-control-install" href="#"
style="display:inline-block;padding:10px 18px;border-radius:4px;background:#1a9fff;color:#fff;
font:600 15px -apple-system,'Segoe UI',sans-serif;text-decoration:none">Install with Frame Control</a>
<script>
(() => {
const MANIFEST = "https://example.com/mygame/frame-control.json";
const GET_APP = "https://github.com/saphid/steam-frame/releases/latest";
const button = document.getElementById("frame-control-install");
button.href = "frame-control://install?manifest=" + encodeURIComponent(MANIFEST);
button.addEventListener("click", () => {
// If Frame Control opens, this page loses focus; if it doesn't, offer the download.
let left = false;
const away = () => { left = true; };
window.addEventListener("blur", away, { once: true });
setTimeout(() => {
window.removeEventListener("blur", away);
if (!left && confirm("Frame Control didn't open. Download it?")) location.href = GET_APP;
}, 2000);
});
})();
</script>
```
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=<URL-encoded URL>` 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.
+21
View File
@@ -130,6 +130,27 @@ class ServerGuards(unittest.TestCase):
status, _ = self.post("/api/launch", ["not", "an", "object"]) status, _ = self.post("/api/launch", ["not", "an", "object"])
self.assertEqual(status, 400) 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): def test_unknown_routes(self):
self.assertEqual(self.request("GET", "/nope")[0], 404) self.assertEqual(self.request("GET", "/nope")[0], 404)
self.assertEqual(self.post("/api/nope", {})[0], 404) self.assertEqual(self.post("/api/nope", {})[0], 404)
+423
View File
@@ -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"<html>"),
"/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()
+464
View File
@@ -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}
+122 -3
View File
@@ -221,10 +221,15 @@
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; } .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; } .and-col { display: grid; gap: 22px; align-content: start; }
.rep-item .s { white-space: normal; } .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); } 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::backdrop, #wiDlg::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 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 { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#repForm label input[type=text], #repForm textarea { margin-top: 5px; } #repForm label input[type=text], #repForm textarea { margin-top: 5px; }
#repForm fieldset { border: 0; padding: 0; margin: 12px 0 0; } #repForm fieldset { border: 0; padding: 0; margin: 12px 0 0; }
@@ -567,6 +572,16 @@
<button type="submit" class="action small" id="repSave">Save report</button></div> <button type="submit" class="action small" id="repSave">Save report</button></div>
</form> </form>
</dialog> </dialog>
<dialog id="wiDlg" aria-labelledby="wiTitle">
<h2 id="wiTitle">Install from a website</h2>
<dl id="wiFacts"></dl>
<p id="wiWarn">A website asked Frame Control to install this. Nothing is downloaded until you click Install.
Only install software from sites you trust.</p>
<div class="progress" id="wiProg" hidden><i></i></div>
<div class="row rep-actions"><span class="sub" id="wiMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="wiCancel">Cancel</button>
<button type="button" class="action small" id="wiGo" disabled>Install</button></div>
</dialog>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script> <script>
@@ -1632,6 +1647,110 @@ const spy = new IntersectionObserver(entries => {
}, { rootMargin: "-80px 0px -55% 0px" }); }, { rootMargin: "-80px 0px -55% 0px" });
["view", "shots", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id))); ["view", "shots", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id)));
// ---- install links from websites (frame-control://install, docs/web-install.md) ----
// The app passes each link here. The server checks it and reads the manifest;
// nothing downloads until the user clicks Install in this dialog.
const wi = { queue: [], open: false, gen: 0, plan: null, job: null, starting: false };
function wiSize(n) {
if (n == null) return "Not given";
return n >= 1e9 ? gb(n) : n >= 1e6 ? (n / 1e6).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
}
function wiFacts(rows) { $("wiFacts").innerHTML = rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join(""); }
function wiButtons(cancel, go) {
$("wiCancel").textContent = cancel[0]; $("wiCancel").disabled = !cancel[1];
$("wiGo").hidden = !go; $("wiGo").disabled = go !== "on";
}
async function wiNext() {
if (wi.open || !wi.queue.length) return;
const req = wi.queue.shift(), gen = ++wi.gen;
wi.open = true; wi.plan = null; wi.job = null;
let host = "";
try { host = new URL(req.target).hostname; } catch {}
wiFacts([["From", host], [req.kind === "manifest" ? "Manifest" : "File", req.target]]);
$("wiProg").hidden = true; $("wiMsg").textContent = "Checking the link…";
wiButtons(["Cancel", true], "off");
$("wiDlg").showModal();
log(`Install link from ${host}`);
try {
const p = await api("/api/webinstall/check", { [req.kind]: req.target });
if (gen !== wi.gen) return;
wi.plan = p;
const from = p.host === p.linkHost ? p.host : `${p.linkHost} (file on ${p.host})`;
wiFacts([["Title", p.name], ["From", from], ["File", p.file], ["Type", p.kindLabel], ["Size", wiSize(p.size)],
["SHA-256", p.sha256 ? "Given; checked after downloading" : "Not given; the download can't be checked"]]);
$("wiMsg").textContent = "";
wiButtons(["Cancel", true], "on");
$("wiGo").focus();
} catch (e) {
if (gen !== wi.gen) return;
$("wiMsg").textContent = e.message;
wiButtons(["Close", true], null);
log(`Install link refused: ${e.message}`, "e");
}
}
async function wiPoll(gen) {
if (gen !== wi.gen || !wi.job) return;
let j;
try {
j = await api(`/api/webinstall/job?id=${encodeURIComponent(wi.job)}`);
} catch (e) {
$("wiMsg").textContent = e.message;
return setTimeout(() => wiPoll(gen), 1500);
}
if (gen !== wi.gen) return;
const bar = $("wiProg").firstElementChild;
$("wiProg").hidden = false;
if (j.phase === "download") {
bar.style.width = j.total ? (100 * j.done / j.total) + "%" : "0";
$("wiMsg").textContent = `Downloading ${wiSize(j.done)}` + (j.total ? ` of ${wiSize(j.total)}` : "");
wiButtons(["Stop download", true], "off");
} else if (j.phase === "install") {
bar.style.width = "100%";
$("wiMsg").textContent = j.detail || "Installing on the Frame…";
wiButtons(["Stop download", false], "off");
} else {
wi.job = null;
$("wiProg").hidden = true;
if (j.phase === "done") {
log(j.message, "ok"); toast(j.message);
$("wiDlg").close();
loadAndroid(); refresh();
} else {
$("wiMsg").textContent = j.error;
log(`Install from link failed: ${j.error}`, "e"); toast(`Install failed: ${j.error}`, true);
wiButtons(["Close", true], null);
}
return;
}
setTimeout(() => wiPoll(gen), 500);
}
$("wiGo").onclick = async () => {
const p = wi.plan, gen = wi.gen;
if (!p) return;
wi.plan = null; // one click, one install
wiButtons(["Cancel", false], "off");
$("wiMsg").textContent = "Starting…";
wi.starting = true; // the dialog stays open until the job is known
try {
wi.job = (await api("/api/webinstall/start", { id: p.id })).job;
log(`Installing ${p.name} from ${p.host}…`);
wiPoll(gen);
} catch (e) {
$("wiMsg").textContent = e.message;
wiButtons(["Close", true], null);
} finally { wi.starting = false; }
};
$("wiCancel").onclick = async () => {
if (!wi.job) return $("wiDlg").close();
try { await api("/api/webinstall/cancel", { job: wi.job }); } catch (e) { $("wiMsg").textContent = e.message; }
};
// Escape doesn't close the dialog while an install runs; it keeps showing progress.
$("wiDlg").addEventListener("cancel", e => { if (wi.job || wi.starting) e.preventDefault(); });
$("wiDlg").addEventListener("close", () => { wi.gen++; wi.open = false; wi.plan = null; wi.job = null; setTimeout(wiNext); });
if (window.frameApp && window.frameApp.onInstallLink) {
window.frameApp.onInstallLink(req => { if (wi.queue.length < 5) wi.queue.push(req); wiNext(); });
}
setView("headset"); setView("headset");
refresh().then(loadShots); // after status, so app names resolve refresh().then(loadShots); // after status, so app names resolve
setInterval(() => { if (!document.hidden) refresh(); }, 30000); setInterval(() => { if (!document.hidden) refresh(); }, 30000);
+191 -1
View File
@@ -15,6 +15,7 @@ import json
import os import os
import queue import queue
import re import re
import secrets
import shlex import shlex
import shutil import shutil
import signal import signal
@@ -36,6 +37,7 @@ import frame_android # noqa: E402
import frame_catalog # noqa: E402 import frame_catalog # noqa: E402
import frame_host # noqa: E402 import frame_host # noqa: E402
import frame_store # noqa: E402 import frame_store # noqa: E402
import frame_webinstall # noqa: E402
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
FRAME = os.environ.get("FRAME_ALIAS", "frame") FRAME = os.environ.get("FRAME_ALIAS", "frame")
@@ -760,8 +762,192 @@ def android_display(body):
return {"message": f"Port {port}: " + "; ".join(c.split(";")[0] for c in cmds), "display": now} return {"message": f"Port {port}: " + "; ".join(c.split(";")[0] for c in cmds), "display": now}
# ---- install links from websites (frame-control://install, docs/web-install.md) ----
# The app hands the link to the page, which asks /check (fetches the manifest,
# downloads nothing), shows what it found and waits for the user's click before
# /start. A website can't call these itself: like all of /api/* they need the
# Host and X-Frame-UI checks in Handler.local_request.
_web_lock = threading.Lock()
_web_plans = {} # id -> checked plan waiting for the user to confirm
_web_jobs = {} # id -> progress of the confirmed install (only the latest is kept)
_web_workers = set() # threads running an install, joined on shutdown
_web_closing = False # set on shutdown; no new installs after that
MAX_WEB_PLANS = 8
WEB_TMP_PREFIX = "frame-webinstall-" # then the server's PID, for sweep_webinstall_tmp
def webinstall_check(body):
manifest, url = body.get("manifest"), body.get("url")
for v in (manifest, url):
if v is not None and not isinstance(v, str):
raise Failure("manifest and url must be strings", 400)
try:
plan = frame_webinstall.plan(manifest=manifest, url=url)
except frame_webinstall.WebInstallError as e:
raise Failure(str(e), 400)
pid = secrets.token_urlsafe(16)
with _web_lock:
while len(_web_plans) >= MAX_WEB_PLANS:
_web_plans.pop(next(iter(_web_plans)))
_web_plans[pid] = plan
shown = ("name", "file", "kind", "kindLabel", "host", "linkHost", "size", "source")
return {"id": pid, **{k: plan[k] for k in shown}, "sha256": bool(plan["sha256"])}
def webinstall_start(body):
pid = body.get("id")
with _web_lock:
if any(j["phase"] in ("download", "install") for j in _web_jobs.values()):
raise Failure("another install from a link is still running", 409)
# One use per check: the page can only install what it showed.
plan = _web_plans.pop(pid, None) if isinstance(pid, str) else None
if not plan:
raise Failure("unknown or already used install id; open the link again", 400)
job = {"phase": "download", "done": 0, "total": plan["size"], "detail": "", "message": None,
"error": None, "cancel": False}
if _web_closing:
raise Failure("Frame Control is quitting", 503)
_web_jobs.clear()
_web_jobs[pid] = job
# Started under the lock, so shutdown never sees a thread it can't join.
worker = threading.Thread(target=_webinstall_run, args=(plan, job), daemon=True)
_web_workers.add(worker)
worker.start()
return {"job": pid}
def _webinstall_run(plan, job):
tmp = None
try:
tmp = tempfile.mkdtemp(prefix=f"{WEB_TMP_PREFIX}{os.getpid()}-")
def progress(done, total):
job["done"], job["total"] = done, total
def detail(*args, **_kw): # frame_titles may report its steps as text
texts = [a for a in args if isinstance(a, str)]
if texts:
job["detail"] = texts[0][:200]
def connected(conn):
with _web_lock:
job["_conn"] = conn
stop = job["cancel"] # cancelled before this connection existed
if stop:
frame_webinstall.abort(conn)
path = frame_webinstall.download(plan, tmp, progress=progress, cancelled=lambda: job["cancel"],
connected=connected)
# Under the lock cancel uses, so a cancel it acknowledged is never followed by an install.
with _web_lock:
if job["cancel"]:
raise frame_webinstall.Cancelled("download cancelled")
job["phase"] = "install"
job.pop("_conn", None)
ensure_master()
res = frame_webinstall.dispatch(path, name=plan["name"], exe=plan["exe"], progress=detail, source=plan["url"])
job["message"], job["phase"] = res["message"], "done"
except Exception as e:
known = (frame_webinstall.WebInstallError, Failure, frame_android.FrameError)
job["error"] = str(e) if isinstance(e, known) else f"{type(e).__name__}: {e}"
job["phase"] = "error"
finally:
with _web_lock:
job.pop("_conn", None)
if tmp:
shutil.rmtree(tmp, ignore_errors=True)
with _web_lock:
_web_workers.discard(threading.current_thread())
def webinstall_job(query):
job = _web_jobs.get((parse_qs(query).get("id") or [""])[0])
if not job:
raise Failure("unknown install job", 404)
with _web_lock: # the worker adds and drops _conn meanwhile
return {k: v for k, v in job.items() if k != "cancel" and not k.startswith("_")}
def webinstall_cancel(body):
jid = body.get("job")
job = _web_jobs.get(jid) if isinstance(jid, str) else None
if not job:
raise Failure("unknown install job", 404)
with _web_lock:
if job["phase"] != "download":
raise Failure("only the download can be cancelled", 409)
job["cancel"] = True
conn = job.get("_conn")
if conn:
frame_webinstall.abort(conn)
return {"message": "Cancelling the download"}
def webinstall_shutdown():
"""Stop downloads and give workers a moment to delete their temporary files.
An install already copying to the Frame may outlive this; sweep_webinstall_tmp
removes what it leaves on a later start.
"""
global _web_closing
with _web_lock:
_web_closing = True
conns = []
for job in _web_jobs.values():
job["cancel"] = True
conns.append(job.get("_conn")) # once: the worker may drop it any time
workers = list(_web_workers)
for conn in conns:
if conn:
frame_webinstall.abort(conn)
deadline = time.time() + 4 # the app kills the server 5 s after asking it to stop
for worker in workers:
worker.join(max(0, deadline - time.time()))
def _pid_alive(pid):
if frame_host.WINDOWS:
# os.kill(pid, 0) would terminate the process there; ask the kernel instead.
import ctypes
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
handle = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
if not handle:
return ctypes.get_last_error() == 5 # access denied: it exists
try:
code = ctypes.c_ulong()
return not k32.GetExitCodeProcess(handle, ctypes.byref(code)) or code.value == 259 # STILL_ACTIVE
finally:
k32.CloseHandle(handle)
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except OSError:
return True # exists, owned by someone else
return True
def sweep_webinstall_tmp():
"""Delete download folders left by a server that was killed mid-install.
Folders carry the server's PID, so only a dead server's are taken.
"""
for d in Path(tempfile.gettempdir()).glob(f"{WEB_TMP_PREFIX}*"):
m = re.fullmatch(re.escape(WEB_TMP_PREFIX) + r"(\d+)-.*", d.name)
if not m:
continue
pid = int(m[1])
try:
if pid != os.getpid() and not _pid_alive(pid) and d.is_dir():
shutil.rmtree(d, ignore_errors=True)
except OSError:
pass
POST = {"/api/android/display": android_display, "/api/android": android,"/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, POST = {"/api/android/display": android_display, "/api/android": android,"/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots} "/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel}
# ---- HTTP ------------------------------------------------------------------ # ---- HTTP ------------------------------------------------------------------
@@ -875,6 +1061,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(steam_frame("owned")) self.send_json(steam_frame("owned"))
elif path == "/api/steam/search": elif path == "/api/steam/search":
self.send_json(steam_search(url.query)) self.send_json(steam_search(url.query))
elif path == "/api/webinstall/job":
self.send_json(webinstall_job(url.query))
elif path == "/api/shots": elif path == "/api/shots":
self.send_json(list_shots()) self.send_json(list_shots())
elif path == "/api/shots/image": elif path == "/api/shots/image":
@@ -1034,6 +1222,7 @@ def main():
"Windows has no SIGTERM to catch)") "Windows has no SIGTERM to catch)")
args = ap.parse_args() args = ap.parse_args()
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
sweep_webinstall_tmp()
if not frame_host.WINDOWS: if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt)) signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
if args.exit_on_eof: if args.exit_on_eof:
@@ -1051,6 +1240,7 @@ def main():
# mid-cleanup would abort it and leave the SSH master running. # mid-cleanup would abort it and leave the SSH master running.
if not frame_host.WINDOWS: if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, signal.SIG_IGN) signal.signal(signal.SIGTERM, signal.SIG_IGN)
webinstall_shutdown()
# The master was started with -N, so it stays up until told to exit. # The master was started with -N, so it stays up until told to exit.
if CONTROL: if CONTROL:
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL) subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)