mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
Merge remote-tracking branch 'origin/main' into vr-apks
# Conflicts: # ui/frame_android.py # ui/index.html
This commit is contained in:
commit
35ef3af54b
143 files changed
+11782
-248
No files matched your search
+16
-6
@@ -7,8 +7,8 @@ in the Steam library and gets its own SteamVR panel. Nothing goes through
|
||||
Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
||||
|
||||
Python stdlib only. CLI: python3 ui/frame_android.py
|
||||
install APK [--vr|--flat] [--no-xr-compat] | info APK | patch SRC DST [--add NAME=PATH ...]
|
||||
list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
|
||||
patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
"""
|
||||
import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
|
||||
@@ -336,12 +336,22 @@ def patch(src, dst, add=None):
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['help']
|
||||
try:
|
||||
if cmd == 'install':
|
||||
if cmd in ('info', 'versions'):
|
||||
import frame_apk_versions
|
||||
if cmd == 'info':
|
||||
info = apk_info(args[0])
|
||||
print(frame_apk_versions.describe(info))
|
||||
if info.get('vr'):
|
||||
print('VR app' + ('' if info.get('launchable') else '; Frame Control adds the LAUNCHER entry Lepton needs'))
|
||||
for note in info.get('vr_issues', []):
|
||||
print(note)
|
||||
return
|
||||
info = apk_info(args[0]) if os.path.isfile(args[0]) or args[0].lower().endswith('.apk') else None
|
||||
r = frame_apk_versions.alternatives(
|
||||
info['package'] if info else args[0], info.get('version_code') if info else None)
|
||||
elif cmd == 'install':
|
||||
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
|
||||
xr_compat=False if '--no-xr-compat' in args else None)
|
||||
elif cmd in ('info', 'describe'):
|
||||
r = apk_info(args[0])
|
||||
r.pop('icon_png', None)
|
||||
elif cmd == 'patch':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
|
||||
|
||||
@@ -232,6 +232,7 @@ def apk_info(path):
|
||||
min_sdk = sdk.get('minSdkVersion')
|
||||
info = {
|
||||
'package': package,
|
||||
'version_code': manifest.get('versionCode', (None, None))[1],
|
||||
'version': _text(manifest.get('versionName'), res) or '',
|
||||
'label': _text(app.get('label'), res) or package,
|
||||
'abis': sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2}),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Explain APK requirements and find installable versions in F-Droid's indexes."""
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import frame_android
|
||||
import frame_catalog
|
||||
|
||||
ANDROID = dict(enumerate([
|
||||
'1.0', '1.1', '1.5', '1.6', '2.0', '2.0.1', '2.1', '2.2', '2.3', '2.3.3',
|
||||
'3.0', '3.1', '3.2', '4.0', '4.0.3', '4.1', '4.2', '4.3', '4.4', '4.4W',
|
||||
'5.0', '5.1', '6.0', '7.0', '7.1', '8.0', '8.1', '9', '10', '11', '12',
|
||||
'12L', '13', '14', '15', '16',
|
||||
], 1))
|
||||
REPOS = (('F-Droid', 'https://f-droid.org/repo/'),
|
||||
('F-Droid archive', 'https://f-droid.org/archive/'),
|
||||
('IzzyOnDroid', 'https://apt.izzysoft.de/fdroid/repo/'))
|
||||
NOTE = ('Pick a version whose minimum is Android 11 or lower and that has an '
|
||||
'arm64-v8a build (or no native code). Installable does not mean every feature works.')
|
||||
|
||||
|
||||
def android_name(sdk):
|
||||
return 'Android ' + ANDROID[sdk] if sdk in ANDROID else f'Android API {sdk}'
|
||||
|
||||
|
||||
def describe(info):
|
||||
sdk = info.get('min_sdk')
|
||||
minimum = f'{android_name(sdk)} (API {sdk})' if sdk else 'not specified'
|
||||
try:
|
||||
frame_android.check_installable(info)
|
||||
verdict = 'Lepton can install this APK. Features may still need services Lepton lacks.'
|
||||
except frame_android.FrameError as e:
|
||||
verdict = f'Lepton cannot install this APK: {e}'
|
||||
return (f"{info['package']} · {info.get('version') or '?'} "
|
||||
f"(code {info.get('version_code') if info.get('version_code') is not None else '?'})\n"
|
||||
f"Minimum: {minimum}\nABIs: {', '.join(info['abis']) or 'no native code'}\n{verdict}")
|
||||
|
||||
|
||||
def search_links(package):
|
||||
q = quote(package, safe='')
|
||||
return [{'source': name, 'url': url} for name, url in (
|
||||
('APKMirror', 'https://www.apkmirror.com/?' + urlencode({'post_type': 'app_release', 's': package})),
|
||||
('APKPure', 'https://apkpure.com/search?q=' + q),
|
||||
('Uptodown', 'https://en.uptodown.com/android/search/' + q),
|
||||
('F-Droid', 'https://search.f-droid.org/?q=' + q),
|
||||
('GitHub', 'https://github.com/search?type=repositories&q=' + q),
|
||||
)]
|
||||
|
||||
|
||||
def _versions(package, cached_only=False):
|
||||
versions, errors, seen = [], [], set()
|
||||
for source, repo in REPOS:
|
||||
try:
|
||||
index = frame_catalog.load_index(repo, cached_only=cached_only)
|
||||
except Exception as e: # one bad repo (dropped download, odd index) mustn't hide the others
|
||||
errors.append(f'Could not check {source}: {e}')
|
||||
continue
|
||||
for v in index.get(package, []):
|
||||
url = repo + v['name'].lstrip('/')
|
||||
key = (v['version_code'], v.get('sha256') or url)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
versions.append(dict(v, url=url, source=source))
|
||||
return versions, errors
|
||||
|
||||
|
||||
def alternatives(package, current_version_code=None):
|
||||
"""At most eight releases, preferring arm64-only builds over universal builds."""
|
||||
versions, errors = _versions(package)
|
||||
versions = [v for v in versions if v['version_code'] != current_version_code]
|
||||
total = len(versions)
|
||||
versions.sort(key=lambda v: (v['abis'] == ['arm64-v8a'], v['version_code']), reverse=True)
|
||||
releases = {}
|
||||
for v in versions:
|
||||
releases.setdefault(v['version'], v)
|
||||
versions = sorted(releases.values(), key=lambda v: v['version_code'], reverse=True)[:8]
|
||||
return {'package': package, 'versions': versions, 'total': total,
|
||||
'links': search_links(package), 'note': NOTE, 'errors': errors}
|
||||
|
||||
|
||||
def install(package, url):
|
||||
# Resolve the selection again: the client cannot supply a trusted hash or arbitrary URL.
|
||||
records, _ = _versions(package, cached_only=True)
|
||||
version = next((v for v in records if v['url'] == url), None)
|
||||
if not version:
|
||||
raise frame_android.FrameError('That version is no longer available; check the APK again')
|
||||
apk = frame_catalog.fetch_apk({'a': version['url'], 'h': version['sha256'], 'n': package})
|
||||
info = frame_android.apk_info(apk)
|
||||
if info['package'] != package or info.get('version_code') != version['version_code']:
|
||||
raise frame_android.FrameError('The downloaded APK does not match the selected version')
|
||||
return frame_android.install(apk, source=version['source'])
|
||||
+139
-2
@@ -2,7 +2,7 @@
|
||||
list, verified downloads, installs into per-app Lepton instances, and
|
||||
compatibility reports. Python stdlib only.
|
||||
"""
|
||||
import hashlib, os, shutil, sys, tempfile, threading, time, urllib.error, urllib.request
|
||||
import hashlib, json, os, shutil, sys, tempfile, threading, time, urllib.error, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CATALOG = os.path.join(ROOT, 'apk-catalog')
|
||||
@@ -17,12 +17,149 @@ import frame_compat_db as compat_db # noqa: E402
|
||||
# the per-user cache (FRAME_CONTROL_APP is set by app/main.js).
|
||||
CACHE = (str(frame_host.cache_dir('apk')) if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG
|
||||
else os.path.join(CATALOG, 'data', 'cache'))
|
||||
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
|
||||
# Repo base URL -> local name of its index; every APK download must come from one of these.
|
||||
INDEX_FILES = {'https://f-droid.org/repo/': 'index-v2.json',
|
||||
'https://f-droid.org/archive/': 'index-v2.archive.json',
|
||||
'https://apt.izzysoft.de/fdroid/repo/': 'index-v2.izzy.json'}
|
||||
APK_HOSTS = tuple(INDEX_FILES)
|
||||
_lock = threading.Lock()
|
||||
_cache = {'mtime': None, 'sig': None, 'apps': None, 'by_pkg': None}
|
||||
_env = {}
|
||||
|
||||
|
||||
_index_lock = threading.Lock()
|
||||
_indexes = {}
|
||||
|
||||
|
||||
class _IndexReader:
|
||||
"""Decode one object member at a time; never retain the whole raw index."""
|
||||
def __init__(self, stream):
|
||||
self.stream, self.buffer = stream, ''
|
||||
self.decoder = json.JSONDecoder()
|
||||
|
||||
def fill(self):
|
||||
chunk = self.stream.read(1 << 16)
|
||||
if not chunk:
|
||||
raise ValueError('incomplete F-Droid index')
|
||||
self.buffer += chunk
|
||||
|
||||
def peek(self):
|
||||
self.buffer = self.buffer.lstrip()
|
||||
while not self.buffer:
|
||||
self.fill()
|
||||
self.buffer = self.buffer.lstrip()
|
||||
return self.buffer[0]
|
||||
|
||||
def expect(self, char):
|
||||
if self.peek() != char:
|
||||
raise ValueError('invalid F-Droid index')
|
||||
self.buffer = self.buffer[1:]
|
||||
|
||||
def value(self):
|
||||
self.peek()
|
||||
while True:
|
||||
try:
|
||||
value, end = self.decoder.raw_decode(self.buffer)
|
||||
self.buffer = self.buffer[end:]
|
||||
return value
|
||||
except json.JSONDecodeError:
|
||||
self.fill()
|
||||
|
||||
def members(self):
|
||||
self.expect('{')
|
||||
if self.peek() != '}':
|
||||
while True:
|
||||
key = self.value()
|
||||
if not isinstance(key, str):
|
||||
raise ValueError('invalid F-Droid index key')
|
||||
self.expect(':')
|
||||
yield key
|
||||
if self.peek() == '}':
|
||||
break
|
||||
self.expect(',')
|
||||
self.expect('}')
|
||||
|
||||
|
||||
def _reduce_index(path):
|
||||
from pick import installable
|
||||
packages = {}
|
||||
found = False
|
||||
with open(path, encoding='utf-8') as f:
|
||||
reader = _IndexReader(f)
|
||||
for key in reader.members():
|
||||
if key != 'packages':
|
||||
reader.value()
|
||||
continue
|
||||
found = True
|
||||
for package in reader.members():
|
||||
records, entry = [], reader.value()
|
||||
versions = entry.get('versions') if isinstance(entry, dict) else None
|
||||
for v in (versions.values() if isinstance(versions, dict) else ()):
|
||||
# Skip malformed entries rather than losing the whole repo.
|
||||
if not (isinstance(v, dict) and isinstance(v.get('manifest'), dict)
|
||||
and isinstance(v.get('file'), dict) and v['file'].get('name')):
|
||||
continue
|
||||
if not installable(v):
|
||||
continue
|
||||
m, file = v['manifest'], v['file']
|
||||
records.append({'version': m.get('versionName', ''),
|
||||
'version_code': m.get('versionCode', 0),
|
||||
'min_sdk': m.get('usesSdk', {}).get('minSdkVersion', 1),
|
||||
'abis': m.get('nativecode') or [],
|
||||
'name': file['name'], 'sha256': file.get('sha256')})
|
||||
if records:
|
||||
packages[package] = records
|
||||
if reader.buffer.strip() or f.read().strip():
|
||||
raise ValueError('trailing data in F-Droid index')
|
||||
if not found:
|
||||
raise ValueError('invalid F-Droid index')
|
||||
return packages
|
||||
|
||||
|
||||
def load_index(repo, cached_only=False):
|
||||
"""Compact installable records by package, cached on disk and by mtime in memory."""
|
||||
if repo not in APK_HOSTS:
|
||||
raise ValueError('unexpected index URL')
|
||||
directory = CACHE if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG else os.path.join(CATALOG, 'data')
|
||||
filename = INDEX_FILES[repo]
|
||||
raw = os.path.join(directory, filename)
|
||||
path = raw + '.installable-v1'
|
||||
with _index_lock:
|
||||
mtime = os.stat(path).st_mtime_ns if os.path.exists(path) else None
|
||||
# A newer raw index (the catalogue script refreshed it) outdates the reduced copy.
|
||||
newer_raw = mtime is not None and os.path.exists(raw) and os.stat(raw).st_mtime_ns > mtime
|
||||
if mtime is not None and (cached_only or (time.time() - mtime / 1e9 < 86400 and not newer_raw)):
|
||||
cached = _indexes.get(path)
|
||||
if cached is None or cached[0] != mtime:
|
||||
with open(path) as f:
|
||||
cached = (mtime, json.load(f))
|
||||
_indexes[path] = cached
|
||||
return cached[1]
|
||||
if cached_only:
|
||||
return {}
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=filename, suffix='.part', dir=directory)
|
||||
os.close(fd)
|
||||
try:
|
||||
if os.path.exists(raw) and time.time() - os.path.getmtime(raw) < 86400:
|
||||
index = _reduce_index(raw)
|
||||
refreshed = os.stat(raw).st_mtime_ns
|
||||
else:
|
||||
with open(tmp, 'wb') as f, urllib.request.urlopen(repo + 'index-v2.json', timeout=30) as r:
|
||||
shutil.copyfileobj(r, f, 1 << 20)
|
||||
index = _reduce_index(tmp)
|
||||
refreshed = time.time_ns()
|
||||
with open(tmp, 'w') as f:
|
||||
json.dump(index, f, separators=(',', ':'))
|
||||
os.utime(tmp, ns=(refreshed, refreshed))
|
||||
os.replace(tmp, path)
|
||||
_indexes[path] = (os.stat(path).st_mtime_ns, index)
|
||||
return index
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
|
||||
|
||||
def catalog():
|
||||
"""Rated apps with the database's reports applied."""
|
||||
path = os.path.join(CATALOG, 'site', 'apps.js')
|
||||
|
||||
+28
-16
@@ -24,6 +24,7 @@ FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'ste
|
||||
'label', 'source')
|
||||
TTL = 60 # seconds a fetched copy is reused
|
||||
_lock = threading.Lock()
|
||||
_load_lock = threading.Lock() # refreshing the cached reports (load); separate from _lock, which flush takes
|
||||
_mem = {'at': 0, 'reports': None, 'source': None}
|
||||
|
||||
|
||||
@@ -179,25 +180,36 @@ def _read_mirror():
|
||||
return []
|
||||
|
||||
|
||||
def _save_mirror(reports):
|
||||
"""Keep a copy for offline use. Failing to write it mustn't fail the read."""
|
||||
try:
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with open(MIRROR + '.tmp', 'w') as f:
|
||||
json.dump({'fetched': time.strftime('%Y-%m-%dT%H:%M:%S'), 'reports': reports}, f)
|
||||
os.replace(MIRROR + '.tmp', MIRROR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load():
|
||||
"""All reports: the database (cached for TTL s), else the offline mirror; plus unsent ones."""
|
||||
now = time.time()
|
||||
if _mem['reports'] is None or now - _mem['at'] > TTL:
|
||||
try:
|
||||
if not shared():
|
||||
raise DBError('no key')
|
||||
# The page asks for the catalogue and the reports at once: one refresh at a
|
||||
# time, so they share a fetch and never write the mirror's .tmp together.
|
||||
with _load_lock:
|
||||
now = time.time()
|
||||
if _mem['reports'] is None or now - _mem['at'] > TTL:
|
||||
try:
|
||||
flush()
|
||||
except Exception:
|
||||
pass # sending can fail for any reason; reading must still work
|
||||
reports, source = fetch_all(), 'lakebed'
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with open(MIRROR + '.tmp', 'w') as f:
|
||||
json.dump({'fetched': time.strftime('%Y-%m-%dT%H:%M:%S'), 'reports': reports}, f)
|
||||
os.replace(MIRROR + '.tmp', MIRROR)
|
||||
except DBError:
|
||||
reports, source = _read_mirror(), 'mirror'
|
||||
_mem.update(at=now, reports=reports, source=source)
|
||||
if not shared():
|
||||
raise DBError('no key')
|
||||
try:
|
||||
flush()
|
||||
except Exception:
|
||||
pass # sending can fail for any reason; reading must still work
|
||||
reports, source = fetch_all(), 'lakebed'
|
||||
_save_mirror(reports)
|
||||
except DBError:
|
||||
reports, source = _read_mirror(), 'mirror'
|
||||
_mem.update(at=now, reports=reports, source=source)
|
||||
sent = {r.get('id') for r in _mem['reports']}
|
||||
return _mem['reports'] + [r for r in _outbox() if r['id'] not in sent]
|
||||
|
||||
|
||||
+18
-9
@@ -35,7 +35,13 @@ PY = 'python3 ~/' + UTILS + '/'
|
||||
# devkit-steam is the trampoline file that switches SteamOS to a sideloaded client
|
||||
# (select_steam.sh); a folder there breaks Valve's devkit tools.
|
||||
RESERVED_IDS = ('steam', 'steamdeckard', 'steamvr', 'steamvrdeckard', 'devkit-steam')
|
||||
ID_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$')
|
||||
# Any title folder on the Frame we'll list, launch or remove: safe in a shell and a path.
|
||||
ID_RE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$')
|
||||
# What a new install may use. Steam's create-shortcut refused "fc-smoke-exe" with
|
||||
# "missing/invalid arguments" and took the same program as "FCSmokeProbe" (headset
|
||||
# smoke test, 2026-09-27, build 20260922.6101926); Valve's client only allows
|
||||
# GAMEID_ALLOWED_PATTERN, ^[A-Za-z_][A-Za-z0-9_.]+$ (devkit_client/gui2). No dots here.
|
||||
NEW_ID_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]{1,63}$')
|
||||
DIR_RE = re.compile(r'^/[A-Za-z0-9_./-]+$')
|
||||
|
||||
# Zip limits: well above any real game, well below a zip bomb.
|
||||
@@ -124,13 +130,15 @@ def _pe(f, head):
|
||||
|
||||
|
||||
def title_id(name):
|
||||
"""The Devkit Game id Steam shows as the title's name: [A-Za-z0-9_-], at most 64."""
|
||||
s = re.sub(r'[^A-Za-z0-9_-]+', '_', str(name or '').strip())
|
||||
s = re.sub(r'_+', '_', s).strip('_-')[:64].strip('_-')
|
||||
"""The Devkit Game id Steam shows as the title's name, in the form Steam accepts
|
||||
(NEW_ID_RE): letters, digits and _, not starting with a digit, 2 to 64 long."""
|
||||
s = re.sub(r'[^A-Za-z0-9]+', '_', str(name or '').strip()).strip('_')[:64].strip('_')
|
||||
if not s:
|
||||
raise FrameError('the title needs a name with some letters or digits')
|
||||
if s.lower() in RESERVED_IDS:
|
||||
s += '-game'
|
||||
if s[0].isdigit():
|
||||
s = '_' + s[:63]
|
||||
if len(s) < 2 or s.lower() in RESERVED_IDS:
|
||||
s += '_game'
|
||||
return s
|
||||
|
||||
|
||||
@@ -653,7 +661,7 @@ def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
|
||||
|
||||
def _install(plan, step):
|
||||
gid = plan['id']
|
||||
if not ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
||||
if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
||||
raise FrameError(f'bad title id {gid!r}')
|
||||
step("Syncing Valve's devkit tools to the Frame", 0.02)
|
||||
ensure_utils()
|
||||
@@ -683,8 +691,9 @@ def _install(plan, step):
|
||||
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||
registered = True # the files stay: Steam registers them once it's running
|
||||
if 'error' in reply:
|
||||
raise FrameError(f"Uploaded, but Steam didn't register it: {reply['error']}. "
|
||||
"With Steam running on the Frame, install it again.")
|
||||
err = str(reply['error']).strip().rstrip('.')
|
||||
hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else ''
|
||||
raise FrameError(f"Uploaded, but Steam didn't register it: {err}.{hint}")
|
||||
step('Done', 1.0)
|
||||
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
|
||||
return meta
|
||||
|
||||
+518
-150
File diff suppressed because it is too large.
Load diff
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Stand-in for ssh when Frame Control's server runs on the Frame itself
|
||||
# (FRAME_LOCAL=1, started by the iPhone app). The server and its helpers call
|
||||
# `ssh [options] frame COMMAND`, and rsync calls it as its transport; here that
|
||||
# means: run COMMAND in the home directory, as ssh would, with the same stdin.
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-[bcDEeFIiJLlmOoPpQRSWwB]) shift 2 ;; # options that take a value
|
||||
-?*) shift ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
[ $# -gt 0 ] && shift # the host alias
|
||||
cd "$HOME" || exit 255
|
||||
[ $# -eq 0 ] && exit 0 # -N: nothing to run
|
||||
# Stopping ssh ends everything the command started (sshd hangs up the session).
|
||||
# To do the same, run COMMAND as its own process group and pass on a TERM, HUP
|
||||
# or INT to all of it, so e.g. a live-video ffmpeg can't outlive its stream.
|
||||
set -m # job control (bash allows it without a terminal): own process group, and stdin kept
|
||||
"${SHELL:-/bin/sh}" -c "$*" &
|
||||
child=$!
|
||||
trap 'kill -TERM -- "-$child" 2>/dev/null' TERM HUP INT
|
||||
wait "$child"
|
||||
status=$?
|
||||
while kill -0 "$child" 2>/dev/null; do # a trapped signal ends `wait` early
|
||||
wait "$child"
|
||||
status=$?
|
||||
done
|
||||
exit "$status"
|
||||
+201
-20
@@ -7,6 +7,9 @@ up by scripts/connect.sh or ui/frame_connect.py.
|
||||
|
||||
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
|
||||
Env: FRAME_ALIAS (default frame)
|
||||
FRAME_LOCAL=1 run on the Frame itself (the iPhone app starts it there over SSH)
|
||||
FRAME_UI_KEY required X-Frame-UI value (the iPhone app passes a fresh one)
|
||||
FRAME_DEVICE what to call the device the page runs on (e.g. iPhone)
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
@@ -34,6 +37,7 @@ from urllib.parse import parse_qs, unquote, urlparse
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import frame_android # noqa: E402
|
||||
import frame_apk_versions # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
@@ -43,12 +47,20 @@ import frame_webinstall # noqa: E402
|
||||
frame_host.trust_bundled_cas()
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
# On the Frame itself, every `ssh frame COMMAND` the server and its helpers run
|
||||
# goes to local-bin/ssh, which runs COMMAND here instead, so one code path serves
|
||||
# both. Nothing listens beyond 127.0.0.1; the phone reaches it through SSH.
|
||||
LOCAL = os.environ.get("FRAME_LOCAL") == "1"
|
||||
if LOCAL:
|
||||
os.environ["PATH"] = f"{HERE / 'local-bin'}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||
UI_KEY = os.environ.get("FRAME_UI_KEY") or "1"
|
||||
DEVICE = os.environ.get("FRAME_DEVICE") or "phone"
|
||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
|
||||
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
|
||||
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||
# supports it (not on Windows: there every command connects on its own).
|
||||
CONTROL = frame_host.control_path()
|
||||
CONTROL = None if LOCAL else frame_host.control_path()
|
||||
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
|
||||
# Commands use the master when it's up and connect directly when it isn't.
|
||||
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
|
||||
@@ -80,9 +92,93 @@ exit 1
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
def __init__(self, message, status=502):
|
||||
def __init__(self, message, status=502, apk=None):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.apk = apk
|
||||
|
||||
|
||||
# What ssh prints when it never reached the Frame, and what to tell the user
|
||||
# instead. Only ssh's own wording is matched, so a command that ran on the Frame
|
||||
# and failed keeps its real error.
|
||||
UNREACHABLE = [
|
||||
(re.compile(r"Could not resolve hostname"),
|
||||
"Can't find the Frame on the network. Check it's on and connected, or run Set Up Connection."),
|
||||
(re.compile(r"port \d+: (Operation timed out|Connection timed out|Host is down|No route to host|"
|
||||
r"Network is unreachable)"),
|
||||
"The Frame isn't answering. It may be asleep, switched off, or on another network."),
|
||||
(re.compile(r"[Tt]imed out talking to "),
|
||||
"The Frame took too long to answer. It may be asleep or busy; try again."),
|
||||
(re.compile(r"port \d+: Connection refused"),
|
||||
"The Frame refused the connection. Check Developer Mode is still on."),
|
||||
(re.compile(r"Permission denied \(publickey"),
|
||||
"The Frame didn't accept this computer's SSH key. Run Set Up Connection again."),
|
||||
(re.compile(r"Host key verification failed"),
|
||||
"The Frame's SSH identity changed (after a reinstall, or a different device). Run Set Up Connection again."),
|
||||
(re.compile(r"kex_exchange_identification|Connection closed by .* port \d+|Connection reset by .* port \d+"),
|
||||
"The connection to the Frame dropped. Try again."),
|
||||
]
|
||||
|
||||
|
||||
def unreachable(message):
|
||||
"""The plain-language reason the Frame couldn't be reached, or None if it was."""
|
||||
for pattern, friendly in UNREACHABLE:
|
||||
if pattern.search(message):
|
||||
return friendly
|
||||
return None
|
||||
|
||||
|
||||
def error_body(message):
|
||||
"""A JSON error body and status; SSH connection failures become one clear offline message."""
|
||||
friendly = unreachable(message)
|
||||
if friendly:
|
||||
return {"error": friendly, "offline": True, "detail": message}, 503
|
||||
return {"error": message}, None
|
||||
|
||||
|
||||
# ---- Background jobs: installs that can outlast a request ------------------
|
||||
#
|
||||
# Flatpak and Android installs can take many minutes. The request starts the
|
||||
# work and returns a job id at once; the page polls /api/job for the outcome.
|
||||
|
||||
JOB_TTL = 3600
|
||||
_jobs_lock = threading.Lock()
|
||||
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"}
|
||||
|
||||
|
||||
def start_job(label, work):
|
||||
"""Run work() in the background. It returns a dict with a "message"."""
|
||||
now = time.time()
|
||||
with _jobs_lock:
|
||||
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
|
||||
del _jobs[j]
|
||||
job = secrets.token_hex(8)
|
||||
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now}
|
||||
|
||||
def run():
|
||||
fields = {}
|
||||
try:
|
||||
result = work()
|
||||
fields = {"message": result.get("message") or f"{label}: done", "result": result}
|
||||
except (Failure, frame_android.FrameError) as e:
|
||||
fields = {"error": unreachable(str(e)) or str(e)}
|
||||
except Exception as e:
|
||||
fields = {"error": f"{type(e).__name__}: {e}"}
|
||||
finally:
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(fields, done=True, time=time.time())
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
return {"message": f"{label}…", "job": job}
|
||||
|
||||
|
||||
def job_status(query):
|
||||
with _jobs_lock:
|
||||
job = _jobs.get((parse_qs(query).get("id") or [""])[0])
|
||||
snapshot = job and {k: v for k, v in job.items() if k != "time"}
|
||||
if not snapshot:
|
||||
raise Failure("no such job (the app may have restarted)", 404)
|
||||
return snapshot
|
||||
|
||||
|
||||
_master_lock = threading.Lock()
|
||||
@@ -408,19 +504,43 @@ def flatpak(body):
|
||||
if not FLATPAK_ID.match(app):
|
||||
raise Failure("bad Flatpak app ID", 400)
|
||||
if action == "install":
|
||||
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
|
||||
ssh("flatpak remote-add --user --if-not-exists flathub "
|
||||
"https://dl.flathub.org/repo/flathub.flatpakrepo && "
|
||||
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=900)
|
||||
return {"message": f"Installed {app}"}
|
||||
def work():
|
||||
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
|
||||
ssh("flatpak remote-add --user --if-not-exists flathub "
|
||||
"https://dl.flathub.org/repo/flathub.flatpakrepo && "
|
||||
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=1800)
|
||||
return {"message": f"Installed {app}"}
|
||||
return start_job(f"Install {app}", work)
|
||||
if action == "uninstall":
|
||||
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
|
||||
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
|
||||
raise Failure("action must be install or uninstall", 400)
|
||||
|
||||
|
||||
def power(what, password):
|
||||
"""Sleep, restart or shut down from the Frame itself: sudo takes the Developer Mode password on stdin."""
|
||||
if not isinstance(password, str) or not password or "\n" in password:
|
||||
raise Failure("enter the Developer Mode password", 400)
|
||||
try:
|
||||
r = subprocess.run(["sudo", "-S", "-k", "-p", "", "systemctl", what], input=password + "\n",
|
||||
capture_output=True, text=True, timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"systemctl {what} didn't answer")
|
||||
if r.returncode != 0:
|
||||
err = r.stderr.strip()
|
||||
raise Failure("that password wasn't accepted" if "incorrect password" in err or "Sorry" in err
|
||||
else err or f"systemctl {what} failed", 400)
|
||||
return {"message": {"suspend": "Going to sleep", "reboot": "Restarting", "poweroff": "Shutting down"}[what]}
|
||||
|
||||
|
||||
def open_thing(body):
|
||||
what = body.get("what")
|
||||
if LOCAL:
|
||||
# Terminals, Steam Link and remote desktop open on the phone (its app does
|
||||
# that); what's left here is power, with the password the page asked for.
|
||||
if what in ("reboot", "poweroff", "suspend"):
|
||||
return power(what, body.get("password"))
|
||||
raise Failure("open that from the app", 400)
|
||||
try:
|
||||
if what == "terminal":
|
||||
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
|
||||
@@ -443,14 +563,31 @@ def open_thing(body):
|
||||
raise Failure("unknown target", 400)
|
||||
|
||||
|
||||
def apk_versions(query):
|
||||
args = parse_qs(query, keep_blank_values=True)
|
||||
packages, codes = args.get('package', []), args.get('code', [])
|
||||
if len(packages) != 1 or not frame_android.PKG_RE.match(packages[0]):
|
||||
raise Failure('invalid Android package id', 400)
|
||||
if codes and (len(codes) != 1 or not re.fullmatch(r'[0-9]{1,19}', codes[0])):
|
||||
raise Failure('invalid version code', 400)
|
||||
return frame_apk_versions.alternatives(packages[0], int(codes[0]) if codes else None)
|
||||
|
||||
|
||||
def android(body):
|
||||
"""Android apps, each in its own persistent Lepton instance (frame_android.py)."""
|
||||
action, pkg = body.get("action"), str(body.get("package", ""))
|
||||
ensure_master()
|
||||
try:
|
||||
if action == "install":
|
||||
m = frame_catalog.install(pkg)
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.", "app": m}
|
||||
url = body.get("url")
|
||||
if not url:
|
||||
frame_catalog.app(pkg) # an unknown package fails now, not in the background
|
||||
|
||||
def work():
|
||||
m = frame_apk_versions.install(pkg, url) if url else frame_catalog.install(pkg)
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
||||
"app": m}
|
||||
return start_job(f"Install {pkg}", work)
|
||||
if action in ("launch", "stop"):
|
||||
m = getattr(frame_android, action)(pkg)
|
||||
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
||||
@@ -739,6 +876,30 @@ class AdbTunnel:
|
||||
return False
|
||||
|
||||
|
||||
class PodmanShell:
|
||||
"""AdbTunnel's stand-in on the Frame itself: there's no adb there, but each
|
||||
instance is a podman container, so run Android's shell inside it."""
|
||||
|
||||
def __init__(self, ports, containers):
|
||||
self.containers = containers
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def shell(self, port, command, timeout=20):
|
||||
ctr = self.containers.get(port)
|
||||
if not ctr:
|
||||
raise Failure(f"port {port} isn't a Lepton container this app can reach")
|
||||
return ssh(f"podman exec {shlex.quote(ctr)} /system/bin/sh -c {shlex.quote(command)}", timeout=timeout)
|
||||
|
||||
|
||||
def android_shell(ports, containers):
|
||||
return PodmanShell(ports, containers) if LOCAL else AdbTunnel(ports)
|
||||
|
||||
|
||||
DISPLAY_READ = "echo @@pkgs; pm list packages -3; echo @@size; wm size; echo @@density; wm density; " \
|
||||
"echo @@font; settings get system font_scale"
|
||||
|
||||
@@ -811,7 +972,7 @@ def android_displays():
|
||||
if not ports:
|
||||
return {"instances": []}
|
||||
instances = []
|
||||
with AdbTunnel(ports) as t:
|
||||
with android_shell(ports, containers) as t:
|
||||
for p in ports:
|
||||
item = {"port": p, "container": containers.get(p)}
|
||||
try:
|
||||
@@ -865,10 +1026,10 @@ def android_display(body):
|
||||
|
||||
if not cmds:
|
||||
raise Failure("nothing to change: give density, size or fontScale", 400)
|
||||
ports, _, _ = lepton_ports()
|
||||
ports, containers, _ = lepton_ports()
|
||||
if port not in ports:
|
||||
raise Failure(f"no Lepton instance is listening on Frame port {port}", 404)
|
||||
with AdbTunnel([port]) as t:
|
||||
with android_shell([port], containers) as t:
|
||||
for c in cmds:
|
||||
out = t.shell(port, c)
|
||||
# wm prints usage or an exception on failure but may still exit 0.
|
||||
@@ -1135,8 +1296,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# All of /api/*, not just POST: an <img> on any website could otherwise
|
||||
# trigger a headset capture and display it.
|
||||
api = urlparse(self.path).path.startswith("/api/")
|
||||
if (self.command == "POST" or api) and self.headers.get("X-Frame-UI") != "1":
|
||||
self.send_json({"error": "missing X-Frame-UI header"}, 403)
|
||||
if (self.command == "POST" or api) and not secrets.compare_digest(self.headers.get("X-Frame-UI") or "", UI_KEY):
|
||||
self.send_json({"error": "missing or wrong X-Frame-UI header"}, 403)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -1156,6 +1317,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def send_json(self, obj, status=200):
|
||||
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
||||
|
||||
def send_error_json(self, message, status, apk=None):
|
||||
body, offline_status = error_body(message)
|
||||
if apk is not None:
|
||||
body["apk"] = apk
|
||||
self.send_json(body, offline_status or status)
|
||||
|
||||
def do_GET(self):
|
||||
if not self.local_request():
|
||||
return
|
||||
@@ -1165,8 +1332,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/api/host":
|
||||
self.send_json({"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||
elif path == "/api/apk-versions":
|
||||
self.send_json(apk_versions(url.query))
|
||||
elif path == "/api/android":
|
||||
ensure_master()
|
||||
self.send_json({"apps": frame_android.list_apps()})
|
||||
@@ -1175,6 +1345,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"titles": frame_titles.list_titles()})
|
||||
elif path == "/api/titles/job":
|
||||
self.send_json(title_job(url.query))
|
||||
elif path == "/api/job":
|
||||
self.send_json(job_status(url.query))
|
||||
elif path == "/api/android/displays":
|
||||
self.send_json(android_displays())
|
||||
elif path == "/api/android/reports":
|
||||
@@ -1204,9 +1376,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
except Failure as e:
|
||||
self.send_json({"error": str(e)}, e.status)
|
||||
self.send_error_json(str(e), e.status, e.apk)
|
||||
except frame_android.FrameError as e:
|
||||
self.send_json({"error": str(e)}, 502)
|
||||
self.send_error_json(str(e), 502)
|
||||
except Exception as e:
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
@@ -1230,11 +1402,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
self.send_json(handler(body))
|
||||
except Failure as e:
|
||||
self.send_json({"error": str(e)}, e.status)
|
||||
self.send_error_json(str(e), e.status, e.apk)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.send_json({"error": f"bad request: {e}"}, 400)
|
||||
except frame_android.FrameError as e:
|
||||
self.send_json({"error": str(e)}, 502)
|
||||
self.send_error_json(str(e), 502)
|
||||
except Exception as e:
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
@@ -1335,6 +1507,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
keep = True # stage_title owns tmp now, and removes it on failure
|
||||
return stage_title(str(dest), temp_dir=str(tmp))
|
||||
if mode == "apk":
|
||||
try:
|
||||
info = frame_android.apk_info(str(dest))
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e), 400)
|
||||
try:
|
||||
frame_android.check_installable(info)
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e), 400, {"package": info["package"], "version_code": info.get("version_code"), "blocker": str(e)})
|
||||
ensure_master()
|
||||
try:
|
||||
display = self.headers.get("X-APK-Display", "auto")
|
||||
@@ -1369,7 +1549,8 @@ def main():
|
||||
sys.stdin.buffer.read()
|
||||
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||
threading.Thread(target=watch_stdin, daemon=True).start()
|
||||
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
|
||||
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
Reference in new issue
Block a user