mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
Merge sideloading, install links and devkit pairing
Combines the three feature branches on bundle-deps. Conflicts in server.py,
index.html, preload.js, README and test_server.py keep both sides. The
web-install downloader now uses urllib's default HTTPS context, so the
bundled CA list from 770f26c applies to it on Windows too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
636a4a47b7
13 files changed
+1541
-10
No files matched your search
@@ -0,0 +1,465 @@
|
||||
"""Install links from websites: frame-control://install?manifest=URL or ?url=URL.
|
||||
|
||||
The app hands the link to the page, the page shows what it will install and
|
||||
asks the user first, and only then does this module download the file and pass
|
||||
it to the installer for its type (dispatch()). See docs/web-install.md.
|
||||
|
||||
A manifest is the same JSON FrameDrop uses, so one works for both tools:
|
||||
{"schema": "framedrop.install/v1", "name": "My Game",
|
||||
"files": [{"url": "https://cdn.example.com/mygame-arm64.apk", "sha256": "..."}]}
|
||||
"frame-control.install/v1" is accepted with the same shape.
|
||||
|
||||
Rules: HTTPS only, except http(s)://localhost or 127.0.0.1 for testing, and then
|
||||
only when the link itself points there. No credentials in URLs, no private,
|
||||
loopback, link-local or CGNAT addresses (checked on every redirect, and the
|
||||
connection goes to the address that was checked, so DNS can't change it in
|
||||
between). The file URL must end in a file name.
|
||||
|
||||
Python stdlib only, 3.9 compatible.
|
||||
"""
|
||||
import hashlib
|
||||
import http.client
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import errno
|
||||
import re
|
||||
import select
|
||||
import socket
|
||||
import ssl
|
||||
import tempfile
|
||||
import time
|
||||
from urllib.parse import unquote, urljoin, urlsplit
|
||||
|
||||
SCHEMAS = ("framedrop.install/v1", "frame-control.install/v1")
|
||||
MAX_FILE = 4 * 1024**3 # largest download accepted
|
||||
MAX_MANIFEST = 256 * 1024 # largest manifest accepted
|
||||
MAX_URL = 2048
|
||||
MAX_REDIRECTS = 5
|
||||
TIMEOUT = 30 # seconds per socket operation
|
||||
CHUNK = 1 << 20
|
||||
LOCAL_HOSTS = ("localhost", "127.0.0.1")
|
||||
USER_AGENT = "FrameControl (+https://github.com/saphid/steam-frame)"
|
||||
# What dispatch() can install, by file extension.
|
||||
KINDS = {".apk": "apk", ".zip": "title", ".exe": "title"}
|
||||
KIND_LABEL = {"apk": "Android app (APK)", "title": "Linux/Windows title"}
|
||||
SHA256 = re.compile(r"[0-9a-fA-F]{64}")
|
||||
CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
||||
# connect_ex() results meaning "still connecting" (the last is Windows' WSAEWOULDBLOCK).
|
||||
_CONNECTING = {errno.EINPROGRESS, errno.EWOULDBLOCK, errno.EALREADY, getattr(errno, "WSAEWOULDBLOCK", 10035)}
|
||||
|
||||
# Swapped out by the tests, which have no network.
|
||||
_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
|
||||
class WebInstallError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Cancelled(WebInstallError):
|
||||
pass
|
||||
|
||||
|
||||
# ---- URLs -------------------------------------------------------------------
|
||||
|
||||
def is_public(ip):
|
||||
"""True for addresses on the public internet, and nothing a LAN or this computer uses."""
|
||||
ip = ipaddress.ip_address(ip)
|
||||
if ip.version == 6:
|
||||
if ip.ipv4_mapped:
|
||||
ip = ip.ipv4_mapped
|
||||
elif ip.is_site_local: # fec0::/10: deprecated, but is_global doesn't catch it
|
||||
return False
|
||||
elif ip.sixtofour and not is_public(ip.sixtofour):
|
||||
return False
|
||||
if ip.version == 4 and ip in CGNAT:
|
||||
return False
|
||||
return ip.is_global and not ip.is_multicast
|
||||
|
||||
|
||||
def check_url(url, allow_local=False):
|
||||
"""Validate a URL against the rules above; returns (scheme, host, port, is_local).
|
||||
|
||||
Resolving the name is left to connect time (see _resolve), so this needs no network.
|
||||
"""
|
||||
if not isinstance(url, str) or not url or len(url) > MAX_URL:
|
||||
raise WebInstallError("the link must be a URL of at most %d characters" % MAX_URL)
|
||||
if any(c.isspace() or ord(c) < 32 for c in url):
|
||||
raise WebInstallError("the URL has spaces or control characters in it")
|
||||
try:
|
||||
u = urlsplit(url)
|
||||
port = u.port
|
||||
except ValueError as e:
|
||||
raise WebInstallError(f"not a valid URL: {e}")
|
||||
scheme = u.scheme.lower()
|
||||
if scheme not in ("https", "http"):
|
||||
raise WebInstallError(f"only https:// links are allowed, not {scheme or 'a relative URL'}")
|
||||
if u.username is not None or u.password is not None or "@" in u.netloc:
|
||||
raise WebInstallError("URLs with a user name or password in them aren't allowed")
|
||||
host = (u.hostname or "").lower().rstrip(".")
|
||||
if not host:
|
||||
raise WebInstallError("the URL has no host")
|
||||
local = host in LOCAL_HOSTS
|
||||
if local and not allow_local:
|
||||
raise WebInstallError("localhost is only allowed when the link itself points there (for testing)")
|
||||
if scheme == "http" and not local:
|
||||
raise WebInstallError("only https:// is allowed (http:// only for localhost while testing)")
|
||||
if not local:
|
||||
try:
|
||||
literal = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
literal = None
|
||||
if literal is not None and not is_public(literal):
|
||||
raise WebInstallError(f"{host} is a private or local address")
|
||||
return scheme, host, port or (443 if scheme == "https" else 80), local
|
||||
|
||||
|
||||
def file_name(url):
|
||||
"""The file name the URL ends in, e.g. mygame-arm64.apk."""
|
||||
path = urlsplit(url).path
|
||||
name = unquote(path.rsplit("/", 1)[-1])
|
||||
if not name or name in (".", "..") or "/" in name or "\\" in name or name.startswith(".") \
|
||||
or any(ord(c) < 32 for c in name) or len(name) > 200:
|
||||
raise WebInstallError("the file URL must end in a file name, e.g. https://example.com/mygame.apk")
|
||||
return name
|
||||
|
||||
|
||||
def file_kind(name):
|
||||
ext = os.path.splitext(name.lower())[1]
|
||||
kind = KINDS.get(ext)
|
||||
if not kind:
|
||||
raise WebInstallError(f"{name}: Frame Control installs .apk, .zip and .exe files, not {ext or 'this type'}")
|
||||
return kind
|
||||
|
||||
|
||||
def _resolve(host, port, local):
|
||||
"""One address to connect to; every address the name has must be public."""
|
||||
if local:
|
||||
return "127.0.0.1"
|
||||
try:
|
||||
infos = _getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
except (OSError, UnicodeError) as e:
|
||||
raise WebInstallError(f"couldn't look up {host}: {e}")
|
||||
ips = [info[4][0].split("%", 1)[0] for info in infos]
|
||||
if not ips:
|
||||
raise WebInstallError(f"couldn't look up {host}")
|
||||
for ip in ips:
|
||||
if not is_public(ip):
|
||||
raise WebInstallError(f"{host} points to a private or local address ({ip})")
|
||||
return ips[0]
|
||||
|
||||
|
||||
# ---- HTTP -------------------------------------------------------------------
|
||||
|
||||
class _Abortable:
|
||||
"""Connects to an address checked beforehand, whatever DNS says by then.
|
||||
|
||||
raw_sock is the socket to shut down to stop the connection from another
|
||||
thread (abort()): http.client drops conn.sock once a response will close
|
||||
the connection, yet keeps reading the body from it.
|
||||
"""
|
||||
raw_sock = None
|
||||
aborted = False
|
||||
|
||||
def _tcp(self):
|
||||
"""Connect without blocking, so abort() can stop a connect that hangs."""
|
||||
sock = socket.socket(socket.AF_INET6 if ":" in self._ip else socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.setblocking(False)
|
||||
err = sock.connect_ex((self._ip, self.port))
|
||||
deadline = time.monotonic() + self.timeout
|
||||
while err in _CONNECTING:
|
||||
if self.aborted:
|
||||
raise OSError("aborted")
|
||||
if time.monotonic() > deadline:
|
||||
raise socket.timeout(f"timed out connecting to {self.host}")
|
||||
_, writable, failed = select.select([], [sock], [sock], 0.2)
|
||||
if writable or failed:
|
||||
err = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
|
||||
if err:
|
||||
raise OSError(err, os.strerror(err))
|
||||
sock.settimeout(self.timeout)
|
||||
self.raw_sock = sock
|
||||
if self.aborted: # abort() ran just now and found nothing to shut down
|
||||
raise OSError("aborted")
|
||||
except BaseException:
|
||||
sock.close()
|
||||
raise
|
||||
return sock
|
||||
|
||||
|
||||
class _HTTPConnection(_Abortable, http.client.HTTPConnection):
|
||||
def __init__(self, host, ip, port, timeout):
|
||||
super().__init__(host, port, timeout=timeout)
|
||||
self._ip = ip
|
||||
|
||||
def connect(self):
|
||||
self.sock = self._tcp()
|
||||
|
||||
|
||||
class _HTTPSConnection(_Abortable, http.client.HTTPSConnection):
|
||||
"""As above, still verifying the certificate for the host name."""
|
||||
|
||||
def __init__(self, host, ip, port, timeout):
|
||||
# urllib's default context, so the app's bundled CA list (frame_host.trust_bundled_cas) applies too.
|
||||
super().__init__(host, port, timeout=timeout, context=ssl._create_default_https_context())
|
||||
self._ip = ip
|
||||
|
||||
def connect(self):
|
||||
# Wrapping detaches the plain socket, so publish the TLS one before the handshake.
|
||||
sock = self._context.wrap_socket(self._tcp(), server_hostname=self.host, do_handshake_on_connect=False)
|
||||
self.raw_sock = sock
|
||||
try:
|
||||
if self.aborted:
|
||||
raise OSError("aborted")
|
||||
sock.do_handshake()
|
||||
except (AttributeError, ValueError) as e:
|
||||
# abort()'s shutdown() can tear down the TLS state mid-way.
|
||||
sock.close()
|
||||
if self.aborted:
|
||||
raise OSError("aborted")
|
||||
raise OSError(str(e))
|
||||
except BaseException:
|
||||
sock.close()
|
||||
raise
|
||||
self.sock = sock
|
||||
|
||||
|
||||
def _open(url, allow_local, method="GET", connected=None):
|
||||
"""(connection, response) for url after redirects, each hop checked. Caller closes the connection.
|
||||
|
||||
connected(conn) gets each connection before it's used, for abort().
|
||||
"""
|
||||
for _ in range(MAX_REDIRECTS + 1):
|
||||
scheme, host, port, local = check_url(url, allow_local)
|
||||
ip = _resolve(host, port, local)
|
||||
cls = _HTTPSConnection if scheme == "https" else _HTTPConnection
|
||||
conn = cls(host, ip, port, TIMEOUT)
|
||||
if connected:
|
||||
connected(conn)
|
||||
u = urlsplit(url)
|
||||
target = (u.path or "/") + ("?" + u.query if u.query else "")
|
||||
try:
|
||||
conn.request(method, target, headers={"User-Agent": USER_AGENT, "Accept-Encoding": "identity"})
|
||||
r = conn.getresponse()
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
conn.close()
|
||||
raise WebInstallError(f"couldn't reach {host}: {e}")
|
||||
if r.status in (301, 302, 303, 307, 308) and r.getheader("Location"):
|
||||
url = urljoin(url, r.getheader("Location").strip())
|
||||
conn.close()
|
||||
continue
|
||||
if r.status != 200:
|
||||
conn.close()
|
||||
raise WebInstallError(f"{host} answered HTTP {r.status} {r.reason}".strip())
|
||||
return conn, r
|
||||
raise WebInstallError(f"more than {MAX_REDIRECTS} redirects")
|
||||
|
||||
|
||||
def _length(r):
|
||||
try:
|
||||
n = int(r.getheader("Content-Length") or "")
|
||||
except ValueError:
|
||||
return None
|
||||
return n if n >= 0 else None
|
||||
|
||||
|
||||
# ---- manifests --------------------------------------------------------------
|
||||
|
||||
def parse_manifest(obj):
|
||||
"""{"name": ..., "file": {"url", "sha256", "size", "exe"}} from a manifest object."""
|
||||
if not isinstance(obj, dict):
|
||||
raise WebInstallError("the manifest must be a JSON object")
|
||||
schema = obj.get("schema")
|
||||
if schema not in SCHEMAS:
|
||||
raise WebInstallError(f"unsupported manifest schema {schema!r} (expected {' or '.join(SCHEMAS)})")
|
||||
files = obj.get("files")
|
||||
if not isinstance(files, list) or not files:
|
||||
raise WebInstallError("the manifest has no files")
|
||||
if len(files) > 1:
|
||||
raise WebInstallError(f"the manifest lists {len(files)} files; Frame Control installs one file per link for now")
|
||||
entry = files[0]
|
||||
if not isinstance(entry, dict) or not isinstance(entry.get("url"), str) or not entry["url"]:
|
||||
raise WebInstallError("the manifest's file has no url")
|
||||
sha = entry.get("sha256")
|
||||
if sha is not None and (not isinstance(sha, str) or not SHA256.fullmatch(sha)):
|
||||
raise WebInstallError("sha256 must be 64 hex digits")
|
||||
size = entry.get("size")
|
||||
if size is not None and (type(size) is not int or size <= 0):
|
||||
raise WebInstallError("size must be a positive integer")
|
||||
exe = entry.get("exe")
|
||||
if exe is not None and (not isinstance(exe, str) or not exe or len(exe) > 300):
|
||||
raise WebInstallError("exe must be a path inside the archive")
|
||||
name = obj.get("name")
|
||||
if name is not None and not isinstance(name, str):
|
||||
raise WebInstallError("name must be a string")
|
||||
return {"name": clean_name(name), "file": {"url": entry["url"], "sha256": sha.lower() if sha else None,
|
||||
"size": size, "exe": exe}}
|
||||
|
||||
|
||||
def clean_name(name):
|
||||
name = re.sub(r"[\x00-\x1f\x7f]", "", name or "").strip()
|
||||
return name[:120] or None
|
||||
|
||||
|
||||
def fetch_manifest(url, allow_local):
|
||||
conn, r = _open(url, allow_local)
|
||||
try:
|
||||
n = _length(r)
|
||||
if n is not None and n > MAX_MANIFEST:
|
||||
raise WebInstallError(f"the manifest is over {MAX_MANIFEST // 1024} KB")
|
||||
data = r.read(MAX_MANIFEST + 1)
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
raise WebInstallError(f"couldn't read the manifest: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
if len(data) > MAX_MANIFEST:
|
||||
raise WebInstallError(f"the manifest is over {MAX_MANIFEST // 1024} KB")
|
||||
try:
|
||||
obj = json.loads(data.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise WebInstallError("the manifest isn't valid JSON")
|
||||
return parse_manifest(obj)
|
||||
|
||||
|
||||
def _head_size(url, allow_local):
|
||||
"""Content-Length from a HEAD request, or None; only for showing the size up front."""
|
||||
try:
|
||||
conn, r = _open(url, allow_local, method="HEAD")
|
||||
except WebInstallError:
|
||||
return None
|
||||
try:
|
||||
return _length(r)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def plan(manifest=None, url=None):
|
||||
"""Everything the confirm dialog shows, fetched and checked; nothing is downloaded yet.
|
||||
|
||||
Exactly one of manifest (a manifest URL) or url (a direct file URL).
|
||||
"""
|
||||
if (manifest is None) == (url is None):
|
||||
raise WebInstallError("give either manifest or url")
|
||||
link = manifest if manifest is not None else url
|
||||
# localhost is for testing a link on your own computer, so only a link that
|
||||
# starts there may reach it: a public manifest can't point at localhost.
|
||||
allow_local = check_url(link, allow_local=True)[3]
|
||||
if manifest is not None:
|
||||
m = fetch_manifest(manifest, allow_local)
|
||||
name, f = m["name"], m["file"]
|
||||
else:
|
||||
name, f = None, {"url": url, "sha256": None, "size": None, "exe": None}
|
||||
_, host, _, _ = check_url(f["url"], allow_local)
|
||||
fname = file_name(f["url"])
|
||||
kind = file_kind(fname)
|
||||
size = f["size"] or _head_size(f["url"], allow_local)
|
||||
if size is not None and size > MAX_FILE:
|
||||
raise WebInstallError(f"{fname} is {size / 1024**3:.1f} GB; the limit is {MAX_FILE / 1024**3:.0f} GB")
|
||||
return {"name": name or fname, "url": f["url"], "file": fname, "kind": kind, "kindLabel": KIND_LABEL[kind],
|
||||
"host": host, "linkHost": urlsplit(link).hostname, "size": size, "sha256": f["sha256"],
|
||||
"exe": f["exe"], "source": link, "allowLocal": allow_local, "sizeFromManifest": bool(f["size"])}
|
||||
|
||||
|
||||
def abort(conn):
|
||||
"""Stop conn from another thread (cancel, shutdown): unblocks a read, or makes the connect fail."""
|
||||
conn.aborted = True
|
||||
sock = conn.raw_sock
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def download(p, dest_dir, progress=None, cancelled=None, connected=None):
|
||||
"""Download plan p's file into dest_dir; returns its path. Checks the size cap and sha256.
|
||||
|
||||
progress(done, total_or_None) is called as bytes arrive; cancelled() may return True to stop;
|
||||
connected(conn) gets each connection before it's used, for abort().
|
||||
"""
|
||||
dest = os.path.join(dest_dir, p["file"])
|
||||
try:
|
||||
conn, r = _open(p["url"], p["allowLocal"], connected=connected)
|
||||
except WebInstallError:
|
||||
if cancelled and cancelled():
|
||||
raise Cancelled("download cancelled")
|
||||
raise
|
||||
fd, part = tempfile.mkstemp(prefix=".part-", dir=dest_dir)
|
||||
out = os.fdopen(fd, "wb")
|
||||
ok = False
|
||||
try:
|
||||
total = _length(r)
|
||||
expected = p["size"] if p.get("sizeFromManifest") else None
|
||||
if total is not None and total > MAX_FILE:
|
||||
raise WebInstallError(f"the file is over the {MAX_FILE / 1024**3:.0f} GB limit")
|
||||
if expected is not None and total is not None and total != expected:
|
||||
raise WebInstallError(f"the server says {total} bytes; the manifest says {expected}")
|
||||
digest = hashlib.sha256()
|
||||
done = 0
|
||||
while True:
|
||||
if cancelled and cancelled():
|
||||
raise Cancelled("download cancelled")
|
||||
try:
|
||||
chunk = r.read(CHUNK)
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
if cancelled and cancelled():
|
||||
raise Cancelled("download cancelled")
|
||||
raise WebInstallError(f"download failed: {e}")
|
||||
if not chunk:
|
||||
if cancelled and cancelled(): # abort() makes the read end early
|
||||
raise Cancelled("download cancelled")
|
||||
break
|
||||
done += len(chunk)
|
||||
if done > MAX_FILE:
|
||||
raise WebInstallError(f"the file is over the {MAX_FILE / 1024**3:.0f} GB limit")
|
||||
digest.update(chunk)
|
||||
out.write(chunk)
|
||||
if progress:
|
||||
progress(done, total or expected)
|
||||
out.close()
|
||||
if total is not None and done != total:
|
||||
raise WebInstallError(f"download cut off at {done} of {total} bytes")
|
||||
if expected is not None and done != expected:
|
||||
raise WebInstallError(f"downloaded {done} bytes; the manifest says {expected}")
|
||||
if p["sha256"] and digest.hexdigest() != p["sha256"]:
|
||||
raise WebInstallError(f"{p['file']} doesn't match the manifest's sha256; not installing it")
|
||||
os.replace(part, dest)
|
||||
ok = True
|
||||
return dest
|
||||
finally:
|
||||
out.close()
|
||||
conn.close()
|
||||
if not ok:
|
||||
try:
|
||||
os.remove(part)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---- installing -------------------------------------------------------------
|
||||
|
||||
def dispatch(path, name=None, exe=None, progress=None, source=None):
|
||||
"""Install a downloaded file with the installer for its type; returns {"message", "kind", "result"}.
|
||||
|
||||
.apk goes to frame_android (its own Lepton instance and Steam shortcut, named by
|
||||
the APK's label); .zip and .exe to frame_titles. The caller has the SSH
|
||||
connection ready.
|
||||
"""
|
||||
kind = file_kind(os.path.basename(path))
|
||||
if kind == "apk":
|
||||
import frame_android
|
||||
try:
|
||||
m = frame_android.install(path, source=source or os.path.basename(path))
|
||||
except frame_android.FrameError as e:
|
||||
raise WebInstallError(str(e))
|
||||
return {"message": f"Installed {m['label']} as its own app in the Steam library", "kind": kind, "result": m}
|
||||
try:
|
||||
import frame_titles
|
||||
except ImportError as e:
|
||||
if e.name != "frame_titles":
|
||||
raise
|
||||
raise WebInstallError("Linux/Windows titles need a newer Frame Control")
|
||||
result = frame_titles.install(path, name=name, exe=exe, progress=progress)
|
||||
msg = result.get("message") if isinstance(result, dict) else None
|
||||
return {"message": msg or f"Installed {name or os.path.basename(path)}", "kind": kind, "result": result}
|
||||
+122
-3
@@ -221,10 +221,10 @@
|
||||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||||
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||
.rep-item .s { white-space: normal; }
|
||||
#repDlg, #titleDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
#repDlg, #titleDlg, #wiDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
#repDlg::backdrop, #titleDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#repDlg h2, #titleDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#repDlg h2, #titleDlg h2, #wiDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||||
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
@@ -233,6 +233,11 @@
|
||||
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#titleForm .note.warn { color: #d9a23a; }
|
||||
#titleList { margin-top: 8px; }
|
||||
#wiFacts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; font-size: 13.5px; }
|
||||
#wiFacts dt { color: var(--muted); }
|
||||
#wiFacts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; }
|
||||
#wiWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 14px 0 0; }
|
||||
#wiProg:not([hidden]) { display: block; }
|
||||
#repForm fieldset { border: 0; padding: 0; margin: 12px 0 0; }
|
||||
#repForm legend { font-size: 12.5px; color: var(--muted); padding: 0; margin-bottom: 4px; }
|
||||
#repForm label.opt { display: inline-flex; align-items: center; gap: 6px; margin: 4px 14px 0 0; color: var(--text); font-size: 13.5px; }
|
||||
@@ -592,6 +597,16 @@
|
||||
<button type="submit" class="action small" id="titleInstall">Install</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>
|
||||
@@ -1798,6 +1813,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);
|
||||
|
||||
+190
-1
@@ -38,6 +38,7 @@ import frame_catalog # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
import frame_titles # noqa: E402
|
||||
import frame_webinstall # noqa: E402
|
||||
|
||||
frame_host.trust_bundled_cas()
|
||||
|
||||
@@ -878,8 +879,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/titles": titles, "/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 ------------------------------------------------------------------
|
||||
@@ -998,6 +1183,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":
|
||||
@@ -1163,6 +1350,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:
|
||||
@@ -1180,6 +1368,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)
|
||||
|
||||
Reference in new issue
Block a user