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

+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-col { display: grid; gap: 22px; align-content: start; }
.rep-item .s { white-space: normal; }
#repDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
#repDlg, #wiDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
#repDlg::backdrop { background: rgba(0,0,0,.55); }
#repDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#repDlg::backdrop, #wiDlg::backdrop { background: rgba(0,0,0,.55); }
#repDlg h2, #wiDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#wiFacts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; font-size: 13.5px; }
#wiFacts dt { color: var(--muted); }
#wiFacts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; }
#wiWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 14px 0 0; }
#wiProg:not([hidden]) { display: block; }
#repForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#repForm label input[type=text], #repForm textarea { margin-top: 5px; }
#repForm fieldset { border: 0; padding: 0; margin: 12px 0 0; }
@@ -567,6 +572,16 @@
<button type="submit" class="action small" id="repSave">Save report</button></div>
</form>
</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>
<script>
@@ -1632,6 +1647,110 @@ const spy = new IntersectionObserver(entries => {
}, { rootMargin: "-80px 0px -55% 0px" });
["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");
refresh().then(loadShots); // after status, so app names resolve
setInterval(() => { if (!document.hidden) refresh(); }, 30000);
+191 -1
View File
@@ -15,6 +15,7 @@ import json
import os
import queue
import re
import secrets
import shlex
import shutil
import signal
@@ -36,6 +37,7 @@ import frame_android # noqa: E402
import frame_catalog # noqa: E402
import frame_host # noqa: E402
import frame_store # noqa: E402
import frame_webinstall # noqa: E402
HERE = Path(__file__).resolve().parent
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}
# ---- 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,
"/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 ------------------------------------------------------------------
@@ -875,6 +1061,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(steam_frame("owned"))
elif path == "/api/steam/search":
self.send_json(steam_search(url.query))
elif path == "/api/webinstall/job":
self.send_json(webinstall_job(url.query))
elif path == "/api/shots":
self.send_json(list_shots())
elif path == "/api/shots/image":
@@ -1034,6 +1222,7 @@ def main():
"Windows has no SIGTERM to catch)")
args = ap.parse_args()
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
sweep_webinstall_tmp()
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
if args.exit_on_eof:
@@ -1051,6 +1240,7 @@ def main():
# mid-cleanup would abort it and leave the SSH master running.
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
webinstall_shutdown()
# The master was started with -N, so it stays up until told to exit.
if CONTROL:
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)