Merge main into devices: several headsets alongside the app store, comfort, panels and media

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-29 12:44:30 +10:00
commit 02b9413f78
129 files changed
+12672 -148

No files matched your search

+8
View File
@@ -43,3 +43,11 @@ Rules
class SourceError(Exception):
"""User-readable failure from a source (network, format, verification)."""
class SourceLimited(SourceError):
"""The source's host asked us to slow down; retry_after is in seconds."""
def __init__(self, message, retry_after=None):
super().__init__(message)
self.retry_after = retry_after
+34
View File
@@ -0,0 +1,34 @@
"""Opt-in local store fixtures: FRAME_APK_SEARCH_DEMO=1. Never downloads."""
import json
from pathlib import Path
from apk_sources import SourceError
KIND = 'demo'
FIXTURES = Path(__file__).resolve().parents[2] / 'tests' / 'fixtures' / 'apk-search'
def sources():
return [{'id': 'demo-' + key, 'kind': KIND, 'name': name, 'enabled': True,
'builtin': True, 'trust': trust, 'url': 'https://example.invalid'}
for key, name, trust in [('github', 'GitHub', 'official'), ('fdroid', 'F-Droid', 'community'),
('sidequest', 'SideQuest', 'official'), ('itch', 'itch.io', 'community')]]
def search(source, query, limit=50):
if source['id'] == 'demo-itch':
raise SourceError('Source temporarily unavailable')
entries = json.loads((FIXTURES / 'store.json').read_text())
if source['id'] == 'demo-sidequest':
entries = [e for e in entries if e['vr']]
if source['id'] == 'demo-fdroid':
entries = [e for e in entries if not e['vr']]
return [dict(e, verified=source['id'] in ('demo-github', 'demo-fdroid')) for e in entries
if query.lower() in (e['name'] + ' ' + e['summary']).lower()][:limit]
def details(source, entry_id):
return next(e for e in search(source, '') if e['id'] == entry_id)
def download(source, entry_id, version_code=None):
raise SourceError('Preview sources cannot download or install apps')
+196
View File
@@ -0,0 +1,196 @@
"""Bounded artwork cache. Only source-provided URLs get opaque image handles."""
from collections import OrderedDict
import http.client
import ipaddress
import secrets
import socket
import ssl
import threading
import time
from urllib.parse import urljoin, urlsplit
from apk_sources import SourceError
_lock = threading.Lock()
_urls = OrderedDict()
_cache = OrderedDict()
MAX_IMAGE = 8 * 1024 * 1024
MAX_CACHE = 64 * 1024 * 1024
def valid_url(url):
try:
p = urlsplit(url)
return (p.scheme in ('https', 'http') and bool(p.hostname) and not p.username and not p.password
and p.port in (None, 80, 443) and len(url) <= 4096)
except (ValueError, TypeError):
return False
def register(url):
if not isinstance(url, str) or not valid_url(url):
return None
with _lock:
for token, known in _urls.items():
if known == url:
_urls.move_to_end(token)
return '/source-image/' + token
token = secrets.token_urlsafe(24)
_urls[token] = url
while len(_urls) > 4096:
_urls.popitem(last=False)
return '/source-image/' + token
def artwork(entry):
images = entry.get('images') or {}
if not isinstance(images, dict):
images = {}
return {'icon': register(images.get('icon') or entry.get('icon')),
'banner': register(images.get('banner')),
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:12]) if path]}
def image_type(data):
if data.startswith(b'\x89PNG\r\n\x1a\n'):
return 'image/png'
if data.startswith(b'\xff\xd8\xff'):
return 'image/jpeg'
if data.startswith((b'GIF87a', b'GIF89a')):
return 'image/gif'
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
return 'image/webp'
raise SourceError('Artwork is not a supported image')
def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
"""deadline: time.monotonic() value by which the whole fetch, redirects included, must finish."""
data = get(url, {'Accept': 'image/png,image/jpeg,image/webp,image/gif'}, redirects, deadline, limit)
return data, image_type(data)
# Lookups that outlast their deadline keep running; cap them so they can't pile up.
_resolvers = threading.BoundedSemaphore(4)
def _resolve(host, port, timeout):
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
if not _resolvers.acquire(blocking=False):
raise SourceError('Too many slow artwork name lookups are still running; try again shortly')
found = {}
def run():
try:
found['addresses'] = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except OSError as e:
found['error'] = e
finally:
_resolvers.release()
try:
worker = threading.Thread(target=run, daemon=True)
worker.start()
except BaseException:
_resolvers.release() # the worker never ran, so it can't release its slot
raise
worker.join(timeout)
if 'error' in found:
raise found['error']
if 'addresses' not in found:
raise SourceError('Artwork download took too long')
return found['addresses']
def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE):
"""GET a public HTTP(S) URL within an overall deadline (default 60 s), redirects included.
A watchdog shuts the socket at the deadline, so a server trickling bytes can't outlast it."""
deadline = time.monotonic() + 60 if deadline is None else deadline
def left():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise SourceError('Artwork download took too long')
return remaining
if not valid_url(url):
raise SourceError('Artwork URL is not allowed')
p = urlsplit(url)
port = p.port or (443 if p.scheme == 'https' else 80)
addresses = _resolve(p.hostname, port, left())
if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses):
raise SourceError('Private network artwork is not allowed')
# Connect to the checked IP, never resolve again between validation and use.
live = [socket.create_connection((addresses[0][4][0], port), timeout=min(10, left()))]
def expire():
try: # the plain socket method: it also ends a TLS handshake in progress
socket.socket.shutdown(live[0], socket.SHUT_RDWR)
except OSError:
pass
watchdog = threading.Timer(left(), expire)
watchdog.daemon = True
watchdog.start()
conn = http.client.HTTPConnection(p.hostname, port, timeout=10)
try:
if p.scheme == 'https':
# Handshake only once the watchdog can reach the TLS socket, within the remaining time.
live[0] = ssl.create_default_context().wrap_socket(live[0], server_hostname=p.hostname,
do_handshake_on_connect=False)
live[0].settimeout(min(10, left()))
live[0].do_handshake()
conn.sock = live[0]
path = p.path or '/'
if p.query:
path += '?' + p.query
conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', **(headers or {})})
live[0].settimeout(min(10, left()))
response = conn.getresponse()
if response.status in (301, 302, 303, 307, 308) and redirects:
target = urljoin(url, response.getheader('Location', ''))
conn.close()
return get(target, headers, redirects - 1, deadline, limit)
if response.status != 200:
raise SourceError('Artwork is unavailable')
data = b''
while len(data) <= limit:
live[0].settimeout(min(10, left()))
chunk = response.read1(min(16384, limit + 1 - len(data))) # one receive at most
if not chunk:
break
data += chunk
if len(data) > limit:
raise SourceError('Artwork is too large')
left()
return data
except (OSError, http.client.HTTPException) as e:
if time.monotonic() >= deadline:
raise SourceError('Artwork download took too long') from e
raise
finally:
watchdog.cancel()
conn.close()
live[0].close()
def remember(url, data):
value = (data, image_type(data))
if len(data) > MAX_IMAGE:
raise SourceError('Artwork is too large')
with _lock:
_cache[url] = value
_cache.move_to_end(url)
while sum(len(v[0]) for v in _cache.values()) > MAX_CACHE:
_cache.popitem(last=False)
return value
def image(token):
with _lock:
url = _urls.get(token)
if not url:
raise SourceError('Unknown artwork')
cached = _cache.get(url)
if cached:
_cache.move_to_end(url)
return cached
data, _ = fetch(url)
return remember(url, data)
+236
View File
@@ -0,0 +1,236 @@
"""Small HTTPS cache and APK downloader for public publisher sources."""
import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.parse, urllib.request, zipfile
from email.utils import parsedate_to_datetime
import frame_host
from . import SourceError, SourceLimited
UA = 'FrameControl/0.1'
APK_CAP = 2 * 1024 ** 3 # cached APKs across all sources, least recently used go first
RECENT = 3600 # an APK used this recently may be about to be installed; never pruned
_use_lock = threading.Lock() # pruning's final check and delete vs touch()/claim()
_in_use = {} # APK path -> installs using it
def touch(path):
"""Mark a cached APK as just used; False if pruning already removed it."""
with _use_lock:
try:
os.utime(str(path))
return True
except FileNotFoundError:
return False
def claim(path):
"""Protect a downloaded APK from pruning until release(path)."""
path = os.path.abspath(str(path))
with _use_lock:
os.utime(path) # raises if it has gone
_in_use[path] = _in_use.get(path, 0) + 1
def release(path):
path = os.path.abspath(str(path))
with _use_lock:
if _in_use.get(path, 0) > 1:
_in_use[path] -= 1
else:
_in_use.pop(path, None)
BACKOFF = 600 # seconds to leave a host alone after 403/429 without Retry-After
_limited = {} # host -> time.time() before which we don't contact it
_limited_lock = threading.Lock()
def _host(url):
return (urllib.parse.urlsplit(url).hostname or '').lower()
def throttle(url, headers=None):
"""Remember that url's host asked us to back off; return the delay in seconds."""
headers = headers or {}
now, delay = time.time(), None
value = (headers.get('Retry-After') or '').strip()
if value.isdigit():
delay = int(value)
elif value:
try:
delay = parsedate_to_datetime(value).timestamp() - now
except (TypeError, ValueError, OverflowError):
pass
reset = headers.get('X-RateLimit-Reset') or ''
if delay is None and headers.get('X-RateLimit-Remaining') == '0' and reset.isdigit():
delay = int(reset) - now # GitHub
delay = min(max(delay if delay is not None else BACKOFF, 1), 6 * 3600)
with _limited_lock:
_limited[_host(url)] = max(_limited.get(_host(url), 0), now + delay)
return delay
def wait_time(url):
with _limited_lock:
return max(0, _limited.get(_host(url), 0) - time.time())
def limited_error(name, seconds, hint=''):
minutes = max(1, int(round(seconds / 60)))
return SourceLimited('%s is limiting requests; try again in %d minute%s%s'
% (name, minutes, '' if minutes == 1 else 's', hint), seconds)
def cache():
path = frame_host.cache_dir('apk-sources', 'publisher')
os.makedirs(path, exist_ok=True)
return str(path)
def prune():
"""Trim the download caches: APKs to APK_CAP by mtime, orphaned .part/temp files, old listings.
Only the long-running app prunes (at start and after store downloads): claim() is
in-process, so the CLIs never prune and so can't delete an APK the app is installing.
"""
now, apks = time.time(), []
for folder in (str(frame_host.cache_dir('apk-sources')), cache()):
try:
names = os.listdir(folder)
except OSError:
continue
for name in names:
path = os.path.join(folder, name)
try:
st = os.lstat(path)
age = now - st.st_mtime
if os.path.isdir(path) and not os.path.islink(path):
if name.startswith('tmp') and age > 86400: # an interrupted F-Droid index download
shutil.rmtree(path, ignore_errors=True)
elif (name.endswith('.part') and age > 86400) or (name.endswith('.data') and age > 7 * 86400):
os.remove(path)
elif name.endswith('.apk'):
apks.append((st.st_mtime, st.st_size, path))
except OSError:
pass
total = sum(size for _, size, _ in apks)
for _, size, path in sorted(apks):
if total <= APK_CAP:
break
with _use_lock: # the scan is old news: check again right before deleting
try:
if os.path.abspath(path) in _in_use or time.time() - os.stat(path).st_mtime < RECENT:
continue
os.remove(path)
total -= size
except OSError:
pass
def checked_url(url, hosts):
try:
p = urllib.parse.urlsplit(url)
port = p.port
except (ValueError, TypeError) as e:
raise SourceError('Source returned an invalid URL') from e
if p.scheme != 'https' or p.username or p.password or port not in (None, 443) or p.hostname not in hosts:
raise SourceError('Source returned an unexpected download URL')
return url
class Redirect(urllib.request.HTTPRedirectHandler):
def __init__(self, hosts):
self.hosts = hosts
def redirect_request(self, req, fp, code, msg, headers, newurl):
checked_url(newurl, self.hosts)
result = super().redirect_request(req, fp, code, msg, headers, newurl)
if result:
result.remove_header('Authorization')
return result
def open_url(url, hosts, headers=None):
checked_url(url, hosts)
return urllib.request.build_opener(Redirect(hosts)).open(
urllib.request.Request(url, headers={'User-Agent': UA, **(headers or {})}), timeout=60)
def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
path = os.path.join(cache(), hashlib.sha256(url.encode()).hexdigest() + '.data')
name = name or _host(url) or 'The source'
def cached():
if os.path.isfile(path): # throttled: an older copy beats no results
with open(path, 'rb') as f:
return f.read()
try:
if os.path.isfile(path) and time.time() - os.path.getmtime(path) < ttl:
with open(path, 'rb') as f:
return f.read()
wait = wait_time(url)
if wait:
data = cached()
if data is None:
raise limited_error(name, wait, hint)
return data
with open_url(url, hosts, headers) as r:
data = r.read(8 * 1024 * 1024 + 1)
if len(data) > 8 * 1024 * 1024:
raise SourceError('Source index is too large')
fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part')
try:
with os.fdopen(fd, 'wb') as f:
f.write(data)
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.remove(tmp)
return data
except urllib.error.HTTPError as e:
if e.code in (403, 429):
delay = throttle(url, e.headers)
data = cached()
if data is None:
raise limited_error(name, delay, hint) from e
return data
raise SourceError('Source HTTP error: ' + str(e.code)) from e
except (OSError, ValueError) as e:
raise SourceError('Could not read source: ' + str(e)) from e
def apk(url, hosts, digest=None, name=None):
tmp = None
name = name or _host(url)
wait = wait_time(url)
if wait:
raise limited_error(name, wait)
try:
fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part')
h = hashlib.sha256()
with os.fdopen(fd, 'wb') as f, open_url(url, hosts) as r:
size = 0
while True:
chunk = r.read(1 << 20)
if not chunk:
break
size += len(chunk)
if size > 2 * 1024 ** 3:
raise SourceError('APK exceeds the 2 GiB download limit')
h.update(chunk)
f.write(chunk)
actual = h.hexdigest()
if digest and actual != digest:
raise SourceError('SHA-256 mismatch; download discarded')
with zipfile.ZipFile(tmp) as z:
if 'AndroidManifest.xml' not in z.namelist():
raise SourceError('Download is not an APK')
path = os.path.join(cache(), actual + '.apk')
os.replace(tmp, path)
return {'apk': path, 'obb': [], 'sha256': actual, 'verified': bool(digest)}
except urllib.error.HTTPError as e:
if e.code in (403, 429):
raise limited_error(name, throttle(url, e.headers)) from e
raise SourceError('Could not download APK: HTTP error ' + str(e.code)) from e
except (OSError, ValueError, zipfile.BadZipFile) as e:
raise SourceError('Could not download APK: ' + str(e)) from e
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
+720
View File
@@ -0,0 +1,720 @@
"""Signed F-Droid repositories. CLI: add|remove|list|search|download."""
import argparse
import base64
import contextlib
import errno
import hashlib
from html.parser import HTMLParser
import json
import os
from pathlib import Path
import re
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
if __package__ in (None, ''):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from apk_sources import SourceError, SourceLimited, _web
import frame_host
from frame_apk_sign import _der_parts, _cert_key, der
from frame_catalog import _IndexReader, _reduce_index, _sha256
KIND = 'fdroid'
CACHE_VERSION = 2
_LOCK = threading.RLock() # settings only; never held while downloading
_load_locks = {}
_refreshing = {} # source id -> background refresh thread
_retry_at = {} # source id -> time before which a failed refresh isn't retried
_stale = set() # source ids currently served from an expired index
MAX_AGE = 86400
# Published by the repository operators; a user repository without a pin uses TOFU.
FDROID_PIN = '43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab'
IZZY_PIN = '3bf0d6abfeae2f401707b6d966be743bf0eee49c2561b9ba39073711f628937a'
_DIGESTS = {
'608648016503040201': ('sha256', '3031300d060960864801650304020105000420'),
'608648016503040202': ('sha384', '3041300d060960864801650304020205000430'),
'608648016503040203': ('sha512', '3051300d060960864801650304020305000440'),
'2b0e03021a': ('sha1', '3021300906052b0e03021a05000414'),
}
def _fingerprint(value):
value = re.sub(r'[:\s]', '', value or '').lower()
if not re.fullmatch('[0-9a-f]{64}', value):
raise SourceError('fingerprint must be a SHA-256 certificate fingerprint (64 hex digits)')
return value
def _url(url, fingerprint=None):
if url.startswith('fdroidrepos://'):
url = 'https://' + url[len('fdroidrepos://'):]
p = urllib.parse.urlsplit(url)
if p.scheme != 'https' or not p.hostname or p.username or p.password or p.fragment:
raise SourceError('repository URL must use HTTPS without credentials or a fragment')
params = urllib.parse.parse_qs(p.query)
pins = params.pop('fingerprint', [])
if params or len(pins) > 1:
raise SourceError('only one fingerprint query parameter is supported')
pin = _fingerprint(fingerprint) if fingerprint else None
if pins:
linked = _fingerprint(pins[0])
if pin and pin != linked:
raise SourceError('conflicting fingerprints')
pin = linked
return urllib.parse.urlunsplit(('https', p.netloc.lower(), p.path.rstrip('/') + '/', '', '')), pin
def _child(base, name):
name = str(name).lstrip('/')
decoded = urllib.parse.unquote(name)
if not name or '\\' in decoded or any(x in ('.', '..') for x in decoded.split('/')):
raise SourceError('unsafe repository file name')
if '?' in decoded or urllib.parse.urlsplit(decoded).scheme:
raise SourceError('repository file is outside its repository')
url = urllib.parse.urljoin(base, urllib.parse.quote(decoded, safe='/')) # names may contain '#' or spaces
if not url.startswith(base) or urllib.parse.urlsplit(url).query or urllib.parse.urlsplit(url).fragment:
raise SourceError('repository file is outside its repository')
return url
class _HTTPSRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
if urllib.parse.urlsplit(newurl).scheme != 'https':
raise SourceError('refusing non-HTTPS redirect')
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _fetch(url, path, maximum):
host = urllib.parse.urlsplit(url).hostname
wait = _web.wait_time(url)
if wait:
raise _web.limited_error(host, wait)
request = urllib.request.Request(url, headers={'User-Agent': 'FrameControl/1.0'})
try:
with urllib.request.build_opener(_HTTPSRedirect()).open(request, timeout=60) as r, open(path, 'wb') as f:
total = 0
while True:
chunk = r.read(1 << 20)
if not chunk:
break
total += len(chunk)
if total > maximum:
raise SourceError('repository file exceeds size limit')
f.write(chunk)
except urllib.error.HTTPError as e:
if e.code in (403, 429):
raise _web.limited_error(host, _web.throttle(url, e.headers)) from e
raise
def _limited(source, error):
return _web.limited_error(source['name'], error.retry_after or _web.BACKOFF)
def _children(item):
return _der_parts(item[1])
def _cms(data, content, strong=False):
outer = _der_parts(data)
if len(outer) != 1:
raise ValueError('invalid CMS wrapper')
wrapper = _children(outer[0])
if wrapper[0][1].hex() != '2a864886f70d010702':
raise ValueError('not CMS SignedData')
fields = _children(_children(wrapper[1])[0])
certs = next(_children(f) for f in fields[3:] if f[0] == 0xa0)
signers = _children(fields[-1])
if len(signers) != 1:
raise ValueError('exactly one repository signer required')
signer = _children(signers[0])
sid = _children(signer[1])
matching = []
for cert in certs:
tbs = _children(_children(cert)[0])
offset = 1 if tbs[0][0] == 0xa0 else 0
if tbs[offset][1] == sid[1][1] and tbs[offset + 2][2] == sid[0][2]:
matching.append(cert[2])
if len(matching) != 1:
raise ValueError('missing or ambiguous signer certificate')
cert = matching[0]
digest, prefix = _DIGESTS[_children(signer[2])[0][1].hex()]
if strong and digest == 'sha1':
raise ValueError('SHA-1 signatures are not accepted for v2 indexes')
at, signed = 3, content
if signer[at][0] == 0xa0:
attrs = {}
for attr in _children(signer[at]):
pair = _children(attr)
oid = pair[0][1].hex()
if oid in attrs:
raise ValueError('duplicate CMS attribute')
attrs[oid] = _children(pair[1])
if attrs['2a864886f70d010904'][0][1] != hashlib.new(digest, content).digest():
raise ValueError('CMS content digest mismatch')
if attrs['2a864886f70d010903'][0][1].hex() != '2a864886f70d010701':
raise ValueError('unexpected CMS content type')
signed = der(0x31, signer[at][1])
at += 1
algorithm = _children(signer[at])[0][1].hex()
allowed = {'sha1': '2a864886f70d010105', 'sha256': '2a864886f70d01010b',
'sha384': '2a864886f70d01010c', 'sha512': '2a864886f70d01010d'}
if algorithm not in ('2a864886f70d010101', allowed[digest]):
raise ValueError('unsupported repository signature algorithm (RSA PKCS#1 required)')
n, e, _ = _cert_key(cert)
sig = signer[at + 1][1]
size = (n.bit_length() + 7) // 8
if not 256 <= size <= 1024 or n % 2 != 1 or not 3 <= e <= 0xffffffff or e % 2 != 1 or len(sig) != size or int.from_bytes(sig, 'big') >= n:
raise ValueError('invalid RSA signature/key size')
value = bytes.fromhex(prefix) + hashlib.new(digest, signed).digest()
expected = b'\0\1' + b'\xff' * (size - len(value) - 3) + b'\0' + value
if pow(int.from_bytes(sig, 'big'), e, n).to_bytes(size, 'big') != expected:
raise ValueError('repository RSA signature mismatch')
return hashlib.sha256(cert).hexdigest()
def _sections(data):
sections = []
for block in re.split(b'\r?\n\r?\n', data):
if not block:
continue
attrs = {}
for line in re.sub(b'\r?\n ', b'', block).splitlines():
key, value = line.decode('utf-8').split(': ', 1)
key = key.lower()
if key in attrs:
raise ValueError('duplicate manifest attribute')
attrs[key] = value
sections.append(attrs)
return sections
def _digest_check(attrs, suffix, content, strong=False):
for label, digest in (('sha-512', 'sha512'), ('sha-384', 'sha384'), ('sha-256', 'sha256'), ('sha1', 'sha1'), ('sha-1', 'sha1')):
if label + suffix in attrs and not (strong and digest == 'sha1'):
if base64.b64decode(attrs[label + suffix], validate=True) != hashlib.new(digest, content).digest():
raise ValueError('JAR digest mismatch')
return
raise ValueError('missing supported JAR digest')
def _jar(path, member, pin, strong=False):
"""strong: SHA-2 only (v2 entry.jar); index-v1.jar may still be SHA-1 signed."""
try:
with zipfile.ZipFile(path) as z:
names = z.namelist()
if len(names) > 64 or len(names) != len(set(names)) or any(
i.file_size > (1024 * 1024 if i.filename.upper().startswith('META-INF/') else 256 * 1024 * 1024)
for i in z.infolist()):
raise ValueError('duplicate or oversized JAR member')
blocks = [n for n in names if n.upper().startswith('META-INF/') and n.upper().endswith('.RSA')]
if len(blocks) != 1:
raise ValueError('exactly one RSA JAR signer required')
sf = z.read(blocks[0][:-4] + '.SF')
fingerprint = _cms(z.read(blocks[0]), sf, strong)
if pin and fingerprint != pin:
raise ValueError('repository fingerprint mismatch')
manifest = z.read('META-INF/MANIFEST.MF')
_digest_check(_sections(sf)[0], '-digest-manifest', manifest, strong)
entries = [s for s in _sections(manifest)[1:] if s.get('name') == member]
if len(entries) != 1:
raise ValueError('index is not uniquely signed')
content = z.read(member)
_digest_check(entries[0], '-digest', content, strong)
return content, fingerprint
except (ValueError, KeyError, IndexError, StopIteration, RuntimeError, NotImplementedError, zipfile.BadZipFile) as e:
raise SourceError('invalid signed repository: ' + str(e)) from e
def _storage():
return frame_host.data_dir('apk-repos.json')
def _read():
try:
settings = json.loads(_storage().read_text())
if not isinstance(settings, dict) or not isinstance(settings.get('repos'), list) or not isinstance(settings.get('enabled'), dict):
raise ValueError('invalid settings structure')
return settings
except FileNotFoundError:
return {'repos': [], 'enabled': {}}
except (OSError, ValueError) as e:
raise SourceError('cannot read repository settings: ' + str(e)) from e
def _write(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
try:
with os.fdopen(fd, 'w') as f:
json.dump(value, f, separators=(',', ':'))
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def _state_path():
return frame_host.data_dir('apk-repo-state.json')
def _states():
try:
states = json.loads(_state_path().read_text())
if not isinstance(states, dict):
raise ValueError('invalid state structure')
return states
except FileNotFoundError:
return {}
except (OSError, ValueError) as e:
raise SourceError('cannot read repository state: ' + str(e)) from e
def _state(source):
"""Newest accepted index timestamp and whether a v2 index was ever accepted (rollback protection)."""
with _LOCK:
state = _states().get(source['id'])
return state if isinstance(state, dict) and state.get('url') == source['url'] else {}
@contextlib.contextmanager
def _state_file_lock():
"""Inter-process lock: the CLI and the app must not publish indexes out of order."""
path = frame_host.data_dir('apk-repo-state.lock')
path.parent.mkdir(parents=True, exist_ok=True)
with open(str(path), 'a+b') as f:
if os.name == 'nt':
import msvcrt
while True:
try:
f.seek(0)
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
break
except OSError as e: # LK_LOCK gives up after ~10 s of contention; keep waiting
if e.errno not in (errno.EACCES, errno.EDEADLK):
raise
try:
yield
finally:
f.seek(0)
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
def _check_timestamp(source, timestamp):
last = _state(source).get('timestamp')
if last is not None and (type(timestamp) is not int or timestamp < last):
raise SourceError('repository index is older than the one already accepted (possible rollback); refused')
def _accept(source, timestamp, v2):
with _LOCK:
states = _states()
state = _state(source)
states[source['id']] = {'url': source['url'], 'v2': bool(v2 or state.get('v2')),
'timestamp': timestamp if type(timestamp) is int else state.get('timestamp')}
_write(_state_path(), states)
def user_repos():
with _LOCK:
return _read()['repos']
def sources():
builtins = [('fdroid', 'F-Droid', 'https://f-droid.org/repo/', FDROID_PIN),
('fdroid-archive', 'F-Droid archive', 'https://f-droid.org/archive/', FDROID_PIN),
('izzyondroid', 'IzzyOnDroid', 'https://apt.izzysoft.de/fdroid/repo/', IZZY_PIN)]
settings = _read()
return [dict(id=i, kind=KIND, name=n, url=u, fingerprint=p, builtin=True,
# The archive only holds superseded versions; it clutters search unless asked for.
enabled=settings['enabled'].get(i, i != 'fdroid-archive'), trust='community')
for i, n, u, p in builtins] + settings['repos']
def _text(value):
if isinstance(value, dict):
return value.get('en-US') or next((v for v in value.values() if v), '')
return value or ''
class _PlainText(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.parts, self.hidden = [], 0
def handle_starttag(self, tag, attrs):
if tag in ('script', 'style'):
self.hidden += 1
elif tag in ('br', 'p', 'div', 'li'):
self.parts.append(' ')
def handle_endtag(self, tag):
if tag in ('script', 'style'):
self.hidden = max(0, self.hidden - 1)
elif tag in ('p', 'div', 'li'):
self.parts.append(' ')
def handle_data(self, data):
if not self.hidden:
self.parts.append(data)
def _summary(value):
parser = _PlainText()
parser.feed(_text(value))
parser.close()
return ' '.join(''.join(parser.parts).split())
def _images(meta, base):
def url(file):
name = file.get('name') if isinstance(file, dict) else file
try:
return _child(base, name) if isinstance(name, str) and name else None
except SourceError:
return None # one odd image name mustn't hide the app
icon = url(_text(meta.get('icon')))
banner = url(_text(meta.get('featureGraphic')))
screenshots = []
groups = meta.get('screenshots') or {}
for device, legacy in (('phone', 'phoneScreenshots'), ('sevenInch', 'sevenInchScreenshots')):
files = _text(groups.get(device)) or _text(meta.get(legacy)) or []
for file in files if isinstance(files, list) else []:
image = url(file)
if image and image not in screenshots:
screenshots.append(image)
if len(screenshots) == 6:
break
if len(screenshots) == 6:
break
return {'icon': icon, 'banner': banner, 'screenshots': screenshots}
def _reduce(path, source):
compatible = _reduce_index(path)
result = {}
with open(path, encoding='utf-8') as f:
reader = _IndexReader(f)
for key in reader.members():
if key != 'packages':
reader.value()
continue
for pkg in reader.members():
item = reader.value()
if pkg not in compatible:
continue
meta = item.get('metadata', {})
files = {v['file'].get('name'): v for v in item.get('versions', {}).values()
if isinstance(v, dict) and isinstance(v.get('file'), dict)}
versions = []
for v in compatible[pkg]:
original = files[v['name']]
versions.append(dict(v, size=original['file'].get('size'), updated=_date(original.get('added'))))
versions.sort(key=lambda v: (v['version_code'], v['abis'] == ['arm64-v8a']), reverse=True)
latest = versions[0]
images = _images(meta, source['url'])
result[pkg] = dict(source=source['id'], id=pkg, package=pkg,
name=_text(meta.get('name')) or pkg, summary=_summary(meta.get('summary')),
icon=images['icon'], images=images, developer=_text(meta.get('authorName')) or None,
page=meta.get('webSite') or source['url'], vr=None, free=True,
license=meta.get('license'), downloadable=bool(latest.get('sha256')),
versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')})
return result
def _date(value):
return time.strftime('%Y-%m-%d', time.gmtime(value / 1000)) if isinstance(value, (int, float)) else None
def _v1(content, path):
index = json.loads(content)
apps = {a['packageName']: a for a in index['apps']}
packages = {}
for pkg, builds in index['packages'].items():
app = apps.get(pkg, {})
localized = app.get('localized', {})
meta = {k: _text({locale: fields[k] for locale, fields in localized.items() if fields.get(k)}) or app.get(k)
for k in ('name', 'summary', 'license', 'webSite', 'authorName')}
for field in ('icon', 'featureGraphic', 'phoneScreenshots', 'sevenInchScreenshots'):
images = {}
for locale, fields in localized.items():
value = fields.get(field)
if not value:
continue
prefix = pkg + '/' + locale + '/'
if field.endswith('Screenshots'):
images[locale] = [{'name': prefix + field + '/' + name} for name in value[:6]]
else:
images[locale] = {'name': prefix + value}
meta[field] = images
if not meta['icon'] and app.get('icon'):
meta['icon'] = {'en-US': {'name': 'icons/' + app['icon']}}
versions = {}
for i, v in enumerate(builds):
versions[str(i)] = {'manifest': {'versionName': v.get('versionName'), 'versionCode': v['versionCode'],
'usesSdk': {'minSdkVersion': v.get('minSdkVersion', 1)}, 'nativecode': v.get('nativecode', [])},
'file': {'name': v['apkName'], 'sha256': v.get('hash') if v.get('hashType') == 'sha256' else None,
'size': v.get('size')}, 'added': v.get('added')}
packages[pkg] = {'metadata': meta, 'versions': versions}
path.write_text(json.dumps({'packages': packages}))
return (index.get('repo') or {}).get('timestamp')
def _cached(source, cache):
"""(apps, pin, expired) from a cache matching this source, or None."""
try:
saved = json.loads(cache.read_text())
if (saved.get('version') == CACHE_VERSION and saved.get('fingerprint') == source.get('fingerprint')
and saved.get('url') == source['url']):
return saved['apps'], saved['fingerprint'], time.time() - cache.stat().st_mtime >= MAX_AGE
except (OSError, ValueError, KeyError, AttributeError):
pass
return None
def stale(source):
"""True while results come from an expired index that is being (or failed to be) refreshed."""
return source['id'] in _stale
def _refresh_later(source):
with _LOCK:
if source['id'] in _refreshing or time.time() < _retry_at.get(source['id'], 0):
return
def run():
try:
_load(source, force=True)
except Exception:
with _LOCK:
_retry_at[source['id']] = time.time() + 600
finally:
with _LOCK:
_refreshing.pop(source['id'], None)
_refreshing[source['id']] = thread = threading.Thread(target=run, daemon=True)
thread.start()
def _source_lock(source_id):
with _LOCK:
return _load_locks.setdefault(source_id, threading.Lock())
def _load(source, force=False):
if not re.fullmatch(r'[a-z0-9-]+', source['id']):
raise SourceError('invalid source id')
_url(source['url'], source.get('fingerprint'))
cache = frame_host.cache_dir('apk-sources', source['id'] + '.json')
found = None if force else _cached(source, cache)
if found:
if found[2]: # expired: serve it now, marked stale, and refresh without blocking anyone
_stale.add(source['id'])
_refresh_later(source)
return found[:2]
with _source_lock(source['id']):
found = None if force else _cached(source, cache) # another caller may have just loaded it
if found and not found[2]:
return found[:2]
cache.parent.mkdir(parents=True, exist_ok=True)
try:
with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp:
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
v2 = True
try:
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024)
except urllib.error.HTTPError as e:
if e.code not in (404, 410):
raise
if _state(source).get('v2'):
raise SourceError('repository no longer serves its signed v2 index; '
'refusing to fall back to the older v1 index') from e
v2 = False
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024)
content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint'))
timestamp = _v1(content, raw)
_check_timestamp(source, timestamp)
else:
content, pin = _jar(jar, 'entry.json', source.get('fingerprint'), strong=True)
signed = json.loads(content)
timestamp, entry = signed.get('timestamp'), signed['index']
_check_timestamp(source, timestamp)
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024)
if _sha256(raw) != entry['sha256'] or (entry.get('size') is not None and raw.stat().st_size != entry['size']):
raise SourceError('index SHA-256 or size mismatch')
apps = _reduce(raw, source)
with _state_file_lock(): # recheck: another process may have accepted a newer index meanwhile
_check_timestamp(source, timestamp)
if not v2 and _state(source).get('v2'): # a v2 index was accepted while we fetched v1
raise SourceError('repository now has a signed v2 index; refusing the older v1 index')
_write(cache, {'version': CACHE_VERSION, 'url': source['url'], 'fingerprint': pin, 'apps': apps})
_accept(source, timestamp, v2)
_stale.discard(source['id'])
return apps, pin
except SourceLimited as e:
raise _limited(source, e) from e
except SourceError:
raise
except (OSError, ValueError, KeyError, TypeError, IndexError) as e:
raise SourceError('cannot load repository: ' + str(e)) from e
def add_repo(url, fingerprint=None, name=None):
url, pin = _url(url, fingerprint)
with _LOCK:
existing = next((s for s in _read()['repos'] if s['url'] == url), None)
if existing:
if pin and pin != existing['fingerprint']:
raise SourceError('repository already has a different pinned fingerprint; remove it first')
pin = existing['fingerprint']
source = dict(id='fdroid-user-' + hashlib.sha256(url.encode()).hexdigest()[:20], kind=KIND,
name=name or (existing or {}).get('name') or urllib.parse.urlsplit(url).hostname,
url=url, builtin=False, enabled=True, trust='user', fingerprint=pin)
_, source['fingerprint'] = _load(source, force=True) # network work outside the settings lock
source['trust_on_first_use'] = existing.get('trust_on_first_use', False) if existing else pin is None
with _LOCK:
settings = _read()
current = next((s for s in settings['repos'] if s['id'] == source['id']), None)
if current and current['fingerprint'] != source['fingerprint']:
raise SourceError('repository already has a different pinned fingerprint; remove it first')
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
_write(_storage(), settings)
return source
def remove_repo(source_id):
with _LOCK:
settings = _read()
if not any(s['id'] == source_id for s in settings['repos']):
raise SourceError('unknown user repository')
settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id]
_write(_storage(), settings)
with _state_file_lock(), _LOCK: # lock order: state file, then _LOCK
states = _states()
if states.pop(source_id, None) is not None: # re-adding is a deliberate new trust decision
_write(_state_path(), states)
def set_enabled(source_id, enabled):
if not isinstance(enabled, bool):
raise SourceError('enabled must be a boolean')
with _LOCK:
settings = _read()
source = next((s for s in sources() if s['id'] == source_id), None)
if not source:
raise SourceError('unknown repository')
if source['builtin']:
settings['enabled'][source_id] = enabled
else:
for s in settings['repos']:
if s['id'] == source_id:
s['enabled'] = enabled
_write(_storage(), settings)
def search(source, query, limit=50):
if not source.get('enabled', True):
return []
apps, _ = _load(source)
words = query.casefold().split()
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)]
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
return [{k: v for k, v in a.items() if k != 'versions'} for a in found[:max(0, limit)]]
def details(source, entry_id):
if not source.get('enabled', True):
raise SourceError('repository is disabled')
apps, _ = _load(source)
if entry_id not in apps:
raise SourceError('app has no Lepton-compatible version in this repository')
return apps[entry_id]
def download(source, entry_id, version_code=None):
entry = details(source, entry_id)
version = next((v for v in entry['versions'] if version_code is None or str(v['version_code']) == str(version_code)), None)
if not version or not re.fullmatch('[0-9a-f]{64}', version.get('sha256') or ''):
raise SourceError('version is missing or has no SHA-256 digest')
sha = version['sha256']
path = frame_host.cache_dir('apk-sources', sha + '.apk')
try:
reuse = False
if _web.touch(path): # before hashing: a just-used APK is never pruned
try:
reuse = _sha256(path) == sha
except FileNotFoundError: # removed anyway (e.g. by hand): download again
pass
if not reuse:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
os.close(fd)
try:
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3)
if _sha256(tmp) != sha:
raise SourceError('APK SHA-256 mismatch; download discarded')
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
return {'apk': str(path), 'obb': [], 'sha256': sha, 'verified': True}
except SourceLimited as e:
raise _limited(source, e) from e
except OSError as e:
raise SourceError('cannot download APK: ' + str(e)) from e
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='command', required=True)
add = sub.add_parser('add')
add.add_argument('url')
add.add_argument('--fingerprint')
add.add_argument('--name')
sub.add_parser('list')
remove = sub.add_parser('remove')
remove.add_argument('source')
for command in ('search', 'download'):
p = sub.add_parser(command)
p.add_argument('source')
p.add_argument('query' if command == 'search' else 'package')
args = parser.parse_args()
try:
_cli(parser, args)
finally:
for thread in list(_refreshing.values()): # daemon refreshes would die with the CLI, even on errors
thread.join()
def _cli(parser, args):
try:
if args.command == 'add':
result = add_repo(args.url, args.fingerprint, args.name)
elif args.command == 'list':
result = sources()
elif args.command == 'remove':
result = remove_repo(args.source)
else:
source = next((s for s in sources() if s['id'] == args.source), None)
if not source:
raise SourceError('unknown repository id; use list')
result = search(source, args.query) if args.command == 'search' else download(source, args.package)
print(json.dumps(result, indent=2))
except SourceError as e:
parser.exit(1, 'error: ' + str(e) + '\n')
if __name__ == '__main__':
main()
+117
View File
@@ -0,0 +1,117 @@
"""Curated publisher releases; optional topic discovery is page-only."""
import fnmatch, json, os, re, urllib.parse
from . import SourceError, _web
KIND = 'github'
API = 'https://api.github.com'
TOPICS = ('oculus-quest', 'openxr', 'quest')
HOSTS = ('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com')
def sources():
return [{'id': KIND, 'kind': KIND, 'name': 'GitHub releases', 'url': 'https://github.com',
'builtin': True, 'enabled': True, 'trust': 'community'}]
def _curated():
with open(os.path.join(os.path.dirname(__file__), 'github_curated.json')) as f:
return json.load(f)
def _api(path):
headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}
token = os.environ.get('FRAME_GITHUB_TOKEN')
if token:
headers['Authorization'] = 'Bearer ' + token
try:
return json.loads(_web.read(API + path, ('api.github.com',), headers, name='GitHub',
hint='' if token else ' (set FRAME_GITHUB_TOKEN to raise the limit)'))
except (ValueError, TypeError) as e:
raise SourceError('Invalid GitHub response') from e
def _entry(source, c, approved=True):
artwork = c.get('images') or {}
icon = artwork.get('icon') or c.get('icon') or 'https://github.com/' + c['repo'].split('/')[0] + '.png'
images = {'icon': icon,
# No GitHub social-preview card: it's repo text and stats, not art; the UI draws a fallback.
'banner': artwork.get('banner'),
'screenshots': list(artwork.get('screenshots') or [])}
return {'source': source['id'], 'id': c['repo'], 'package': None,
'name': c['name'], 'summary': c.get('summary') or '', 'icon': icon,
'images': images,
'page': 'https://github.com/' + c['repo'], 'version': None, 'version_code': None,
'min_sdk': None, 'abis': None, 'vr': c.get('vr'), 'size': None,
'free': True if approved else None, 'license': c.get('license'), 'updated': None,
'downloadable': approved}
def search(source, query, limit=50):
limit = max(0, min(int(limit), 100))
if not limit:
return []
if query.startswith('topic:'):
topic = query[6:].strip()
if topic not in TOPICS:
raise SourceError('Choose topic:oculus-quest, topic:openxr or topic:quest')
data = _api('/search/repositories?' + urllib.parse.urlencode(
{'q': 'topic:' + topic + ' archived:false', 'sort': 'stars', 'per_page': limit}))
curated = {c['repo'].lower(): c for c in _curated()}
out = []
for repo in data.get('items', []):
c = curated.get(repo['full_name'].lower())
entry = _entry(source, c or {'repo': repo['full_name'], 'name': repo['name'],
'summary': repo.get('description'), 'vr': True,
'icon': (repo.get('owner') or {}).get('avatar_url')}, bool(c))
out.append(entry)
return out
words = query.lower().split()
return [_entry(source, c) for c in _curated()
if all(w in (c['name'] + ' ' + c['repo'] + ' ' + c['summary']).lower()
for w in words)][:limit]
def details(source, entry_id):
c = next((c for c in _curated() if c['repo'].lower() == entry_id.lower()), None)
if not c:
if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', entry_id):
raise SourceError('Invalid GitHub repository')
entry = _entry(source, {'repo': entry_id, 'name': entry_id}, False)
return {**entry, 'versions': []}
entry = _entry(source, c)
releases = _api('/repos/' + c['repo'] + '/releases?per_page=10')
versions = []
for release in releases:
if release.get('draft') or (release.get('prerelease') and not c.get('allow_prerelease')):
continue
assets = [a for a in release.get('assets', []) if
fnmatch.fnmatch(a['name'].lower(), c['asset_pattern'].lower())]
for asset in assets:
digest = asset.get('digest') or ''
versions.append({'version': release['tag_name'], 'version_code': None,
'asset_id': asset['id'], 'name': asset['name'], 'min_sdk': None,
'prerelease': bool(release.get('prerelease')),
'size': asset.get('size'), 'updated': release.get('published_at'),
'url': asset['browser_download_url'],
'sha256': digest[7:] if re.fullmatch(r'sha256:[0-9a-f]{64}', digest) else None})
entry['versions'] = versions
entry['downloadable'] = bool(versions)
if versions:
entry.update({k: versions[0][k] for k in ('version', 'size', 'updated')})
return entry
def download(source, entry_id, version_code=None):
entry = details(source, entry_id)
versions = entry['versions']
if not versions:
raise SourceError('No approved APK release; open the publisher page')
# GitHub asset IDs and tags are not Android version codes.
if version_code is not None:
raise SourceError('GitHub does not publish Android version codes; download the latest release')
v = versions[0]
expected = 'https://github.com/' + entry['id'] + '/releases/download/'
if not v['url'].startswith(expected):
raise SourceError('APK URL does not belong to the curated publisher')
return _web.apk(v['url'], HOSTS, v['sha256'], name='GitHub')
+72
View File
@@ -0,0 +1,72 @@
[
{
"repo": "KhronosGroup/OpenXR-SDK-Source",
"name": "hello_xr",
"summary": "A simple VR scene for checking that OpenXR graphics and controllers work.",
"license": "Apache-2.0",
"vr": true,
"asset_pattern": "hello_xr-Vulkan-*.apk",
"allow_prerelease": false,
"images": {
"icon": "https://raw.githubusercontent.com/KhronosGroup/OpenXR-SDK-Source/3ed64d0f9bb680f24b80a085091e5c8fab38f7b7/src/tests/hello_xr/android_resources/vulkan/mipmap-xxxhdpi/ic_helloxr_launcher.png",
"banner": null,
"screenshots": []
},
"icon": "https://raw.githubusercontent.com/KhronosGroup/OpenXR-SDK-Source/3ed64d0f9bb680f24b80a085091e5c8fab38f7b7/src/tests/hello_xr/android_resources/vulkan/mipmap-xxxhdpi/ic_helloxr_launcher.png"
},
{
"repo": "icosa-foundation/open-brush",
"name": "Open Brush",
"summary": "Paint in the air and create colorful 3D artwork in VR.",
"license": "Apache-2.0",
"vr": true,
"asset_pattern": "*Quest*.apk",
"allow_prerelease": true,
"images": {
"icon": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/Assets/Resources/DefaultImages/OpenBrushLogo.png",
"banner": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/open-brush.png",
"screenshots": [
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_785ea37d63378146dfe0f0ffa3f1d5c155ca978f.1920x1080.jpg",
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_0a9c208e26a43cf34879c2ca361d4c8f18af8cba.1920x1080.jpg",
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_19b25b86ef55c0d8769a65135d60eaae8fa40553.1920x1080.jpg"
]
},
"icon": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/Assets/Resources/DefaultImages/OpenBrushLogo.png"
},
{
"repo": "SgtBilko76/SuperTux-3D",
"name": "SuperTux 3D",
"summary": "Run and jump through Tux\u2019s platform adventure on a layered 3D screen in VR.",
"license": "GPL-3.0",
"vr": true,
"asset_pattern": "*Quest*.apk",
"allow_prerelease": true,
"images": {
"icon": "https://raw.githubusercontent.com/SgtBilko76/SuperTux-3D/1955493ee6f1000e048c58db40d4904df827210e/data/images/engine/icons/supertux-256x256.png",
"banner": "https://www.supertux.org/images/0_7_0/github_preview.png",
"screenshots": [
"https://www.supertux.org/images/0_7_0/github_preview.png"
]
},
"icon": "https://raw.githubusercontent.com/SgtBilko76/SuperTux-3D/1955493ee6f1000e048c58db40d4904df827210e/data/images/engine/icons/supertux-256x256.png"
},
{
"repo": "arpruss/OpenSaberPlus",
"name": "Open Saber Plus",
"summary": "Slash glowing blocks to the beat with two lightsabers, with songs from BeatSaver.",
"license": "MIT",
"vr": true,
"asset_pattern": "OpenSaberPlus.apk",
"allow_prerelease": false,
"images": {
"icon": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/icon.png",
"banner": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_1.gif",
"screenshots": [
"https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_1.gif",
"https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_2.gif",
"https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_3.gif"
]
},
"icon": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/icon.png"
}
]
+72
View File
@@ -0,0 +1,72 @@
"""Free Android VR RSS listings. Download pages stay in the publisher's UI."""
import html, re, urllib.parse, xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from . import SourceError, _web
KIND = 'itch'
FEEDS = ('openxr', 'oculus-quest')
def sources():
return [{'id': KIND if tag == 'openxr' else 'itch-quest', 'kind': KIND,
'name': 'itch.io (' + tag + ')', 'url': 'https://itch.io', 'tag': tag,
'builtin': True, 'enabled': True, 'trust': 'community'} for tag in FEEDS]
def _parse(source, data):
try:
root = ET.fromstring(data)
except ET.ParseError as e:
raise SourceError('Invalid itch.io RSS feed') from e
entries = []
for item in root.findall('./channel/item'):
page = item.findtext('link') or ''
p = urllib.parse.urlsplit(page)
if p.scheme != 'https' or not (p.hostname or '').endswith('.itch.io') or p.username or ':' in p.netloc:
continue
if item.findtext('price') != '$0.00' or item.findtext('platforms/android') != 'yes':
continue
cover = item.findtext('imageurl') or None
if cover and not cover.startswith('https://img.itch.zone/'):
cover = None
updated = None
try:
updated = parsedate_to_datetime(item.findtext('updateDate')).date().isoformat()
except (ValueError, TypeError, AttributeError):
pass
entries.append({'source': source['id'], 'id': page, 'package': None,
'name': item.findtext('plainTitle') or item.findtext('title') or page,
'summary': html.unescape(re.sub('<[^>]*>', '', item.findtext('description') or '')).strip(),
'icon': cover, 'images': {'icon': cover, 'banner': cover, 'screenshots': []},
'page': page, 'version': None, 'version_code': None, 'min_sdk': None,
'abis': None, 'vr': True, 'size': None, 'free': True, 'license': None,
'updated': updated, 'downloadable': False})
return entries
def search(source, query, limit=50):
limit = max(0, min(int(limit), 100))
if not limit:
return []
entries = {}
tag = source.get('tag', 'openxr')
if tag not in FEEDS:
raise SourceError('Unsupported itch.io feed')
url = 'https://itch.io/games/free/platform-android/tag-' + tag + '.xml'
for entry in _parse(source, _web.read(url, ('itch.io',), name='itch.io')):
entries.setdefault(entry['id'], entry)
words = query.lower().split()
return [e for e in entries.values() if all(w in (e['name'] + ' ' + e['summary']).lower()
for w in words)][:limit]
def details(source, entry_id):
entry = next((e for e in search(source, '', 100) if e['id'] == entry_id), None)
if not entry:
raise SourceError('Game is not in the current free Android VR feed; open its publisher page')
return {**entry, 'versions': []}
def download(source, entry_id, version_code=None):
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')
+349
View File
@@ -0,0 +1,349 @@
"""Parallel APK search and source selection. No device access during searches.
Optional store metadata: images {icon, banner, screenshots}, developer,
description, popularity, open_source, requires_meta_services, frame_tested.
Only an explicit frame_tested=True produces a working-on-Frame verdict.
"""
import importlib
import inspect
import json
import os
import pkgutil
import re
import threading
import time
import unicodedata
import frame_host
from apk_sources import SourceError, SourceLimited
_lock = threading.RLock()
_running = {}
_pending = {} # source id -> newest query waiting for the running one
_game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
_status = {}
TIMEOUT = 12
def modules():
import apk_sources
found, errors = [], []
for item in pkgutil.iter_modules(apk_sources.__path__):
if item.name == 'search' or item.name.startswith('_'):
continue
try:
module = importlib.import_module('apk_sources.' + item.name)
if getattr(module, 'KIND', None):
found.append(module)
except Exception as e:
errors.append({'id': item.name, 'name': item.name, 'enabled': False,
'trust': 'unknown', 'status': 'error', 'error': str(e)})
if os.environ.get('FRAME_APK_SEARCH_DEMO') == '1':
found.append(importlib.import_module('apk_sources._demo'))
return found, errors
def settings_path():
return frame_host.data_dir('apk-sources', 'enabled.json')
def overrides():
try:
return json.loads(settings_path().read_text())
except FileNotFoundError:
return {}
def registry():
result = []
mods, errors = modules()
with _lock:
enabled = overrides()
for module in mods:
try:
for source in module.sources():
source = dict(source)
source['enabled'] = enabled.get(source['id'], source.get('enabled', True))
source.update(_status.get(source['id'], {'status': 'not searched'}))
result.append((module, source))
except Exception as e:
errors.append({'id': module.KIND, 'name': module.KIND, 'enabled': False,
'trust': 'unknown', 'status': 'error', 'error': str(e)})
return result, errors
def sources():
items, errors = registry()
return [s for _, s in items] + errors
def resolve(source_id):
for module, source in registry()[0]:
if source['id'] == source_id:
return module, source
raise SourceError('Unknown source')
def set_enabled(source_id, enabled):
module, source = resolve(source_id)
if hasattr(module, 'set_enabled'):
module.set_enabled(source_id, enabled) # never call into a source while holding _lock
with _lock:
values = overrides()
values[source_id] = enabled
path = settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix('.tmp')
tmp.write_text(json.dumps(values))
tmp.replace(path)
return {'message': source['name'] + (' enabled' if enabled else ' disabled')}
def repo_module():
module = next((m for m in modules()[0] if m.KIND == 'fdroid'), None)
if module is None or not hasattr(module, 'add_repo'):
raise SourceError('User repositories are not available in this build')
return module
def manage_repo(action, **kwargs):
module = repo_module()
if not hasattr(module, action):
raise SourceError('User repositories are not available in this build')
return getattr(module, action)(**kwargs)
def normalise(name):
return ' '.join(re.findall(r'\w+', unicodedata.normalize('NFKC', name or '').casefold()))
def fit(entry):
sdk, abis = entry.get('min_sdk'), entry.get('abis')
reasons = []
blocked = (sdk is not None and sdk > 30) or (abis is not None and bool(abis) and 'arm64-v8a' not in abis)
if sdk is not None and sdk > 30:
reasons.append('Needs Android API %s; Lepton supports 30' % sdk)
if abis and 'arm64-v8a' not in abis:
reasons.append('No arm64-v8a build')
known = sdk is not None and abis is not None
hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower()
if 'vrapi' in hints:
reasons.append('Legacy VrApi requires a translator')
if 'godot' in hints:
reasons.append('Older Godot builds can crash on the missing clipboard service')
if 'openxr' in hints:
reasons.append('OpenXR candidate; required extensions still need checking')
if entry.get('vr'):
reasons.append('VR runtime compatibility is not guaranteed')
return {'installable': False if blocked else True if known else None,
'verdict': "Won't install" if blocked else 'Installable' if known else 'Compatibility unknown',
'reasons': reasons}
def verdict(entry):
compatibility = fit(entry)
hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower()
if entry.get('requires_meta_services') is True:
return {'label': "Needs Meta Quest services; won't run", 'tone': 'blocked'}
if entry.get('min_sdk') is not None and entry['min_sdk'] > 30:
return {'label': 'Might not work: needs a newer Android than the Frame has', 'tone': 'blocked'}
if compatibility['installable'] is False:
return {'label': "This version isn't made for the Frame", 'tone': 'blocked'}
if 'vrapi' in hints:
return {'label': "Made for older Quest headsets; won't run on the Frame", 'tone': 'blocked'}
if entry.get('frame_tested') is True:
return {'label': 'Works on the Frame', 'tone': 'works'}
if compatibility['installable'] is True:
return {'label': 'Ready to try on the Frame', 'tone': 'ready'}
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
def decorate(entry):
from apk_sources import _images
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry))
def details(source_id, entry_id):
module, source = resolve(source_id)
return decorate(dict(module.details(source, entry_id), source=source_id,
source_name=source['name'], trust=source.get('trust')))
def offer_rank(entry):
compatible = entry['fit']['installable'] is True
return (entry.get('downloadable') is True and entry['fit']['installable'] is not False,
entry.get('verified') is True, compatible,
str(entry.get('updated') or '') if compatible else '',
(entry.get('version_code') or 0) if compatible else 0,
entry.get('trust') == 'official')
def group(entries, query='', vr=None, installable=False):
groups = {}
for entry in entries:
entry = decorate(entry)
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
continue
if installable and entry['fit']['installable'] is not True:
continue
key = ('package', entry['package']) if entry.get('package') else ('name', normalise(entry.get('name')))
if not key[1]:
key = ('id', entry['source'], entry['id'])
groups.setdefault(key, []).append(entry)
result = []
for offers in groups.values():
offers.sort(key=offer_rank, reverse=True)
best = offers[0]
result.append({'name': best.get('name'), 'package': best.get('package'),
'summary': best.get('summary'), 'offers': offers})
q = normalise(query)
def browse(a): # a headset store: VR first, then apps with artwork, newest first
o = a['offers'][0]
art = o.get('images') or {}
return (o.get('vr') is not True, not art.get('banner'), not art.get('screenshots'),
''.join(chr(0x10ffff - ord(c)) for c in str(o.get('updated') or '')))
if not q:
# Unknown fit stays in the VR-first order; only apps known not to install sink.
result.sort(key=lambda a: (all(o['fit']['installable'] is False for o in a['offers']),) + browse(a))
return result
result.sort(key=lambda a: (not any(normalise(o.get('name')) == q for o in a['offers']),
not any(o['fit']['installable'] is True for o in a['offers']),
not any(normalise(o.get('name')).startswith(q) for o in a['offers']),
a['offers'][0].get('vr') is not True,
normalise(a['name'])))
return result
def _launch(module, source, query, limit):
"""One search per source at a time; the newest different query runs next."""
key = source['id']
task = {'event': threading.Event(), 'query': (query, limit), 'started': time.monotonic(),
'job': (module, source)}
with _lock:
old = _running.get(key)
if old and not old['event'].is_set():
if old['query'] == task['query']:
return old
queued = _pending.get(key)
if queued and queued['query'] == task['query']:
return queued
_pending[key] = task
return task
_running[key] = task
_start(key, task)
return task
def _start(key, task):
module, source = task['job']
query, limit = task['query']
def run():
queued = None
try:
task['entries'] = [dict(e, source=key, source_name=source['name'], trust=source.get('trust'))
for e in module.search(source, query, limit=limit) if e.get('free') is True]
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
except Exception as e:
task['error'] = str(e)
task['limited'] = isinstance(e, SourceLimited)
finally:
with _lock: # completion and queue handover are one step for _launch
queued = _pending.pop(key, None)
if queued:
_running[key] = queued
task['event'].set()
if queued:
_start(key, queued)
threading.Thread(target=run, daemon=True).start()
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50):
items, errors = registry()
if source and source not in [s['id'] for _, s in items]:
raise SourceError('Unknown source')
chosen = [(m, s) for m, s in items if s['enabled'] and (not source or s['id'] == source)]
# Page-only sources (SideQuest) can't be searched; offer a link to browse them instead.
elsewhere = [{'name': s['name'], 'url': s['url']} for m, s in chosen if s.get('page_only')]
tasks = [(s, _launch(m, s, query, limit)) for m, s in chosen if not s.get('page_only')]
entries, statuses = [], list(errors)
for s, task in tasks:
status = {'id': s['id'], 'name': s['name']}
if not task['event'].wait(max(0, task['started'] + timeout - time.monotonic())):
# Still working (e.g. first download of a large index); it keeps going and fills the cache.
status.update(status='loading')
elif 'error' in task:
status.update(status='limited' if task.get('limited') else 'error', error=task['error'])
else:
status.update(status='ok', stale=task['stale'])
entries.extend(task['entries'])
statuses.append(status)
with _lock:
_status[s['id']] = {k: v for k, v in status.items() if k not in ('id', 'name')}
return {'apps': group(entries, query, vr, installable), 'sources': statuses, 'elsewhere': elsewhere}
def warm():
"""Start every enabled source's index download in the background (server start, new repo)."""
from apk_sources import _web
_web.prune()
items, _ = registry()
for m, s in items:
if s['enabled'] and not s.get('page_only'):
_launch(m, s, '', 50) # same as the first browse, so that search reuses it
def install(source_id, entry_id, version_code=None, progress=None):
import frame_android
module, source = resolve(source_id)
if not source['enabled']:
raise SourceError('This source is disabled')
entry = module.details(source, entry_id)
if entry.get('free') is not True or entry.get('downloadable') is not True:
raise SourceError('This app must be obtained from its developer page')
if progress:
progress('Downloading', None)
downloaded = module.download(source, entry_id, version_code=version_code)
obb = downloaded.get('obb') or entry.get('obb') or []
if obb and not hasattr(frame_android, 'install_obb'):
raise SourceError('This app needs OBB data; this build cannot install it yet')
kwargs = {'name': entry.get('name'), 'icon_png': downloaded.get('icon_png') or entry.get('icon_png'),
'source': source['name']}
if 'artwork' in inspect.signature(frame_android.install).parameters:
# The source's own image URLs (not the UI's /source-image/ proxy paths) become Steam library art.
images = entry.get('images') if isinstance(entry.get('images'), dict) else {}
art = {'icon': images.get('icon') or entry.get('icon'), 'banner': images.get('banner'),
'screenshots': [u for u in images.get('screenshots') or [] if u][:4]}
kwargs['artwork'] = downloaded.get('artwork') or {k: v for k, v in art.items() if v} or None
if progress:
progress('Installing', None)
from apk_sources import _web
try:
_web.claim(downloaded['apk']) # no cache pruning while it installs
except OSError as e:
raise SourceError('The downloaded APK disappeared before installing; try again') from e
try:
_web.prune() # the app is the only pruner (see _web.prune)
result = frame_android.install(downloaded['apk'], **kwargs)
finally:
_web.release(downloaded['apk'])
if obb:
# OBB files go into the app's own instance, which only exists while the app runs.
with _lock:
_game_data[result['package']] = list(obb)
result = dict(result, game_data=True, message='Installed ' + (entry.get('name') or result['package']) +
'. It also needs its game data: open it once on the Frame, then choose Add game data.')
return result
def add_game_data(package):
import frame_android
with _lock:
paths = _game_data.get(package)
if not paths:
raise SourceError('No downloaded game data is waiting for this app; install it again from the store')
result = frame_android.install_obb(package, paths)
with _lock:
_game_data.pop(package, None)
return dict(result, message='Game data added')
+39
View File
@@ -0,0 +1,39 @@
"""SideQuest page links only: its terms do not authorise third-party scraping.
No API calls, cached listings or automated downloads. See docs/sidequest.md.
"""
from . import SourceError
KIND = 'sidequest'
URL = 'https://sidequestvr.com'
REASON = ('SideQuest is page-only: its terms restrict scraping and unauthorised '
'access. Browse and download with SideQuest, then import a developer-provided APK.')
def sources():
return [{'id': KIND, 'kind': KIND, 'name': 'SideQuest', 'url': URL,
'builtin': True, 'enabled': True, 'trust': 'community',
'page_only': True, 'reason': REASON}]
def search(source, query, limit=50):
# Do not invent catalogue results or interpret a query as a verified free app.
return []
def details(source, entry_id):
entry_id = str(entry_id)
if not entry_id.isascii() or not entry_id.isdecimal() or len(entry_id) > 12:
raise SourceError('SideQuest listing ids must be numeric')
return {'source': source['id'], 'id': entry_id, 'package': None,
'name': 'SideQuest listing ' + entry_id, 'summary': REASON,
'icon': None, 'page': URL + '/app/' + entry_id, 'version': None,
'version_code': None, 'min_sdk': None, 'abis': None, 'vr': None,
'size': None, 'free': None, 'license': None, 'updated': None,
'downloadable': False, 'versions': [], 'tags': [], 'headsets': [],
'images': {'icon': None, 'banner': None, 'screenshots': []}}
def download(source, entry_id, version_code=None):
entry = details(source, entry_id)
raise SourceError(REASON + ' ' + entry['page'])
+30
View File
@@ -0,0 +1,30 @@
// Uses index.html's api(), which sends the X-Frame-UI key every /api call needs.
(() => {
const get=id=>document.getElementById(id), status=get('steamGridStatus');
function show(data) {
status.textContent=data.environment?'SteamGridDB key is set by an environment variable.':
data.steamgriddb_configured?'SteamGridDB key saved.':'No key configured. Source images and generated art are enabled.';
}
async function save(value) {
try {show(await api('/api/settings/artwork',{steamgriddb_api_key:value}));get('steamGridKey').value='';}
catch(e) {status.textContent=e.message;}
}
get('saveSteamGridKey').onclick=()=>save(get('steamGridKey').value.trim());
get('clearSteamGridKey').onclick=()=>save('');
get('refreshAndroidArt').onclick=async()=>{
const button=get('refreshAndroidArt');button.disabled=true;
try {
const result=await runJob('Refresh library artwork','library-artwork',()=>api('/api/android',{action:'refresh-art',all:true}));
if (result) {
const items=[...(result.apps||[]),...(result.titles||[])];
const failed=items.filter(a=>a.error);
status.textContent=failed.length?`${failed.length} of ${items.length} failed: ${failed.map(a=>(a.package||a.id)+': '+a.error).join('; ')}`:
`Refreshed artwork for ${items.length} apps and titles.`;
if (typeof loadAndroid==='function') loadAndroid();
if (typeof loadTitles==='function') loadTitles();
}
} catch(e) {status.textContent=e.message;}
finally {button.disabled=false;}
};
api('/api/settings/artwork').then(show).catch(e=>{status.textContent=e.message;});
})();
+198 -18
View File
@@ -8,11 +8,13 @@ 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 | versions APK-or-PKG
patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
refresh-art PKG|--all | 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
import base64, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
import frame_apk
import frame_artwork
import frame_host
import tempfile
import zipfile
@@ -56,8 +58,12 @@ def ssh(cmd, input=None, timeout=120):
def shortcut_tool(*args, timeout=60):
with open(SHORTCUTS) as f:
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
timeout=timeout).strip()
script = f.read()
if args and args[0] == 'render':
with open(os.path.join(ROOT, 'frame/android/library_artwork.js')) as f:
script = 'ART_RENDERER = ' + repr(f.read()) + '\n' + script
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=script,
timeout=timeout).strip()
def instance_id(pkg):
@@ -140,7 +146,7 @@ def _write_meta(d, meta):
install_hooks = []
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None):
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
start, info = time.time(), None
try:
info = apk_info(apk_path)
@@ -161,9 +167,9 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
patched = os.path.join(tmp, 'app.apk')
info['patched'] = patch(apk_path, patched, add)['patched']
info['launchable'] = True
meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path))
meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
else:
meta = _install(apk_path, info, pkg, flatscreen, name, source)
meta = _install(apk_path, info, pkg, flatscreen, name, source, artwork)
except FrameError as e:
_after_install(info, None, e, start)
raise
@@ -179,39 +185,53 @@ def _after_install(info, meta, error, start):
pass # reporting must never change an install's outcome
def _install(apk_path, info, pkg, flatscreen, name, source):
def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
try:
images, art_warnings = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork)
except (ValueError, OSError) as e:
raise FrameError(f'could not prepare artwork: {e}') from e
iid = instance_id(pkg)
d = f'{APPS_DIR}/{pkg}'
existing = read_meta(pkg)
ok = False
ok, created = False, None
try:
ssh(f'mkdir -p {d}')
_copy(apk_path, f'{d}/app.apk.part')
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
icon = ''
if info['icon_png']:
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
icon = f'$HOME/{d}/icon.png'
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
home = ssh('echo $HOME').strip()
shortcut = _int((existing or {}).get('shortcut'))
if not shortcut or shortcut not in _shortcut_ids():
reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}',
icon.replace('$HOME', home))
'')
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
if not shortcut:
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
created = shortcut
presentation = apply_library(shortcut, name or info['label'], d, images,
vr=not flatscreen, home=home,
exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}',
details={'package': pkg, 'version': info['version'],
'source': source or os.path.basename(apk_path)})
presentation['warnings'] = art_warnings + presentation.get('warnings', [])
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
'source': source or os.path.basename(apk_path)}
'source': source or os.path.basename(apk_path),
'library_warnings': presentation.get('warnings', []),
'artwork': presentation.get('artwork', {}), 'library_version': 2}
_write_meta(d, meta)
ok = True
return meta
finally:
if not ok and created:
try:
shortcut_tool('remove', str(created))
except FrameError:
pass
if not ok and not existing:
# A first install that failed part-way: don't leave an orphan folder behind.
try:
@@ -220,6 +240,121 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
pass
def apply_library(shortcut, label, directory, images, vr=None, home=None, exe='', start_dir='', details=None,
category='Android', fill_only=False):
"""Mandatory for every sideload: render all five slots before reporting success.
vr None leaves Steam's VR flag as it is (devkit titles declare their own). fill_only (automatic
backfill) sets only slots Steam has no art for, and never the name, exe or flags."""
home = home or ssh('echo $HOME').strip()
d = directory
ssh(f'mkdir -p {shlex.quote(d)}/artwork')
paths = {}
for slot, (ext, data) in images.items():
path = f'{d}/artwork/source-{slot}.{ext}'
ssh(f'cat > {shlex.quote(path)}', input=data)
paths[slot] = f'{home}/{path}' if not path.startswith('/') else path
manifest = {'label': label, 'images': paths}
plan = f'{d}/artwork/input.json'
ssh(f'cat > {shlex.quote(plan)}', input=json.dumps(manifest))
absolute = f'{home}/{plan}' if not plan.startswith('/') else plan
# The Frame retries once with generated art, each attempt allowed 75 s.
rendered = json.loads(shortcut_tool('render', absolute, timeout=200))
art = rendered['paths']
if set(art) != set(frame_artwork.SLOTS):
raise FrameError('Steam artwork renderer did not produce every slot')
result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'],
'' if vr is None else '1' if vr else '0', json.dumps(art),
json.dumps({'category': category, 'details': details or {},
'fill_only': fill_only}), timeout=120))
result['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
result['artwork'] = art
if category == 'Android':
ssh(f'cat > {shlex.quote(d)}/shortcut.id', input=str(int(shortcut)))
return result
def cached_art_script(d):
"""Frame-side Python that loads the source images kept beside d's last render into `cached`."""
return f"""import base64, json, os
cached = {{}}
directory = os.path.realpath({d!r})
try:
with open(os.path.join(directory, 'artwork/input.json')) as f:
plan = json.load(f)
for slot, path in plan.get('images', {{}}).items():
if os.path.commonpath([os.path.realpath(path), directory]) != directory:
continue
with open(path, 'rb') as f:
data = f.read(12 * 1024 * 1024 + 1)
if len(data) <= 12 * 1024 * 1024:
cached[slot] = base64.b64encode(data).decode()
except (OSError, ValueError, TypeError, AttributeError):
pass
"""
def art_missing(m):
"""True when a Frame Control install has no complete Steam artwork on record."""
return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS)
def refresh_art(pkg=None, artwork=None, fill_only=False):
"""Refresh existing APK library entries without reinstalling or stopping them.
fill_only: the automatic backfill; it only fills empty Steam slots (see apply_library)."""
if pkg is None:
results = []
for app in list_apps():
try:
results.append(refresh_art(app['package'], artwork, fill_only))
except Exception as e: # one app's failure must not stop the others
results.append({'package': app['package'], 'label': app.get('label'), 'error': str(e) or type(e).__name__})
return results
with _install_lock: # shared with install and remove: a removed app is never recreated
m = _meta_or_fail(pkg)
d = f'{APPS_DIR}/{pkg}'
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
modules = {}
for module in ('frame_apk', 'frame_apk_vr'):
with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
modules[module] = f.read()
script = 'import sys, types, json, base64\n'
for module, source in modules.items():
script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n"
script += cached_art_script(d) + "info['artwork'] = cached\nprint(json.dumps(info))\n"
info = json.loads(ssh('python3 -', input=script))
icon = base64.b64decode(info['icon_png'])
cached = {k: base64.b64decode(v) for k, v in info.get('artwork', {}).items()}
images, warnings = frame_artwork.prepare(m['label'], icon, artwork if artwork is not None else cached)
home = ssh('echo $HOME').strip()
created = False
if not m['shortcut'] or m['shortcut'] not in _shortcut_ids():
m['shortcut'] = _int(shortcut_tool('add', m['label'], f'{home}/{d}/launch.sh', f'{home}/{d}'))
if not m['shortcut']:
raise FrameError('Steam did not create the missing shortcut')
m['game_id'] = game_id(m['shortcut'])
created = True
try:
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m,
fill_only=fill_only and not created)
except Exception:
if created:
try:
shortcut_tool('remove', str(m['shortcut']))
except FrameError:
pass # keep the render error, not the cleanup's
raise
m.update(artwork=result.get('artwork', {}), library_version=2, art_pending=False)
m['library_warnings'] = warnings + result.get('warnings', [])
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
_write_meta(d, m)
return m
def _int(v):
try:
n = int(v)
@@ -268,6 +403,7 @@ def list_apps():
continue
if m:
m['running'] = f"lepton-steamlaunch-{m['instance']}" in running
m['art_missing'] = art_missing(m)
apps.append(m)
return sorted(apps, key=lambda m: m['label'].lower())
@@ -291,18 +427,30 @@ def launch(pkg):
def stop(pkg):
m = _meta_or_fail(pkg)
if m['shortcut']:
try:
shortcut_tool('stop', str(int(m['shortcut'])))
except FrameError:
pass # Steam unavailable: the container stop also ends its waiting launcher.
ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60)
return m
def remove(pkg, keep_data=False):
with _install_lock: # never while an install or artwork refresh of any app is writing
return _remove(pkg, keep_data)
def _remove(pkg, keep_data):
m = _meta_or_fail(pkg)
stop(pkg)
if m['shortcut']:
# Best effort: Steam may not be running, and the files must still go.
try:
shortcut_tool('remove', str(int(m['shortcut'])))
except FrameError:
pass # already gone from Steam
result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}')
m['library_warnings'] = result.get('warnings', [])
except (FrameError, ValueError, AttributeError) as e:
m['library_warnings'] = [f'Steam shortcut not removed: {e}']
iid = int(m['instance'])
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
@@ -354,6 +502,21 @@ def patch(src, dst, add=None):
raise FrameError(str(e)) from e
def install_obb(pkg, paths):
import frame_android_data
return frame_android_data.install_obb(pkg, paths)
def backup_data(pkg, destination):
import frame_android_data
return frame_android_data.backup_data(pkg, destination)
def restore_data(pkg, archive):
import frame_android_data
return frame_android_data.restore_data(pkg, archive)
def main():
cmd, *args = sys.argv[1:] or ['help']
try:
@@ -374,6 +537,13 @@ def main():
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 == 'refresh-art':
if len(args) != 1:
raise FrameError('usage: refresh-art PACKAGE or refresh-art --all')
r = refresh_art(None if args[0] == '--all' else args[0])
if isinstance(r, list) and any('error' in item for item in r):
print(json.dumps(r, indent=1))
raise SystemExit(1)
elif cmd == 'patch':
import argparse
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
@@ -389,8 +559,18 @@ def main():
with open(path, 'rb') as f:
additions[entry] = f.read()
r = patch(opts.src, opts.dst, additions)
elif cmd == 'install-obb':
if len(args) < 2:
raise FrameError('install-obb requires PACKAGE OBB [OBB ...]')
r = install_obb(args[0], args[1:])
elif cmd in ('backup-data', 'restore-data'):
if len(args) != 2:
raise FrameError(cmd + ' requires PACKAGE ARCHIVE.tar.gz')
r = (backup_data if cmd == 'backup-data' else restore_data)(*args)
elif cmd == 'list':
r = list_apps()
if any(a['art_missing'] for a in r):
print('Some apps have no Steam artwork: python3 ui/frame_android.py refresh-art --all', file=sys.stderr)
elif cmd in ('launch', 'stop', 'probe'):
r = globals()[cmd](args[0])
elif cmd == 'remove':
+133
View File
@@ -0,0 +1,133 @@
"""Expansion files and stopped-instance private-data backups. Python stdlib only."""
import hashlib
import json
import os
from pathlib import Path
import re
import runpy
import shlex
import subprocess
import tempfile
import uuid
import frame_android as android
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
def _stream(command, src=None, dst=None):
try:
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
stdin=src if src else subprocess.DEVNULL,
stdout=dst if dst else subprocess.PIPE,
stderr=subprocess.PIPE, timeout=1800)
except subprocess.TimeoutExpired:
raise android.FrameError('app-data transfer timed out')
except OSError as error:
raise android.FrameError('app-data transfer failed: ' + str(error))
if result.returncode:
raise android.FrameError(result.stderr.decode(errors='replace').strip()[-600:] or
'app-data transfer failed')
return result.stdout
def _sha256(path):
digest = hashlib.sha256()
with open(path, 'rb') as src:
for chunk in iter(lambda: src.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def _meta(package):
if not android.PKG_RE.fullmatch(package or ''):
raise android.FrameError('invalid package name')
meta = android._meta_or_fail(package)
if meta['package'] != package:
raise android.FrameError('installed app metadata has a different package')
return meta
def install_obb(package, paths):
if not android.PKG_RE.fullmatch(package or ''):
raise android.FrameError('invalid package name')
paths = [Path(p).resolve() for p in paths]
if not paths:
raise android.FrameError('select at least one OBB file')
names = set()
for path in paths:
if (not re.fullmatch(r'(main|patch)\.[0-9]+\.' + re.escape(package) + r'\.obb', path.name)
or not path.is_file() or path.stat().st_size == 0 or path.name in names):
raise android.FrameError('OBB must be a nonempty main/patch.<version>.' + package + '.obb file')
names.add(path.name)
with android._install_lock:
meta = _meta(package)
container = 'lepton-steamlaunch-' + str(int(meta['instance']))
# No implicit launch. Android resolves /sdcard, never an assumed host path.
running = android.ssh('podman ps --format "{{.Names}}"').splitlines()
if container not in running:
raise android.FrameError('start this app instance before installing OBB data')
dest = '/sdcard/Android/obb/' + package
results = []
for path in paths:
digest = _sha256(path)
part = dest + '/.frame-' + uuid.uuid4().hex + '.part'
target = dest + '/' + path.name
script = (f'set -eu; mkdir -p {dest}; umask 002; '
f'trap "rm -f {part}" EXIT; cat > {part}; '
f'test "$(sha256sum {part} | cut -d " " -f 1)" = {digest}; '
f'chmod 664 {part}; mv {part} {target}')
command = shlex.join(['podman', 'exec', '-i', container, '/system/bin/sh', '-c', script])
with path.open('rb') as src:
_stream(command, src=src)
results.append({'name': path.name, 'path': target, 'sha256': digest})
return {'package': package, 'instance': meta['instance'], 'obb': results,
'verified': True}
def _data_command(action, meta):
container = 'lepton-steamlaunch-' + str(int(meta['instance']))
# Fail closed if podman cannot enumerate containers; don't mistake errors for stopped.
guard = ('running=$(podman ps --format "{{.Names}}") || exit 1; '
f'if printf "%s\\n" "$running" | grep -Fxq {shlex.quote(container)}; then '
'echo "stop the app before backup or restore" >&2; exit 1; fi; ')
return guard + shlex.join(['podman', 'unshare', 'python3', '-c', REMOTE.read_text(),
action, meta['package'], str(int(meta['instance']))])
def backup_data(package, destination):
destination = Path(destination).expanduser().absolute()
if destination.exists():
raise android.FrameError('backup destination already exists')
with android._install_lock:
meta = _meta(package)
fd, temporary = tempfile.mkstemp(prefix='.frame-backup-', dir=str(destination.parent))
try:
with os.fdopen(fd, 'wb') as dst:
_stream(_data_command('backup', meta), dst=dst)
result = _inspect(temporary, meta)
# Exclusive publication: a concurrently created backup is never overwritten.
os.link(temporary, str(destination))
return dict(result, path=str(destination), sha256=_sha256(destination))
finally:
os.unlink(temporary)
def _inspect(path, meta):
import tarfile
try:
return runpy.run_path(str(REMOTE))['inspect_archive'](path, meta['package'], int(meta['instance']))
except (OSError, EOFError, ValueError, tarfile.TarError) as error:
raise android.FrameError('invalid app-data backup: ' + str(error))
def restore_data(package, archive):
with android._install_lock:
meta = _meta(package)
_inspect(archive, meta)
with open(archive, 'rb') as src:
result = _stream(_data_command('restore', meta), src=src)
try:
return json.loads(result)
except (ValueError, TypeError):
raise android.FrameError('could not read restore result; inspect app data before retrying')
+4 -1
View File
@@ -255,7 +255,10 @@ def _icon_png(z, names, icons):
# Adaptive icons are XML; fall back to the largest launcher PNG.
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
key=lambda n: z.getinfo(n).file_size)
return _read(z, pngs[-1], MAX_ICON) if pngs else None
if pngs:
return _read(z, pngs[-1], MAX_ICON)
# Godot exports can keep only an adaptive launcher plus the project icon.
return _read(z, 'assets/icon.png', MAX_ICON) if 'assets/icon.png' in names else None
if __name__ == '__main__':
+160
View File
@@ -0,0 +1,160 @@
"""Bounded artwork inputs for Steam library canvas rendering."""
import struct
import time
import zlib
SLOTS = {'grid': (600, 900), 'wide': (920, 430), 'hero': (3840, 1240),
'logo': (1280, 480), 'icon': (256, 256)}
MAX_IMAGE = 12 * 1024 * 1024
MAX_PIXELS = 4096 * 4096 # a 4K screenshot; Chromium decodes it on the Frame
PNG = b'\x89PNG\r\n\x1a\n'
def chunk(kind, data):
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
def png_size(data):
"""IHDR dimensions of a PNG of any bit depth or interlace; Steam's Chromium decodes the pixels."""
if len(data) < 33 or data[12:16] != b'IHDR' or struct.unpack_from('>I', data, 8)[0] != 13:
raise ValueError('invalid PNG header')
if zlib.crc32(data[12:29]) != struct.unpack_from('>I', data, 29)[0]:
raise ValueError('invalid PNG checksum')
w, h, depth, color = struct.unpack_from('>IIBB', data, 16)
if color not in (0, 2, 3, 4, 6) or depth not in (1, 2, 4, 8, 16):
raise ValueError('unsupported PNG encoding')
if not w or not h or w * h > MAX_PIXELS or w > 8192 or h > 8192:
raise ValueError('PNG dimensions exceed limits')
return w, h
def _gif_blocks(data, pos):
"""Position after a run of GIF data sub-blocks and its terminator."""
while True:
if pos >= len(data):
raise ValueError('truncated GIF')
size = data[pos]
pos += 1 + size
if not size:
return pos
def gif_frame(data):
"""A GIF's first frame as a minimal single-frame GIF, so no frame count or oversized frame
reaches the Frame's Chromium. Raises ValueError for anything malformed or out of bounds."""
if data[:6] not in (b'GIF87a', b'GIF89a') or len(data) < 13:
raise ValueError('invalid GIF')
sw, sh, flags = struct.unpack_from('<HHB', data, 6)
if not sw or not sh or sw * sh > MAX_PIXELS or max(sw, sh) > 4096:
raise ValueError('artwork GIF has unsupported dimensions')
pos = 13 + (3 << ((flags & 7) + 1) if flags & 0x80 else 0)
head, control = data[:pos], b''
if len(head) != pos:
raise ValueError('truncated GIF')
while True:
if pos >= len(data):
raise ValueError('truncated GIF')
if data[pos] == 0x21 and pos + 1 < len(data): # extension: keep the frame's graphic control
end = _gif_blocks(data, pos + 2)
if data[pos + 1] == 0xf9 and end - pos == 8 and data[pos + 2] == 4: # GIF89a: fixed 4-byte payload
control = data[pos:end]
pos = end
elif data[pos] == 0x2c and pos + 10 <= len(data): # the first image
x, y, w, h, local = struct.unpack_from('<HHHHB', data, pos + 1)
if not w or not h or x + w > sw or y + h > sh:
raise ValueError('artwork GIF frame exceeds its screen')
if not flags & 0x80 and not local & 0x80:
raise ValueError('GIF has no colour table')
start = pos
pos += 10 + (3 << ((local & 7) + 1) if local & 0x80 else 0)
if pos >= len(data) or not 2 <= data[pos] <= 8: # the LZW minimum code size
raise ValueError('invalid GIF image data')
end = _gif_blocks(data, pos + 1)
if end - pos <= 2: # code size then the terminator: no pixels at all
raise ValueError('invalid GIF image data')
return head + control + data[start:end] + b'\x3b'
else:
raise ValueError('invalid GIF block')
def image_type(data):
if not isinstance(data, bytes) or len(data) > MAX_IMAGE:
raise ValueError('artwork must be image bytes or an HTTP(S) URL, at most 12 MiB')
if data.startswith(PNG):
png_size(data)
return 'png'
if data[:6] in (b'GIF87a', b'GIF89a'):
gif_frame(data)
return 'gif'
if data.startswith(b'\xff\xd8'):
# Check JPEG SOF dimensions without depending on an image library; trailing padding is fine.
pos = 2
while pos + 4 <= len(data) and data[pos] == 255:
marker = data[pos + 1]
pos += 2
if marker == 255:
pos -= 1
continue
size = struct.unpack_from('>H', data, pos)[0]
if size < 2 or pos + size > len(data):
break
if 0xc0 <= marker <= 0xcf and marker not in (0xc4, 0xc8, 0xcc) and size >= 8:
h, w = struct.unpack_from('>HH', data, pos + 3)
if w and h and w * h <= MAX_PIXELS and max(w, h) <= 8192:
return 'jpg'
break
pos += size
raise ValueError('artwork must be a supported PNG, JPEG or GIF')
def fetch(value, deadline=None):
if isinstance(value, str):
from apk_sources import _images
# Public addresses only, at most three redirects, within the overall deadline.
value = _images.fetch(value, deadline=deadline, limit=MAX_IMAGE)[0]
kind = image_type(value)
return kind, gif_frame(value) if kind == 'gif' else value
def prepare(label, icon_png=None, artwork=None, budget=90):
"""Gather inputs; the Frame's Chromium canvas renders every final slot.
Every source is optional: any failure falls back to generated art, within budget seconds overall."""
import frame_steamgriddb
artwork = artwork or {}
allowed = set(SLOTS) | {'banner', 'feature_graphic', 'screenshots', 'screenshot'}
if not isinstance(artwork, dict) or set(artwork) - allowed:
raise ValueError('unknown artwork slot')
deadline = time.monotonic() + budget
supplied, warnings = {}, []
def get(value):
try:
return fetch(value, deadline)
except Exception: # an optional source never blocks the install; generated art covers it
return None
for slot, value in artwork.items():
values = value if slot == 'screenshots' and isinstance(value, (list, tuple)) else [value]
for candidate in values[:4]:
image = get(candidate)
if image:
supplied['screenshot' if slot == 'screenshots' else slot] = image
break
else:
warnings.append('Source ' + slot + ' unavailable; using fallback art')
if 'icon' not in supplied and icon_png:
image = get(icon_png)
if image:
supplied['icon'] = image
try:
provider, provider_warnings = frame_steamgriddb.lookup(label, deadline)
except Exception:
provider, provider_warnings = {}, ['SteamGridDB unavailable; using source or generated art']
warnings.extend(provider_warnings)
for slot, value in provider.items():
image = get(value)
if image:
supplied[slot] = image
else:
warnings.append('SteamGridDB ' + slot + ' download failed; using fallback art')
return supplied, warnings
+264
View File
@@ -0,0 +1,264 @@
"""Opt-in session worker ON the Frame; no root, extra apps, or power actions.
One worker per user, shared by desktop and phone. State survives companion
connections, not headset reboots. See docs/family-comfort.md for guarantees.
"""
import contextlib
import json
import os
from pathlib import Path
import subprocess
import sys
import time
import uuid
from frame_steam import Page
from frame_status import battery, thermal_alerts, activity_level
ROOT = Path.home() / '.local/state/frame-control/comfort'
VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd'
HOME_JS = """(async () => {
SteamUIStore.Navigate('/library/home');
await SteamClient.OpenVR.VROverlay.ShowDashboard('valve.steam.gamepadui.main');
if (!await SteamClient.OpenVR.VROverlay.IsDashboardVisible()) throw Error('Steam dashboard did not open');
return {path: location.pathname};
})()"""
def clock():
# CLOCK_BOOTTIME includes headset suspend; wall-clock corrections don't alter limits.
return time.clock_gettime(time.CLOCK_BOOTTIME)
def boot():
return Path('/proc/sys/kernel/random/boot_id').read_text().strip()
def validate(body):
if not isinstance(body, dict) or body.get('action') not in ('status', 'start', 'cancel'):
raise ValueError('Choose status, start or cancel')
if body['action'] == 'start':
for key, low, high in (('minutes', 1, 240), ('breakMinutes', 0, 120), ('stillMinutes', 0, 240)):
n = body.get(key)
if type(n) is not int or not low <= n <= high:
raise ValueError(f'{key} must be a whole number from {low} to {high}')
for key in ('batteryAlert', 'heatAlert'):
if type(body.get(key)) is not bool:
raise ValueError(f'{key} must be true or false')
return body
def new_session(body, now, boot_id):
return {'id': uuid.uuid4().hex, 'boot': boot_id, 'active': True,
'options': {k: body[k] for k in ('minutes', 'breakMinutes', 'stillMinutes', 'batteryAlert', 'heatAlert')},
'started': now, 'deadline': now + body['minutes'] * 60, 'lastSample': now,
'used': 0, 'nextBreak': body['breakMinutes'] * 60, 'stillSent': False,
'warned': None, 'events': [], 'seq': 0, 'latched': [], 'error': None}
def event(s, kind, message):
s['seq'] += 1
s['events'].append({'id': s['id'] + ':' + str(s['seq']), 'kind': kind,
'message': message, 'time': time.time()})
s['events'] = s['events'][-40:]
def notify(message):
r = subprocess.run([VRCMD, '--notify', 'Frame Control: ' + message],
capture_output=True, text=True, timeout=20)
if r.returncode or 'succeeded' not in r.stdout:
raise RuntimeError('SteamVR could not show the reminder: ' + (r.stderr or r.stdout)[-300:])
def home():
# A total process deadline also bounds a CDP peer that keeps sending events
# without completing the request. Keep cancellation ordered after this action.
r = subprocess.run([sys.executable, str(Path(__file__).resolve()), '--home'],
capture_output=True, text=True, timeout=15)
if r.returncode:
raise RuntimeError('Steam Home failed: ' + (r.stdout or r.stderr)[-300:])
def open_home():
page = Page()
try:
result = page.eval(HOME_JS)
if result.get('path') != '/routes/library/home':
raise RuntimeError('Steam did not navigate Home')
finally:
page.sock.close()
def tick(s, now, sample, warn=notify, go_home=home, read_clock=clock):
"""One deterministic step; injected actions/samples also exercise a fake Frame."""
if not s.get('active'):
return
o = s['options']
s['heartbeat'] = now
delta = max(0, min(30, now - s['lastSample']))
s['lastSample'] = now
level = sample.get('activity')
b = sample.get('battery') or {}
s['unavailable'] = []
if o['batteryAlert'] and b.get('percent') is None:
s['unavailable'].append('battery')
if o['heatAlert'] and sample.get('thermal') is None:
s['unavailable'].append('temperature')
if (o['breakMinutes'] or o['stillMinutes']) and level is None:
s['unavailable'].append('activity')
s['activity'] = level
if level in (1, 2):
s['used'] += delta
elif level is not None:
s['used'] = 0
s['nextBreak'] = o['breakMinutes'] * 60
s['stillSent'] = False
# Missing samples never count as time worn. No catch-up burst after a disconnect.
if now >= s['deadline'] - 60 and s['warned'] is None:
warn('One minute left. Save your progress; Steam Home will open.')
s['warned'] = max(now, read_clock())
event(s, 'warning', 'One minute left. Save your progress; Steam Home will open.')
if s['warned'] is not None and now >= max(s['deadline'], s['warned'] + 60):
go_home()
s['active'] = False
event(s, 'finished', 'Session ended: Steam Home opened. Your game is still running.')
return
if o['breakMinutes'] and s['used'] >= s['nextBreak']:
warn('Time for a break. Take off the headset and rest your eyes.')
event(s, 'break', 'Time for a break. Take off the headset and rest your eyes.')
s['nextBreak'] = s['used'] + o['breakMinutes'] * 60
if o['stillMinutes'] and not s['stillSent'] and s['used'] >= o['stillMinutes'] * 60:
event(s, 'still', f"Headset still active after {o['stillMinutes']} active minute(s). Check in with the wearer.")
s['stillSent'] = True
low = b.get('percent') is not None and b['percent'] <= 15 and b.get('status') == 'Discharging'
hot = sample.get('thermal')
for kind, enabled, value, message in (
('battery', o['batteryAlert'], low if b else None, 'Frame battery is low (15% or less).'),
('heat', o['heatAlert'], bool(hot) if hot is not None else None,
'Frame reports a hot/critical thermal trip or battery overheat. Ask the wearer to take a break.')):
if enabled and value and kind not in s['latched']:
event(s, kind, message)
s['latched'].append(kind)
elif value is False and kind in s['latched']:
# Battery hysteresis prevents repeated alerts around 15%.
if kind != 'battery' or b.get('status') == 'Charging' or (b.get('percent') or 0) >= 20:
s['latched'].remove(kind)
@contextlib.contextmanager
def locked(name='state.lock', nonblocking=False):
import fcntl # only needed ON the Linux headset, not by desktop validation/tests
ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
with (ROOT / name).open('a') as f:
fcntl.flock(f, fcntl.LOCK_EX | (fcntl.LOCK_NB if nonblocking else 0))
yield f
def read_state():
try:
state = json.loads((ROOT / 'session.json').read_text())
if not isinstance(state, dict):
raise ValueError('Saved session must be an object')
return state
except FileNotFoundError:
return {'active': False, 'events': []}
except (ValueError, UnicodeDecodeError):
# Preserve the unreadable state for diagnosis, then allow a new session.
(ROOT / 'session.json').replace(ROOT / ('session-unreadable-' + uuid.uuid4().hex + '.json'))
return {'active': False, 'events': [],
'error': 'Saved session was unreadable. Start a new session.'}
def save(s):
p = ROOT / 'session.tmp'
p.write_text(json.dumps(s))
p.chmod(0o600)
p.replace(ROOT / 'session.json')
def current(s, now):
if s.get('active') and s.get('boot') != boot():
s['active'] = False
s['error'] = 'Headset restarted. Start a new session.'
out = dict(s)
out['time'] = time.time() # event age uses the Frame's clock, not the phone's
out['remaining'] = max(0, max(s.get('deadline', now), (s.get('warned') or 0) + 60) - now) if s.get('active') else 0
beat = s.get('heartbeat', s.get('started', now)) # a hand-edited state may lack either
if s.get('active') and now - beat > 90:
out['error'] = 'Session worker is not responding. Timer enforcement is unverified; cancel and start again.'
return out
def watch():
try:
with locked('worker.lock', nonblocking=True) as worker:
while True:
with locked():
s = current(read_state(), clock())
if not s.get('active'):
save(s)
# Release ownership before state.lock: a concurrent start
# cannot miss the gap between an old worker and its exit.
import fcntl
fcntl.flock(worker, fcntl.LOCK_UN)
return
try:
b = battery()
hot = thermal_alerts()
if b and b.get('health') == 'Overheat':
hot = (hot or []) + ['battery']
tick(s, clock(), {'battery': b, 'thermal': hot, 'activity': activity_level()})
s['error'] = None
except Exception as e:
error = str(e)
if s.get('error') != error:
event(s, 'error', 'Session action failed: ' + error)
s['error'] = error
save(s)
time.sleep(5)
except BlockingIOError:
pass # another connection already started the single worker
def command(body):
validate(body)
with locked():
s = current(read_state(), clock())
if body['action'] == 'start':
if s.get('active'):
raise ValueError('A session is already running. Cancel it before starting another.')
s = new_session(body, clock(), boot())
event(s, 'started', 'Session started. Steam Home opens at the limit; games are not closed.')
elif body['action'] == 'cancel':
s['active'] = False
s['error'] = None
if s.get('id'):
event(s, 'cancelled', 'Session timer and monitoring cancelled.')
save(s)
if body['action'] == 'start':
try:
subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '--watch'],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True, close_fds=True)
except OSError as e:
s['active'] = False
s['error'] = 'Could not start session worker: ' + str(e)
event(s, 'error', s['error'])
save(s)
raise
return current(s, clock())
if __name__ == '__main__':
if sys.argv[1:] == ['--watch']:
watch()
else:
try:
if sys.argv[1:] == ['--home']:
open_home()
print(json.dumps({'home': True}))
else:
print(json.dumps(command(json.loads(sys.argv[1]))))
except Exception as e:
print(json.dumps({'error': str(e)}))
sys.exit(1)
+80 -14
View File
@@ -33,6 +33,15 @@ SLOTS = {
}
class OverlayBusy(RuntimeError):
"""SetOverlayRaw's RequestFailed (23): SteamVR isn't taking frames, e.g. the
unworn headset is in standby (verified 2026-09-29). Transient, not fatal."""
# A screen that can't take a frame for this long is broken, not asleep.
BUSY_LIMIT = 300
class Overlay:
def __init__(self):
self.handles = []
@@ -53,6 +62,8 @@ class Overlay:
def call(self, name, *values):
slot, args = SLOTS[name]
rc = C.CFUNCTYPE(C.c_int, *args)(self.table[slot])(*values)
if rc == 23 and name == 'SetOverlayRaw':
raise OverlayBusy('SteamVR is not accepting frames (standby?)')
if rc:
raise RuntimeError('OpenVR %s failed: %s' % (name, rc))
@@ -75,11 +86,14 @@ class Overlay:
self.call('ShowOverlay', handle)
def close(self):
try:
for h in reversed(self.handles):
# Best effort: SteamVR removes a disconnected client's overlays anyway,
# and a teardown error must not overwrite a finished playback's status.
for h in reversed(self.handles):
try:
self.call('DestroyOverlay', h)
finally:
self.vr.VR_ShutdownInternal()
except RuntimeError:
pass
self.vr.VR_ShutdownInternal()
def probe(path):
@@ -114,6 +128,11 @@ def decoder_command(path, info, width, height, audio, photo=False):
return cmd
def ignore_signals():
signal.signal(signal.SIGTERM, signal.SIG_IGN)
signal.signal(signal.SIGINT, signal.SIG_IGN)
def write_status(path, **values):
tmp = path.with_suffix('.tmp')
tmp.write_text(json.dumps(values))
@@ -135,6 +154,46 @@ def play(args):
photo = plan['kind'] == 'photo'
command = decoder_command(path, info, width, height, audio, photo)
vr, proc, frames, started = None, None, 0, time.monotonic()
dropped, busy_since, pending = 0, None, []
def show(handle, data, w, h, video=False):
"""Submit a frame. During standby return False; a video frame is dropped."""
nonlocal dropped, busy_since
try:
vr.pixels(handle, data, w, h)
except OverlayBusy:
if not video:
return False # stills and the surround just wait; nothing is lost
dropped += 1
busy_since = busy_since or time.monotonic()
if time.monotonic() - busy_since > BUSY_LIMIT:
raise RuntimeError('SteamVR stopped accepting frames for %d s' % BUSY_LIMIT)
return False
if video:
busy_since = None
drain()
return True
def drain():
"""Re-send anything that arrived during standby (e.g. the theatre surround)."""
while pending:
item = pending.pop(0)
try:
vr.pixels(*item)
except OverlayBusy:
pending.insert(0, item)
return
def hold(handle, data, w, h):
"""Keep a still (photo or splat) up until Stop, retrying through standby."""
shown = False
while True:
if shown:
drain()
else:
shown = show(handle, data, w, h)
time.sleep(1)
# systemd sends SIGTERM to the whole unit, including ffmpeg. Python unwinds
# ownership; no unrelated Steam/SteamVR process or setting is touched.
def stop(signum, frame):
@@ -146,14 +205,13 @@ def play(args):
if args.theatre:
surround = vr.create('framecontrol.media.surround', 40, 4, order=0)
vr.call('SetOverlayAlpha', surround, .85)
vr.pixels(surround, b'\x00\x00\x00\xff', 1, 1)
if not show(surround, b'\x00\x00\x00\xff', 1, 1):
pending.append((surround, b'\x00\x00\x00\xff', 1, 1))
screen = vr.create('framecontrol.media.screen', 3 if args.theatre else 1.6, 2,
plan['layout'] != 'mono', aspect)
if splat:
vr.pixels(screen, data, width, height)
write_status(status, state='playing', file=path.name, frames=1, **plan)
while True:
time.sleep(1)
hold(screen, data, width, height)
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
video_start = time.monotonic()
while True:
@@ -163,11 +221,14 @@ def play(args):
data, outw, outh = frame_media.stereo_pixels(data, width, height, plan['layout'])
if not photo:
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
vr.pixels(screen, data, outw, outh)
if photo:
still = data, outw, outh
else:
show(screen, data, outw, outh, video=True)
frames += 1
if frames == 1 or frames % 30 == 0:
write_status(status, state='playing', file=path.name, frames=frames,
seconds=time.monotonic()-started, **plan)
dropped=dropped, seconds=time.monotonic()-started, **plan)
if not photo:
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
rc = proc.wait(timeout=10)
@@ -176,12 +237,17 @@ def play(args):
if not frames:
raise RuntimeError('Decoder produced no frames')
if photo:
while True:
time.sleep(1)
write_status(status, state='ended', frames=frames, seconds=time.monotonic()-started)
hold(screen, *still)
# From here on a Stop can't change the outcome; don't let it turn
# 'ended' into an error while we write status and clean up.
ignore_signals()
write_status(status, state='ended', frames=frames, dropped=dropped,
seconds=time.monotonic()-started)
except InterruptedError:
write_status(status, state='stopped', frames=frames)
ignore_signals()
write_status(status, state='stopped', frames=frames, dropped=dropped)
finally:
ignore_signals()
if proc:
if proc.poll() is None:
proc.terminate()
+1 -1
View File
@@ -88,7 +88,7 @@ def run(body):
'--property=StandardOutput=append:'+str(RUNTIME/'player.log'),
'--property=StandardError=append:'+str(RUNTIME/'player.log'),
'python3', str(RUNTIME/'frame_media_player.py'), str(path),
'--layout', plan['layout'], '--status', str(STATUS)]
'--layout', body.get('layout', 'auto'), '--status', str(STATUS)]
if body.get('theatre'):
command.append('--theatre')
started = subprocess.run(command, capture_output=True, text=True, timeout=15)
+261
View File
@@ -0,0 +1,261 @@
"""Panel switcher. Runs on the Frame, either piped over SSH or installed with
--open for its loopback-only headset page. Uses Valve's shipped vrcmd and
Chromium, not an overlay app. Spatial layout limitations: docs/panels.md.
"""
import argparse
import hmac
import json
import os
from pathlib import Path
import re
import secrets
import signal
import subprocess
import sys
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd'
PANEL_ID = 2000999030
PANEL_KEY = 'valve.steam.desktopgame.' + str(PANEL_ID)
KEY = re.compile(r'[A-Za-z0-9_.:-]{1,200}\Z')
class PanelError(Exception):
pass
def run(args):
try:
p = subprocess.run(args, capture_output=True, text=True, timeout=10,
env={**os.environ, 'DISPLAY': ':0', 'LC_ALL': 'C.UTF-8'})
except (OSError, subprocess.TimeoutExpired) as e:
raise PanelError('The panel service did not answer: ' + str(e))
if p.returncode:
raise PanelError((p.stderr or p.stdout).strip()[-400:] or 'Panel command failed')
return p.stdout
def parse_overlays(text):
"""Only main dashboard panels, never their thumbnails, layers or cursors.
vrcmd output verified on SteamVR 2.18.1, BUILD_ID 20260925.6191901.
"""
if '---- OVERLAYS ----' not in text:
raise PanelError('SteamVR did not return its panel list. Is the headset awake?')
panels = []
for line in text.splitlines():
m = re.fullmatch(r"'([^']+)' -- '(.*)', (.*?) VROverlayType_Dashboard_Main\s*", line)
if m and KEY.fullmatch(m[1]):
panels.append({'key': m[1], 'title': 'Panel switcher' if m[1] == PANEL_KEY else m[2] or m[1],
'visible': 'not_visible' not in m[3]})
return panels
def state():
return {'panels': parse_overlays(run([VRCMD, '--overlays']))}
def focus(key):
if not isinstance(key, str) or not KEY.fullmatch(key):
raise PanelError('Choose an open panel.')
if key not in {p['key'] for p in state()['panels']}:
raise PanelError('That panel has closed. Refresh the list.')
run([VRCMD, '--showdashboard', key])
# vrcmd acknowledges dispatch, not final focus (a game or the user can
# switch again). Do not report a focus success without observing it.
return {'requested': key, 'message': 'Asked SteamVR to show the panel.'}
PAGE = '''<!doctype html><html lang="en"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Panel switcher [fc-panels]</title>
<style>body{background:#101b27;color:#eee;font:24px system-ui;margin:36px;max-width:1000px}
h1{font-size:36px}button{font:inherit;padding:16px 24px;border:1px solid #546574;border-radius:10px;
background:#23384b;color:white;cursor:pointer}button:focus-visible{outline:4px solid #66c0f4}
#panels{display:grid;gap:14px;margin:24px 0}#panels button{text-align:left}p{color:#bac8d5}</style>
<h1>Panel switcher</h1><p>Choose a panel to show it. Open this panel again from Steam's dashboard.</p>
<button id="refresh">Refresh</button> <button id="close">Close switcher</button>
<p id="status" role="status"></p><div id="panels"></div>
<script>
const token=location.hash.slice(1)||sessionStorage.getItem('panelKey')||'';
if(token)sessionStorage.setItem('panelKey',token);history.replaceState(null,'',location.pathname);
async function api(path,body){const r=await fetch(path,{method:body?'POST':'GET',
headers:{'X-Panel-Key':token,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
const s=await r.json();if(!r.ok)throw Error(s.error||'Panel request failed');return s;}
const status=document.getElementById('status');
async function refresh(){try{const s=await api('/panels');const list=document.getElementById('panels');list.replaceChildren();
for(const p of s.panels){const b=document.createElement('button');b.textContent=p.title;
b.onclick=async()=>{b.disabled=true;try{const r=await api('/focus',{key:p.key});status.textContent=r.message;}
catch(e){status.textContent=e.message;}finally{b.disabled=false;}};list.append(b);}
status.textContent=s.panels.length?'':'No open panels.';}catch(e){status.textContent=e.message;}}
document.getElementById('refresh').onclick=refresh;
document.getElementById('close').onclick=async()=>{try{await api('/close',{});window.close();}catch(e){status.textContent=e.message;}};
refresh();
</script></html>'''
class Handler(BaseHTTPRequestHandler):
def setup(self):
super().setup()
self.connection.settimeout(5)
def log_message(self, *args):
pass # never log the page's access key
def reply(self, code, value, html=False):
data = value.encode() if html else json.dumps(value).encode()
self.send_response(code)
self.send_header('Content-Type', 'text/html; charset=utf-8' if html else 'application/json')
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Referrer-Policy', 'no-referrer')
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'")
self.end_headers()
self.wfile.write(data)
def allowed(self):
host = '127.0.0.1:' + str(self.server.server_port)
origin = self.headers.get('Origin')
return (self.headers.get('Host') == host and
(origin is None or origin == 'http://' + host) and
hmac.compare_digest(self.headers.get('X-Panel-Key', '').encode(), self.server.key.encode()))
def do_GET(self):
if self.path == '/':
return self.reply(200, PAGE, html=True) # no data or access key in the page
if not self.allowed():
return self.reply(403, {'error': 'Open the switcher from Frame Control.'})
try:
if self.path == '/panels':
return self.reply(200, state())
self.reply(404, {'error': 'Not found'})
except PanelError as e:
self.reply(502, {'error': str(e)})
def do_POST(self):
if not self.allowed():
return self.reply(403, {'error': 'Forbidden'})
try:
size = int(self.headers.get('Content-Length', '0'))
if not 0 < size <= 1024:
raise ValueError('Invalid request size')
body = json.loads(self.rfile.read(size))
if not isinstance(body, dict):
raise ValueError('Expected an object')
if self.path == '/focus':
return self.reply(200, focus(body.get('key')))
if self.path == '/close':
self.server.closing = True
return self.reply(200, {'closed': True})
self.reply(404, {'error': 'Not found'})
except (ValueError, PanelError) as e:
self.reply(400, {'error': str(e)})
def serve():
"""Own only our Chromium profile and process group. No changes to Steam,
SteamVR, other Chromium sessions, or global power settings.
"""
import fcntl # only on the Frame; module/tests also import on Windows
folder = Path.home() / '.local/share/frame-control/panels'
folder.mkdir(parents=True, exist_ok=True, mode=0o700)
with (folder / 'lock').open('w') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print(json.dumps(focus(PANEL_KEY)), flush=True)
return
chrome = Path.home() / 'chromium-xr/chrome'
if chrome.is_file():
command = [str(chrome)]
profile = folder / 'chromium'
elif Path('/usr/bin/chromium').is_file():
command = ['/usr/bin/chromium']
profile = folder / 'chromium'
elif subprocess.run(['flatpak', 'info', 'org.chromium.Chromium'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10).returncode == 0:
command = ['flatpak', 'run', 'org.chromium.Chromium']
profile = Path.home() / '.var/app/org.chromium.Chromium/data/frame-panel-switcher'
else:
raise PanelError('The headset switcher needs Chromium. The companion switcher still works.')
server = HTTPServer(('127.0.0.1', 0), Handler)
server.key = secrets.token_urlsafe(32)
server.closing = False
server.timeout = .5
url = 'http://127.0.0.1:%d/#%s' % (server.server_port, server.key)
env = {**os.environ, 'DISPLAY': ':0'}
env.pop('WAYLAND_DISPLAY', None)
browser = subprocess.Popen([*command, '--ozone-platform=x11',
'--user-data-dir=' + str(profile),
'--no-first-run', '--no-default-browser-check',
'--password-store=basic', '--window-size=1200,800', '--app=' + url],
env=env, start_new_session=True, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def stop(signum, frame):
server.closing = True
signal.signal(signal.SIGTERM, stop)
win = None
try:
deadline = time.monotonic() + 30
while not win and time.monotonic() < deadline and browser.poll() is None:
server.handle_request() # Chromium must fetch the page before it has a title
for line in run(['xwininfo', '-root', '-children']).splitlines():
m = re.match(r'\s*(0x[0-9a-fA-F]+) .*\[fc-panels\]', line)
if m:
win = m[1]
break
if not win:
raise PanelError('The switcher window did not appear within 30 seconds.')
run(['xprop', '-id', win, '-f', 'STEAM_GAME', '32c', '-set', 'STEAM_GAME', str(PANEL_ID)])
print(json.dumps({'message': 'Opened the panel switcher in the headset.'}), flush=True)
# Caller reads exactly one line, then disconnects; no more stdout.
while not server.closing and browser.poll() is None:
server.handle_request()
# Closing the last app window need not exit Chromium.
if win not in run(['xwininfo', '-root', '-children']):
break
finally:
server.server_close()
if browser.poll() is None:
os.killpg(browser.pid, signal.SIGTERM)
try:
browser.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(browser.pid, signal.SIGKILL)
browser.wait()
def open_switcher():
# This command runs from an installed path, never from the SSH stdin copy.
proc = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '--serve'],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, start_new_session=True)
line = proc.stdout.readline()
proc.stdout.close()
if not line:
raise PanelError('The headset switcher could not start.')
result = json.loads(line)
if 'error' in result:
raise PanelError(result['error'])
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--focus')
parser.add_argument('--open', action='store_true')
parser.add_argument('--serve', action='store_true')
args = parser.parse_args()
try:
if args.serve:
serve()
else:
print(json.dumps(open_switcher() if args.open else focus(args.focus) if args.focus else state()))
except (PanelError, OSError) as e:
print(json.dumps({'error': str(e)}), flush=True)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
+157 -25
View File
@@ -1,9 +1,12 @@
"""Runs ON the Steam Frame (piped over SSH as `python3 -`); prints one JSON object.
Read-only. Every probe is best effort: a missing tool or file gives null, not an
error. Paths verified on SteamOS 0.3.0 (vr), build 20260922.
error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1
(build 20260925.6191901). See docs/vr-utilities.md.
"""
import ctypes as C
import glob
import math
import json
import os
import re
@@ -156,27 +159,156 @@ def flatpaks():
return out
uptime = read("/proc/uptime")
procs = process_names()
print(json.dumps({
"time": time.time(),
"hostname": socket.gethostname(),
"os": os_release(),
"uptime": float(uptime.split()[0]) if uptime else None,
"battery": battery(),
"power": power_source(),
"disk": {"root": disk("/"), "home": disk("/home")},
"memory": memory(),
"temp": max_temp(),
"wifi": wifi(),
"ip": ip_addr(),
"volume": volume(),
"services": {
"steamvr": "vrserver" in procs,
"desktop": "plasmashell" in procs,
"lepton": port_listening(5555),
"rdp": "xrdp" in procs,
},
"games": games(),
"flatpaks": flatpaks(),
}))
def thermal_alerts():
"""Use the kernel's per-zone hot/critical trips, never a guessed chip limit."""
alerts, known = [], False
for z in glob.glob("/sys/class/thermal/thermal_zone*"):
t = num(z + "/temp", 0.001)
for trip in glob.glob(z + "/trip_point_*_type"):
if read(trip) not in ("hot", "critical"):
continue
limit = num(trip[:-4] + "temp", 0.001)
if t is not None and limit is not None and limit > 0:
known = True
if t >= limit:
alerts.append({"zone": read(z + "/type"), "tempC": t, "limitC": limit})
return alerts if known else None
def activity_level():
try:
rows = json.loads(run("/opt/steamvr/bin/linuxarm64/vrcmd", "--stats"))
if not isinstance(rows, list):
return None
return next((r.get("activity_level") for r in rows
if isinstance(r, dict) and r.get("operation") == "status"), None)
except (ValueError, TypeError):
return None
# OpenVR C ABI, ValveSoftware/openvr headers/openvr_capi.h (IVRCompositor_029).
# Exact interface version: never guess an index against a different table.
class Pose(C.Structure):
_fields_ = [('matrix', C.c_float * 12), ('velocity', C.c_float * 3),
('angular', C.c_float * 3), ('result', C.c_int),
('valid', C.c_bool), ('connected', C.c_bool)]
class Timing(C.Structure):
_fields_ = [(n, C.c_uint32) for n in ('size', 'index', 'presents', 'mis', 'dropped', 'flags')] + [
('time', C.c_double)] + [(n, C.c_float) for n in (
'gpuPre', 'gpuPost', 'gpu', 'compositorGpu', 'compositorCpu', 'idleCpu', 'interval',
'presentCpu', 'waitCpu', 'submit', 'posesCalled', 'posesReady', 'frameReady',
'updateStart', 'updateEnd', 'renderStart')] + [
('pose', Pose), ('ready', C.c_uint32), ('first', C.c_uint32), ('transfer', C.c_float)]
class OpenVR:
def __enter__(self):
self.lib = C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
self.lib.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p]
self.lib.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)]
self.lib.VR_GetGenericInterface.restype = C.c_void_p
err = C.c_int()
self.lib.VR_InitInternal2(C.byref(err), 3, None) # background: never start or keep SteamVR running
if err.value:
raise RuntimeError(f'SteamVR unavailable (init {err.value})')
return self
def __exit__(self, *args):
self.lib.VR_ShutdownInternal()
def function(self, interface, index, result, *args):
err = C.c_int()
ptr = self.lib.VR_GetGenericInterface(('FnTable:' + interface).encode(), C.byref(err))
if err.value or not ptr:
raise RuntimeError(f'{interface} unavailable ({err.value})')
return C.CFUNCTYPE(result, *args)(C.cast(ptr, C.POINTER(C.c_void_p))[index])
def cpu_ticks():
line = (read('/proc/stat') or '').splitlines()
if not line or not line[0].startswith('cpu '):
return None
try:
# guest/guest_nice are already included in user/nice.
ticks = [int(x) for x in line[0].split()[1:9]]
return sum(ticks), ticks[3] + ticks[4]
except (ValueError, IndexError):
return None
def cpu_percent(before, after):
if before is None or after is None or after[0] <= before[0]:
return None
return round(max(0, min(100, 100 * (1 - (after[1] - before[1]) / (after[0] - before[0])))), 1)
def positive(value):
return round(value, 2) if math.isfinite(value) and value > 0 else None
def timing_values(before, after):
dt, frames = after.time - before.time, after.index - before.index
if dt <= 0 or frames <= 0 or frames / dt > 1000:
return {} # standby or old data; never present stale timings as live
return {'compositorFps': positive(frames / dt),
'frameMs': positive(1000 * dt / frames),
'appFps': positive(1000 / after.interval) if after.interval > 0 else None,
'gpuMs': positive(after.gpu), 'compositorCpuMs': positive(after.compositorCpu)}
def performance():
out = {'compositorFps': None, 'frameMs': None, 'appFps': None,
'gpuMs': None, 'compositorCpuMs': None, 'cpuPercent': None,
'gpuMHz': num('/sys/class/devfreq/3d00000.gpu/cur_freq', 1e-6)}
before = cpu_ticks()
try:
with OpenVR() as vr:
get = vr.function('IVRCompositor_029', 10, C.c_bool, C.POINTER(Timing), C.c_uint32)
a, b = Timing(), Timing()
a.size = b.size = C.sizeof(Timing)
first = get(C.byref(a), 0)
time.sleep(0.2)
if first and get(C.byref(b), 0):
out.update(timing_values(a, b))
except (OSError, RuntimeError, AttributeError): # AttributeError: a SteamVR build without these exports
time.sleep(0.2)
out['cpuPercent'] = cpu_percent(before, cpu_ticks())
return out
def status():
uptime = read("/proc/uptime")
procs = process_names()
return {
"time": time.time(),
"performance": performance(),
"hostname": socket.gethostname(),
"os": os_release(),
"uptime": float(uptime.split()[0]) if uptime else None,
"battery": battery(),
"power": power_source(),
"disk": {"root": disk("/"), "home": disk("/home")},
"memory": memory(),
"temp": max_temp(),
"wifi": wifi(),
"ip": ip_addr(),
"volume": volume(),
"services": {
"steamvr": "vrserver" in procs,
"desktop": "plasmashell" in procs,
"lepton": port_listening(5555),
"rdp": "xrdp" in procs,
},
"games": games(),
"flatpaks": flatpaks(),
}
def main():
print(json.dumps(status()))
if __name__ == "__main__":
main()
+21
View File
@@ -141,6 +141,21 @@ OWNED_JS = r"""
})()
"""
# Software has app_type 2, so utility ownership must not use OWNED_JS's games filter.
# Steam prices checked 2026-09-28: OVR Advanced Settings is paid on Steam too.
UTILITY_IDS = (1009850, 1173510, 1068820, 908520, 1494460)
FREE_UTILITIES = (1494460,)
UTILITIES_JS = """(() => {
const apps = appStore.allApps;
if (!apps || !apps.length) throw new Error("Steam library is not loaded; ownership is unknown");
return [1009850,1173510,1068820,908520,1494460].map(id => {
const a = apps.find(a => a.appid === id);
return {id, owned: !!a, installed: !!a?.local_per_client_data?.installed,
frame: a ? (a.steam_hw_compat_category_packed >> 8) & 3 : 0};
});
})()"""
WIZARD_JS = """SteamClient.Installs.GetInstallManagerInfo().then(i => ({
state: i?.eInstallState ?? 0, app: i?.currentAppID ?? 0, need: i?.nDiskSpaceRequired || 0,
free: i?.nDiskSpaceAvailable || 0, error: i?.eAppError, detail: i?.errorDetail }))"""
@@ -158,6 +173,10 @@ def owned():
def install(appid):
page = Page()
if appid in UTILITY_IDS and appid not in FREE_UTILITIES:
rows = page.eval(UTILITIES_JS)
if not any(r['id'] == appid and r['owned'] for r in rows):
raise Fail("This paid utility is not owned by the Frame account. No install or store action was taken.")
app = page.eval(f"(a => a && {{name: a.display_name, installed: !!a.local_per_client_data?.installed}})"
f"(appStore.GetAppOverviewByAppID({appid}))")
if app and app["installed"]:
@@ -216,6 +235,8 @@ def main():
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "owned":
out = owned()
elif cmd == "utilities":
out = {"utilities": Page().eval(UTILITIES_JS)}
elif cmd in ("install", "store") and len(sys.argv) == 3 and sys.argv[2].isdigit():
out = (install if cmd == "install" else store)(int(sys.argv[2]))
else:
+105
View File
@@ -0,0 +1,105 @@
"""Optional SteamGridDB artwork. Credentials stay on the host, never in app metadata."""
import json
import os
import re
import tempfile
import time
import unicodedata
import urllib.parse
import frame_host
API = 'https://www.steamgriddb.com/api/v2'
MAX_JSON = 2 * 1024 * 1024
def settings_path():
return frame_host.data_dir('artwork-settings.json')
def api_key():
env = os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY')
if env:
return env.strip()
try:
return str(json.loads(settings_path().read_text()).get('steamgriddb_api_key') or '')
except (OSError, ValueError, AttributeError):
return ''
def settings():
return {'steamgriddb_configured': bool(api_key()),
'environment': bool(os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY'))}
def save_settings(body):
key = body.get('steamgriddb_api_key')
if not isinstance(key, str) or len(key) > 200 or (key and not re.fullmatch(r'[A-Za-z0-9_-]+', key)):
raise ValueError('enter a valid SteamGridDB API key, or an empty value to remove it')
path = settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp = tempfile.mkstemp(prefix='.artwork-', dir=str(path.parent))
try:
with os.fdopen(fd, 'w') as f:
json.dump({'steamgriddb_api_key': key}, f)
os.replace(temp, path)
finally:
if os.path.exists(temp):
os.remove(temp)
return settings()
def _get(path, key, deadline=None):
from apk_sources import _images
# No redirects: the credential never goes anywhere but the API. Errors never contain it.
data = _images.get(API + path, {'Authorization': 'Bearer ' + key, 'Accept': 'application/json'}, redirects=0,
deadline=time.monotonic() + 12 if deadline is None else min(deadline, time.monotonic() + 12),
limit=MAX_JSON)
result = json.loads(data)
if not isinstance(result, dict) or not result.get('success') or not isinstance(result.get('data'), list):
raise ValueError('SteamGridDB lookup failed')
return result['data']
def _name(value):
# Letters and digits of any script, so a CJK title never normalises to ''.
return ''.join(c for c in unicodedata.normalize('NFKC', str(value or '')).casefold() if c.isalnum())
def lookup(name, deadline=None):
"""Best-voted art per slot for an exact title match; unrelated games are never guessed."""
key = api_key()
if not key:
return {}, []
try:
names = {_name(name), _name(re.sub(r'\s+VR$', '', name, flags=re.I))} - {''}
if not names:
return {}, []
matches = _get('/search/autocomplete/' + urllib.parse.quote(name, safe=''), key, deadline)
game = next((g for g in matches if isinstance(g, dict) and _name(g.get('name')) in names), None)
if not game:
return {}, []
gid = int(game['id'])
result, warnings = {}, []
for slot, kind, dimensions in [('grid', 'grids', '600x900'), ('wide', 'grids', '920x430'),
('hero', 'heroes', ''), ('logo', 'logos', ''), ('icon', 'icons', '')]:
try:
# Logos and icons only come as PNG (or WebP/ICO); asking for JPEG there is rejected outright.
mimes = 'image/png,image/jpeg' if kind in ('grids', 'heroes') else 'image/png'
query = {'types': 'static', 'nsfw': 'false', 'humor': 'false', 'mimes': mimes}
if dimensions:
query['dimensions'] = dimensions
records = _get('/' + kind + '/game/' + str(gid) + '?' + urllib.parse.urlencode(query), key, deadline)
records = [r for r in records if isinstance(r, dict) and str(r.get('url', '')).startswith('https://')
and not r.get('nsfw')]
if dimensions:
w, h = map(int, dimensions.split('x'))
records = [r for r in records if (r.get('width'), r.get('height')) == (w, h)]
records.sort(key=lambda r: (int(r.get('score') or 0), int(r.get('upvotes') or 0)), reverse=True)
if records:
result[slot] = records[0]['url']
except Exception: # HTTPException, odd JSON: this slot falls back
warnings.append('SteamGridDB ' + slot + ' unavailable; using source or generated art')
return result, warnings
except Exception:
return {}, ['SteamGridDB unavailable; using source or generated art']
+146 -10
View File
@@ -16,9 +16,9 @@ checked on a headset; see docs/sideloading.md.
Python stdlib only. CLI:
python3 ui/frame_titles.py inspect PATH
python3 ui/frame_titles.py install PATH [--name N] [--exe REL] [--runtime R]
python3 ui/frame_titles.py list | launch ID | remove ID
python3 ui/frame_titles.py list | launch ID | remove ID | refresh-art ID|--all
"""
import hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile
import base64, hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile
import frame_android
import frame_host
@@ -632,7 +632,7 @@ def _rsync():
_install_lock = threading.Lock()
def install(path, name=None, exe=None, runtime=None, progress=None):
def install(path, name=None, exe=None, runtime=None, progress=None, artwork=None):
"""Sideload a .zip, folder or executable as a Devkit Game; returns the title dict.
name: the Steam name (sanitised to the title id), default from the file name.
@@ -643,12 +643,12 @@ def install(path, name=None, exe=None, runtime=None, progress=None):
"""
plan = inspect(path, name)
try:
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress)
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress, artwork=artwork)
finally:
discard(plan)
def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
def install_plan(plan, name=None, exe=None, runtime=None, progress=None, artwork=None):
"""Install an inspect() plan, optionally with another name, target or runtime."""
if name:
plan['name'], plan['id'] = name, title_id(name)
@@ -656,13 +656,21 @@ def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
_choose(plan, exe or plan['target'], runtime)
step = progress or (lambda *a: None)
with _install_lock:
return _install(plan, step)
return _install(plan, step, artwork)
def _install(plan, step):
def _install(plan, step, artwork=None):
gid = plan['id']
if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
raise FrameError(f'bad title id {gid!r}')
icon = None
for filename in ('icon.png', 'logo.png'):
path = os.path.join(plan['root'], filename)
if os.path.isfile(path):
with open(path, 'rb') as f:
icon = f.read(frame_android.frame_artwork.MAX_IMAGE + 1)
break
images, warnings = frame_android.frame_artwork.prepare(plan['name'], icon, artwork)
step("Syncing Valve's devkit tools to the Frame", 0.02)
ensure_utils()
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
@@ -672,6 +680,7 @@ def _install(plan, step):
if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'):
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
registered = False
library_ready, shortcut, steam_registered = False, None, False
try:
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
if _rsync():
@@ -687,17 +696,40 @@ def _install(plan, step):
reply = _json_out(ssh(f'{PY}steam-client-create-shortcut --parms {shlex.quote(json.dumps(parms))}',
timeout=90), 'steam-client-create-shortcut')
meta = {'id': gid, 'name': plan['name'], 'target': plan['target'], 'runtime': plan['runtime'],
'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S')}
'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
# Until art is applied: the only entries automatic backfill may touch.
'art_pending': True}
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:
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}")
steam_registered = True
shortcut = _library_shortcut(gid, directory)
if not shortcut:
raise FrameError('Steam registered the title but its shortcut is not available for mandatory artwork; retry install')
result = frame_android.apply_library(shortcut, plan['name'], directory + '/.frame-artwork', images,
category='Sideloaded', details={'source': plan['source']})
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), art_pending=False,
library_warnings=warnings + result.get('warnings', []))
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
library_ready = True
step('Done', 1.0)
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
return meta
finally:
if steam_registered and not library_ready and not existed:
# A newly registered title must not remain as a blank library tile. Cleanup
# failures are swallowed so the error that got us here is the one reported.
try:
if shortcut:
frame_android.shortcut_tool('remove', str(shortcut))
else:
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
except FrameError:
pass
registered = False
if not registered and not existed:
# A first install that failed part-way: don't leave an orphan folder behind.
try:
@@ -706,6 +738,90 @@ def _install(plan, step):
pass
def _library_shortcut(gid, directory):
"""The Steam shortcut of title gid, only ever one that is provably this title's.
Steam's app overviews don't expose devkit_gameid (checked 2026-09-28, build 20260925.6191901),
so after the saved id this matches the shortcut's executable or start folder inside directory.
Never by display name: another non-Steam shortcut could share it and would be renamed or deleted.
"""
shortcuts = json.loads(frame_android.shortcut_tool('list'))
matches = [s for s in shortcuts if s.get('devkit_gameid') == gid]
if not matches:
try:
meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json 2>/dev/null || true') or 'null')
matches = [s for s in shortcuts if meta.get('shortcut') and s.get('appid') == meta.get('shortcut')]
except (ValueError, AttributeError):
pass
if not matches:
root = posixpath.normpath(directory)
def inside(path):
path = str(path or '').strip().strip('"')
return bool(path) and (posixpath.normpath(path) + '/').startswith(root + '/')
matches = [s for s in shortcuts if inside(s.get('exe')) or inside(s.get('start_dir'))]
if len(matches) > 1:
raise FrameError('ambiguous Steam shortcut for ' + gid)
return int(matches[0]['appid']) if matches else None
def _home():
return ssh('echo $HOME', timeout=30).strip()
def refresh_art(gid=None, artwork=None, fill_only=False):
"""Render and apply Steam artwork for Frame Control's titles (all of them when gid is None).
fill_only: the automatic backfill; it only fills empty Steam slots (see apply_library)."""
if gid is None:
results = []
for t in list_titles():
if not t['frame_control']:
continue
try:
results.append(refresh_art(t['id'], artwork, fill_only))
except Exception as e: # report each title; one failure doesn't stop the rest
results.append({'id': t['id'], 'name': t['name'], 'error': str(e) or type(e).__name__})
return results
with _install_lock:
gid = _check_id(gid)
try:
meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json', timeout=30))
except (FrameError, ValueError):
meta = None
if not isinstance(meta, dict):
raise FrameError(f'{gid} was not installed by Frame Control')
directory = f'{_home()}/{GAMES}/{gid}'
script = frame_android.cached_art_script(directory + '/.frame-artwork') + f"""
icon = ''
for name in ('icon.png', 'logo.png'):
try:
with open(os.path.join({directory!r}, name), 'rb') as f:
icon = base64.b64encode(f.read(12 * 1024 * 1024 + 1)).decode()
break
except OSError:
pass
print(json.dumps({{'artwork': cached, 'icon': icon}}))
"""
found = _json_out(ssh('python3 -', input=script, timeout=60), 'the title artwork')
cached = {k: base64.b64decode(v) for k, v in (found.get('artwork') or {}).items()}
icon = base64.b64decode(found.get('icon') or '') or None
name = str(meta.get('name') or gid)
images, warnings = frame_android.frame_artwork.prepare(name, icon, artwork if artwork is not None else cached)
shortcut = _library_shortcut(gid, directory)
if not shortcut:
raise FrameError(f"Steam hasn't registered {gid} yet; with Steam running on the Frame, refresh again")
result = frame_android.apply_library(shortcut, name, directory + '/.frame-artwork', images,
category='Sideloaded', details={'source': meta.get('source')},
fill_only=fill_only)
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), art_pending=False,
library_warnings=warnings + result.get('warnings', []),
artwork_refreshed=time.strftime('%Y-%m-%dT%H:%M:%S'))
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
return meta
LIST_SCRIPT = r'''
import json, os
root = os.path.expanduser('~/devkit-game')
@@ -741,7 +857,9 @@ def list_titles():
'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'),
'source': str(meta.get('source') or ''), 'size': meta.get('size'),
'installed': meta.get('installed'), 'registered': t.get('settings') is not None,
'frame_control': bool(meta)})
'frame_control': bool(meta),
# Only a title whose install couldn't apply art; older installs have no flag and keep theirs.
'art_pending': bool(meta.get('art_pending')), 'art_missing': bool(meta.get('art_pending'))})
return titles
@@ -772,9 +890,20 @@ def remove(gid):
try:
gid = _check_id(gid)
ensure_utils()
# steamos-delete removes the folder and syncs Steam's shortcuts; its json files stay, so clear them too.
try:
shortcut = _library_shortcut(gid, f'{_home()}/{GAMES}/{gid}')
except (FrameError, ValueError):
shortcut = None
# steamos-delete removes the folder, the shortcut and its Proton prefix (found through the
# shortcut), so it runs first; its json files stay, so clear them too.
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
ssh(f'rm -f {_json_files(gid)}', timeout=30)
# Then tidy what it leaves (artwork, collections); best effort.
if shortcut:
try:
frame_android.shortcut_tool('remove', str(shortcut))
except (FrameError, ValueError):
pass
return {'id': gid}
finally:
_install_lock.release()
@@ -815,8 +944,15 @@ def main():
progress=lambda text, _: print(text + '…', file=sys.stderr))
elif cmd == 'list':
r = list_titles()
if any(t['art_missing'] for t in r):
print('Some titles have no Steam artwork: python3 ui/frame_titles.py refresh-art --all', file=sys.stderr)
elif cmd in ('launch', 'remove') and args:
r = globals()[cmd](args[0])
elif cmd == 'refresh-art' and args:
r = refresh_art(None if args[0] == '--all' else args[0])
if isinstance(r, list) and any('error' in t for t in r):
print(json.dumps(r, indent=1))
raise SystemExit(1)
else:
sys.exit(__doc__)
except FrameError as e:
+446
View File
@@ -0,0 +1,446 @@
"""Touch and direct input for the Steam Frame's panels. Frame Control's server runs this ON the Frame.
gamescope, the Frame's compositor, serves Valve's own input injection: an EIS
socket (libei's server side), which Steam uses to feed it Remote Play input.
This connects to it with libei, which is on the SteamOS image, and points,
clicks, scrolls and types into the panel that has focus in the headset: the
one the wearer last used. No install, and it reaches every panel, on either
of gamescope's X displays (see docs/streaming.md).
python3 frame_touch.py focus print the focused panel as JSON
python3 frame_touch.py panels print every app panel as JSON, and which has focus
python3 frame_touch.py read events on stdin, one JSON object (or list) per line:
{"fx": 0.5, "fy": 0.2, "window": 123, "display": ":1"}
pointer to that fraction of that panel; any event can
name its panel, and goes nowhere if another has focus
{"dx": 4, "dy": -2} pointer by that much
{"button": "left", "down": true} left, right or middle; "down" false releases
{"scroll": [0, 120]} by pixels; positive y scrolls down
{"key": 30, "down": true} a Linux (evdev) key code, as the page maps KeyboardEvent.code
{"text": "hello"} printable ASCII, typed on a US layout
Status goes to stdout, one JSON object per line: {"state": "ready" | "error", ...}.
Standard library only (ctypes for libei), like the rest of what runs on the Frame.
"""
import ctypes
import json
import os
import select
import subprocess
import sys
import time
SOCKET = "/run/user/{uid}/gamescope-0-ei" # filled in on the Frame (Windows has no getuid; the tests import this)
BUTTONS = {"left": 0x110, "right": 0x111, "middle": 0x112} # BTN_LEFT, BTN_RIGHT, BTN_MIDDLE
SHIFT = 42 # KEY_LEFTSHIFT
# Printable ASCII on a US layout: character -> (evdev key code, shifted).
ROWS = [("1234567890-=", "!@#$%^&*()_+", 2), ("qwertyuiop[]", "QWERTYUIOP{}", 16),
("asdfghjkl;'`", 'ASDFGHJKL:"~', 30), ("\\zxcvbnm,./", "|ZXCVBNM<>?", 43)]
ASCII = {" ": (57, False), "\n": (28, False), "\t": (15, False)}
for plain, shifted, first in ROWS:
for i, (a, b) in enumerate(zip(plain, shifted)):
ASCII[a], ASCII[b] = (first + i, False), (first + i, True)
# libei's event types and device capabilities (libei.h, libei 1.4).
EV_CONNECT, EV_DISCONNECT, EV_SEAT_ADDED, EV_DEVICE_ADDED, EV_DEVICE_REMOVED = 1, 2, 3, 5, 6
EV_DEVICE_PAUSED, EV_DEVICE_RESUMED = 7, 8
CAP_POINTER, CAP_ABSOLUTE, CAP_KEYBOARD, CAP_SCROLL, CAP_BUTTON = 1, 2, 4, 16, 32
def say(state, **more):
print(json.dumps({"state": state, **more}), flush=True)
# ---- which panel has focus -----------------------------------------------------
def xprop_root(display, name):
try:
out = subprocess.run(["xprop", "-root", name], env=dict(os.environ, DISPLAY=display),
capture_output=True, text=True, timeout=5).stdout
except (OSError, subprocess.SubprocessError):
return []
values = out.split("=", 1)[1] if "=" in out else ""
return [int(v) for v in values.replace(",", " ").split() if v.isdigit()]
def window_info(display, window):
"""Name and geometry of a window on one X display, or None if it isn't there."""
try:
which = ["-root"] if window == "root" else ["-id", str(window)]
out = subprocess.run(["xwininfo", *which], env=dict(os.environ, DISPLAY=display),
capture_output=True, text=True, timeout=5).stdout
except (OSError, subprocess.SubprocessError):
return None
if "IsViewable" not in out:
return None
info = {}
for line in out.splitlines():
line = line.strip()
if line.startswith("xwininfo: Window id:"):
info["name"] = line.split('"', 1)[1].rsplit('"', 1)[0] if '"' in line else ""
for key, field in (("Absolute upper-left X:", "x"), ("Absolute upper-left Y:", "y"),
("Width:", "width"), ("Height:", "height")):
if line.startswith(key):
info[field] = int(line.split(":", 1)[1])
return info if "width" in info else None
def displays():
return sorted(f":{n[1:]}" for n in os.listdir("/tmp/.X11-unix") if n[1:].isdigit())
def window_pid(display, window):
try:
out = subprocess.run(["xprop", "-id", str(window), "_NET_WM_PID"], env=dict(os.environ, DISPLAY=display),
capture_output=True, text=True, timeout=5).stdout
except (OSError, subprocess.SubprocessError):
return None
value = out.rsplit("=", 1)[-1].strip() if "=" in out else ""
return int(value) if value.isdigit() else None
def locate(window, pid):
"""The display a focusable window is on, with its name and geometry.
Window ids are per X server, so :0 and :1 can both have one; the pid gamescope
lists with it (GAMESCOPE_FOCUSABLE_WINDOWS) tells them apart.
"""
found = []
for display in displays():
info = window_info(display, window)
if info:
found.append((display, info))
if len(found) > 1 and pid:
found = [f for f in found if window_pid(f[0], window) == pid] or found
if not found:
return None
display, info = found[0]
root = window_info(display, "root") or {}
return {"window": window, "display": display, **info,
"root": [root.get("width", info["width"]), root.get("height", info["height"])]}
def focusable():
"""gamescope's focusable windows as (window, app id, pid)."""
t = xprop_root(":0", "GAMESCOPE_FOCUSABLE_WINDOWS")
return [tuple(t[i:i + 3]) for i in range(0, len(t) - 2, 3)]
def focus_display():
"""The display of the focused window, from GAMESCOPE_FOCUS_DISPLAY on :0's root.
gamescope writes the name (":1") as 32-bit items, so its first four bytes land,
little-endian, in the first value: 12602 is 0x313A, ":1" (steamcompmgr.cpp;
seen 2026-09-29).
"""
values = xprop_root(":0", "GAMESCOPE_FOCUS_DISPLAY")
if not values:
return None
name = (values[0] & 0xFFFFFFFF).to_bytes(4, "little").split(b"\0", 1)[0].decode("ascii", "replace")
return name if name[:1] == ":" and name[1:].isdigit() else None
def focus_now():
"""Just which window and display have focus: two property reads, for checking a press."""
window = (xprop_root(":0", "GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
return (window or None, focus_display() if window else None)
def focus():
"""The panel that has focus in the headset: window, display, name and sizes (gamescope
publishes the window and its display on :0's root)."""
window = (xprop_root(":0", "GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
if not window:
return {"window": None}
app, pid = next(((a, p) for w, a, p in focusable() if w == window), (None, None))
display = focus_display()
info = window_info(display, window) if display else None
if info:
root = window_info(display, "root") or {}
panel = {"window": window, "display": display, **info,
"root": [root.get("width", info["width"]), root.get("height", info["height"])]}
else:
panel = locate(window, pid) # no display published: tell them apart by pid
return {**panel, "app": app} if panel else {"window": None}
def panels():
"""Every app panel (gamescope's focusable windows), for watching one that hasn't focus."""
now = focus()
found = []
for window, app, pid in focusable():
panel = locate(window, pid)
if panel and panel["width"] > 1 and panel["height"] > 1 and \
not any(f["window"] == window and f["display"] == panel["display"] for f in found):
panel.pop("root", None)
found.append({**panel, "app": app, "focused": (window, panel["display"]) ==
(now.get("window"), now.get("display"))})
return {"focus": now.get("window"), "focus_display": now.get("display"), "panels": found}
def to_root(panel, fx, fy):
"""A point given as a fraction of the panel, in the root coordinates gamescope's pointer uses.
gamescope fits each panel's window to its display, so a 1920x1080 window on a
1280x720 display takes pointer positions at two thirds scale (verified 2026-09-29).
"""
rw, rh = panel["root"]
w, h = panel["width"], panel["height"]
s = min(rw / w, rh / h)
ox, oy = (rw - w * s) / 2, (rh - h * s) / 2
fx, fy = min(max(fx, 0.0), 1.0), min(max(fy, 0.0), 1.0)
return ox + fx * (w * s - 1), oy + fy * (h * s - 1)
# ---- gamescope's input socket ----------------------------------------------------
def libei():
L = ctypes.CDLL("libei.so.1")
vp, c = ctypes.c_void_p, ctypes
sig = {
"ei_new_sender": (vp, [vp]), "ei_configure_name": (None, [vp, c.c_char_p]),
"ei_setup_backend_socket": (c.c_int, [vp, c.c_char_p]), "ei_get_fd": (c.c_int, [vp]),
"ei_dispatch": (None, [vp]), "ei_get_event": (vp, [vp]), "ei_event_get_type": (c.c_int, [vp]),
"ei_event_unref": (vp, [vp]), "ei_event_get_seat": (vp, [vp]), "ei_event_get_device": (vp, [vp]),
"ei_device_has_capability": (c.c_bool, [vp, c.c_int]), "ei_now": (c.c_uint64, [vp]),
"ei_device_start_emulating": (None, [vp, c.c_uint32]), "ei_device_stop_emulating": (None, [vp]),
"ei_device_frame": (None, [vp, c.c_uint64]),
"ei_device_pointer_motion": (None, [vp, c.c_double, c.c_double]),
"ei_device_pointer_motion_absolute": (None, [vp, c.c_double, c.c_double]),
"ei_device_button_button": (None, [vp, c.c_uint32, c.c_bool]),
"ei_device_scroll_delta": (None, [vp, c.c_double, c.c_double]),
"ei_device_keyboard_key": (None, [vp, c.c_uint32, c.c_bool]),
"ei_unref": (vp, [vp]),
}
for name, (res, args) in sig.items():
f = getattr(L, name)
f.restype, f.argtypes = res, args
return L
class Gamescope:
"""One connection to gamescope's EIS socket and its virtual input device."""
def __init__(self):
self.L = L = libei()
self.ei = L.ei_new_sender(None)
L.ei_configure_name(self.ei, b"Frame Control")
if L.ei_setup_backend_socket(self.ei, SOCKET.format(uid=os.getuid()).encode()) != 0:
raise RuntimeError("Couldn't reach gamescope's input socket. Is the headset on?")
self.fd = L.ei_get_fd(self.ei)
self.device, self.sequence, self.held, self.keys, self.alive = None, 0, set(), set(), True
def pump(self, wait=0.0):
"""Handle gamescope's events; False once it has disconnected."""
select.select([self.fd], [], [], wait)
self.L.ei_dispatch(self.ei)
alive = True
while True:
ev = self.L.ei_get_event(self.ei)
if not ev:
return alive
kind = self.L.ei_event_get_type(ev)
if kind == EV_SEAT_ADDED:
seat = self.L.ei_event_get_seat(ev)
# Variadic, ending in 0 (NULL): ask for everything we send.
self.L.ei_seat_bind_capabilities(ctypes.c_void_p(seat), *map(ctypes.c_int, (
CAP_POINTER, CAP_ABSOLUTE, CAP_BUTTON, CAP_SCROLL, CAP_KEYBOARD, 0)))
elif kind == EV_DEVICE_RESUMED:
device = self.L.ei_event_get_device(ev)
if self.L.ei_device_has_capability(device, CAP_ABSOLUTE):
self.sequence += 1
self.L.ei_device_start_emulating(device, self.sequence)
self.device = device
# Releases that arrived while it was paused were dropped: let go of
# everything now, so the headset and this agent agree nothing is held.
if self.held or self.keys:
self.release_all()
elif kind in (EV_DEVICE_PAUSED, EV_DEVICE_REMOVED):
if self.L.ei_event_get_device(ev) == self.device:
self.device = None
elif kind == EV_DISCONNECT:
self.device, alive, self.alive = None, False, False
self.L.ei_event_unref(ev)
def wait_ready(self, timeout=5):
end = time.time() + timeout
while self.device is None and time.time() < end:
if not self.pump(0.1):
break
if self.device is None:
raise RuntimeError("gamescope closed its input socket" if not self.alive
else "gamescope didn't offer an input device")
def frame(self):
self.L.ei_device_frame(self.device, self.L.ei_now(self.ei))
self.L.ei_dispatch(self.ei)
def move_to(self, x, y):
self.L.ei_device_pointer_motion_absolute(self.device, x, y)
self.frame()
def move_by(self, dx, dy):
self.L.ei_device_pointer_motion(self.device, dx, dy)
self.frame()
def button(self, name, down):
code = BUTTONS[name]
if down == (code in self.held):
return # already in that state
self.L.ei_device_button_button(self.device, code, down)
self.frame()
(self.held.add if down else self.held.discard)(code)
def scroll(self, dx, dy):
self.L.ei_device_scroll_delta(self.device, dx, dy)
self.frame()
def key(self, code, down):
self.L.ei_device_keyboard_key(self.device, code, down)
self.frame()
(self.keys.add if down else self.keys.discard)(code)
# Paced: a burst of keys can reach the app out of order (seen 2026-09-29).
time.sleep(0.008)
def text(self, text):
for ch in text:
if ch not in ASCII:
continue
code, shifted = ASCII[ch]
if shifted:
self.key(SHIFT, True)
self.key(code, True)
self.key(code, False)
if shifted:
self.key(SHIFT, False)
def release_all(self):
"""Let go of every button and key still down, so nothing stays held in the headset."""
for code in list(self.held):
name = next(n for n, c in BUTTONS.items() if c == code)
self.button(name, False)
for code in list(self.keys):
self.key(code, False)
# ---- events from the server --------------------------------------------------------
def events(line):
try:
data = json.loads(line)
except ValueError:
return []
return [e for e in (data if isinstance(data, list) else [data]) if isinstance(e, dict)]
def number(value, limit=100000.0):
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
raise ValueError("not a number")
return max(-limit, min(limit, float(value)))
STALE = [False] # whether the last status said a tap went nowhere
def aimed_elsewhere(event, panel):
"""Whether an event names a panel that isn't the one with focus now."""
if "window" not in event:
return False
return (panel.get("window"), panel.get("display")) != (event.get("window"), event.get("display"))
def apply(gs, event, panel):
"""Send one event; returns the focused panel it checked against (looked up at most once a second).
Positions and presses name the panel they were meant for. If focus has moved to
another panel since, they go nowhere, so a tap can't land on the wrong one;
releases always go, so nothing stays held.
"""
# Moves may use a focus reading up to a second old; anything that acts (a press, key,
# text or scroll) reads it afresh, so it can't land on a panel that took focus since.
acts = any(k in event for k in ("button", "key", "text", "scroll")) and event.get("down") is not False
if "window" in event:
if panel and acts and focus_now() == (panel.get("window"), panel.get("display")):
pass # still the same panel (its geometry is re-read on the usual one-second schedule)
elif acts or not panel or time.time() - panel.get("_at", 0) > 1 or aimed_elsewhere(event, panel):
panel = {**focus(), "_at": time.time()}
stale = aimed_elsewhere(event, panel) if "window" in event else False
if stale and not (event.get("down") is False and ("button" in event or "key" in event)):
say("ready", focus=panel.get("window"), display=panel.get("display"), stale=True) # the page re-syncs
STALE[0] = True
return panel
if STALE[0] and not stale:
# Anything that goes through (a trackpad move names no panel) means caught up: stop re-syncing.
STALE[0] = False
say("ready", focus=(panel or {}).get("window"), display=(panel or {}).get("display"))
if "fx" in event and panel and panel.get("window"):
gs.move_to(*to_root(panel, number(event["fx"], 1), number(event["fy"], 1)))
if "dx" in event or "dy" in event:
gs.move_by(number(event.get("dx", 0), 2000), number(event.get("dy", 0), 2000))
if event.get("button") in BUTTONS:
gs.button(event["button"], event.get("down") is not False)
if isinstance(event.get("scroll"), list) and len(event["scroll"]) == 2:
gs.scroll(number(event["scroll"][0], 5000), number(event["scroll"][1], 5000))
if isinstance(event.get("key"), int) and not isinstance(event["key"], bool) and 0 < event["key"] < 768:
gs.key(event["key"], event.get("down") is not False)
if isinstance(event.get("text"), str):
gs.text(event["text"][:500])
return panel
def main():
if sys.argv[1:] == ["focus"]:
print(json.dumps(focus()))
return 0
if sys.argv[1:] == ["panels"]:
print(json.dumps(panels()))
return 0
try:
gs = Gamescope()
gs.wait_ready()
except (OSError, RuntimeError) as e:
say("error", message=str(e))
return 1
say("ready", focus=focus().get("window"))
stdin, pending, panel = sys.stdin.fileno(), b"", None
try:
while True:
ready, _, _ = select.select([stdin, gs.fd], [], [], 30)
if gs.fd in ready and not gs.pump():
say("error", message="gamescope closed its input socket")
return 1
if stdin not in ready:
continue
chunk = os.read(stdin, 65536)
if not chunk:
return 0 # the server went away
*lines, pending = (pending + chunk).split(b"\n")
for line in lines:
waited = False
for event in events(line):
if gs.device is None and waited:
continue # still paused: don't wait again for each event of this batch
if gs.device is None:
waited = True
# Paused (gamescope can pause the device): wait a moment; drop this
# event if it doesn't come back. Only a disconnect ends the session.
try:
gs.wait_ready(2)
except RuntimeError:
if not gs.alive:
raise
continue
try:
panel = apply(gs, event, panel)
except (ValueError, KeyError, TypeError, OSError):
continue # the server checks events; skip anything odd
except RuntimeError as e:
say("error", message=str(e))
return 1
finally:
if gs.device is not None:
gs.release_all() # never leave a button held down in the headset
if __name__ == "__main__":
sys.exit(main())
+29
View File
@@ -0,0 +1,29 @@
"""Optional software, separate from Frame Control's own controls and HUD.
Public reports are attributed leads, never local verification. Compatibility
reports reuse the existing database with steam:<appid> package keys.
"""
REPORT = 'https://www.roadtovr.com/valve-steam-frame-review/'
UTILITIES = (
(1009850, 'OVR Advanced Settings', False, 'No verified Frame result. Steam edition is paid; the developer also publishes free source/releases.', 'https://github.com/OpenVR-Advanced-Settings/OpenVR-AdvancedSettings'),
(1173510, 'XSOverlay', False, 'Supplied research reports Proton support, but the linked review did not corroborate it on recheck. Untested.', REPORT),
(1068820, 'OVR Toolkit', False, 'Supplied research reports Proton support, but the linked review did not corroborate it on recheck. Untested.', REPORT),
(908520, 'fpsVR', False, 'No Frame-specific result established in the supplied public research. Untested here.', 'https://store.steampowered.com/app/908520/'),
(1494460, 'Desktop+', True, 'Free, developer-published software. No verified Frame result.', 'https://github.com/elvissteinjr/DesktopPlus'),
)
def catalogue(ownership, reports):
owned = {r['id']: r for r in ownership}
out = []
for appid, name, free, note, source in UTILITIES:
local = owned.get(appid, {})
matches = [r for r in reports if r.get('package') == f'steam:{appid}' and r.get('rating') in ('works', 'issues', 'broken')]
latest = max(matches, key=lambda r: r.get('date') or '') if matches else None
out.append({'id': appid, 'name': name, 'free': free, 'owned': local.get('owned'),
'installed': local.get('installed', False), 'frame': local.get('frame', 0),
'status': latest['rating'] if latest else 'untested',
'report': {k: latest.get(k) for k in ('notes', 'date', 'steamos', 'source')} if latest else None,
'note': note, 'source': source,
'canInstall': bool(free or local.get('owned'))})
return {'utilities': out}
+126
View File
@@ -0,0 +1,126 @@
"""Frame Control's optional performance HUD, using Frame platform tools only.
Controls remain blocked pending idle-headset verification; no playspace writes
are exposed. See docs/vr-utilities.md for the probe evidence and follow-up.
"""
import json
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import time
from frame_status import battery, max_temp, performance
ROOT = Path.home() / '.local/share/frame-control/vr'
APPID = '2000250025'
def validate(body):
action = body.get('action')
if action not in ('hud-start', 'hud-stop'):
raise ValueError('VR action must be hud-start or hud-stop; playspace controls are not yet verified')
return action
def save(path, data):
tmp = path.with_suffix('.tmp')
tmp.write_text(json.dumps(data))
os.replace(tmp, path)
def process_identity(pid):
try:
stat = Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split()
args = Path(f'/proc/{pid}/cmdline').read_bytes().split(b'\0')
if b'frame-control-hud' in args and b'xterm' in Path(f'/proc/{pid}/comm').read_bytes():
return stat[19] # field 22, starttime; protects against PID reuse
except OSError:
pass
return None
def panel(action):
path = ROOT / 'hud.json'
saved = json.loads(path.read_text()) if path.exists() else {}
pid = saved.get('pid')
running = bool(pid and saved.get('start') and process_identity(pid) == saved['start'])
if action == 'hud-stop':
if running:
os.kill(pid, signal.SIGTERM) # xterm closes the PTY; child exits on HUP
path.unlink(missing_ok=True)
return {'message': 'HUD closed.'}
if running:
return {'message': 'HUD is already open. Find Frame Control HUD in the SteamVR dashboard.'}
env = {**os.environ, 'DISPLAY': ':0'}
p = subprocess.Popen(['xterm', '-name', 'frame-control-hud', '-title', 'Frame Control HUD',
'-fa', 'Monospace', '-fs', '20', '-geometry', '56x16',
'-bg', '#171d25', '-fg', '#d6d7d8', '-e',
sys.executable, str(ROOT / 'frame_vr.py'), 'hud'],
env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
try:
deadline = time.monotonic() + 8
while time.monotonic() < deadline and p.poll() is None:
tree = subprocess.check_output(['xwininfo', '-display', ':0', '-root', '-tree'],
text=True, timeout=2)
for w in re.findall(r'(0x[0-9a-f]+).*"frame-control-hud"', tree):
owner = subprocess.check_output(['xprop', '-display', ':0', '-id', w, '_NET_WM_PID'],
text=True, timeout=2)
if owner.strip().endswith('= ' + str(p.pid)):
subprocess.run(['xprop', '-display', ':0', '-id', w, '-f', 'STEAM_GAME', '32c',
'-set', 'STEAM_GAME', APPID], check=True, timeout=2)
identity = process_identity(p.pid)
if not identity:
raise RuntimeError('HUD process exited before its panel was ready')
save(path, {'pid': p.pid, 'start': identity})
return {'message': 'HUD opened. In SteamVR, select Frame Control HUD and Float in World or dock it to a controller.'}
time.sleep(.1)
raise RuntimeError('HUD did not create a window; is gamescope running?')
except BaseException:
if p.poll() is None:
p.terminate()
p.wait(timeout=3)
raise
def hud():
def fmt(v, unit=''):
return 'unavailable' if v is None else f'{v:.1f}{unit}'
while True:
p, b = performance(), battery() or {}
lines = ['FRAME CONTROL HUD', '',
'Compositor FPS ' + fmt(p['compositorFps']),
'Compositor period ' + fmt(p['frameMs'], ' ms'),
'Application FPS ' + fmt(p['appFps']),
'Render GPU time ' + fmt(p['gpuMs'], ' ms'),
'Compositor CPU ' + fmt(p['compositorCpuMs'], ' ms'),
'System CPU ' + fmt(p['cpuPercent'], '%'),
'GPU clock ' + fmt(p['gpuMHz'], ' MHz'),
'Hottest sensor ' + fmt(max_temp(), ' C'),
'Battery ' + fmt(b.get('percent'), '%'), '',
time.strftime('Updated %H:%M:%S'), 'Close this window to stop.']
print('\033[2J\033[H' + '\n'.join(lines), flush=True)
time.sleep(2)
def dispatch(body):
import fcntl # Frame only; validation is also imported by Windows hosts
action = validate(body)
ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
with (ROOT / 'control.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
return panel(action)
if __name__ == '__main__':
if sys.argv[1:] == ['hud']:
hud()
else:
try:
print(json.dumps(dispatch(json.load(sys.stdin))))
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as e:
print(json.dumps({'error': str(e)}))
sys.exit(1)
+1072 -37
View File
File diff suppressed because it is too large. Load diff
+380 -13
View File
@@ -44,18 +44,25 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_agent # noqa: E402
import frame_assistant # noqa: E402
import frame_android # noqa: E402
from apk_sources import search as apk_search, SourceError # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_devices # noqa: E402
import frame_steamgriddb
import frame_comfort # noqa: E402
import frame_host # noqa: E402
import frame_link # noqa: E402
import frame_macview # noqa: E402
import frame_media # noqa: E402
import frame_panels # noqa: E402
import frame_report # noqa: E402
import frame_store # noqa: E402
import frame_telemetry # noqa: E402
import frame_titles # noqa: E402
import frame_webinstall # noqa: E402
import frame_vr # noqa: E402
import frame_utilities # noqa: E402
import frame_compat_db # noqa: E402
frame_host.trust_bundled_cas()
@@ -235,7 +242,41 @@ _jobs_lock = threading.Lock()
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"}
def start_job(label, work):
_backfill = {"running": False, "last": 0.0}
_backfill_lock = threading.Lock()
def backfill_art(apps=(), titles=()):
"""Apply art Frame Control couldn't at install time (Steam wasn't running), once Steam is up.
Only entries marked art_pending at install; it fills empty Steam slots and never replaces art or
names the user may have customised. Runs in the background, at most once every five minutes."""
pkgs = [a["package"] for a in apps if a.get("art_pending")]
gids = [t["id"] for t in titles if t.get("art_pending")]
with _backfill_lock:
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
return False
_backfill.update(running=True, last=time.time())
def run():
try:
frame_android.shortcut_tool("list") # Steam isn't up: try again on a later listing
for refresh, key in [(frame_android.refresh_art, p) for p in pkgs] + \
[(frame_titles.refresh_art, g) for g in gids]:
try:
refresh(key, fill_only=True)
except Exception as e:
print(f"artwork backfill for {key}: {e}", file=sys.stderr)
except Exception:
pass
finally:
with _backfill_lock:
_backfill["running"] = False
threading.Thread(target=run, daemon=True).start()
return True
def start_job(label, work, progress=False):
"""Run work() in the background. It returns a dict with a "message"."""
now = time.time()
with _jobs_lock:
@@ -244,14 +285,20 @@ def start_job(label, work):
job = secrets.token_hex(8)
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now}
def report(stage, percent=None):
with _jobs_lock:
_jobs[job].update(stage=stage, percent=percent)
def run():
fields = {}
try:
result = work()
result = work(report) if progress else 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)}
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
except SourceError as e: # already user-readable, and about a store, not the Frame
fields = {"error": str(e)}
except Exception as e:
fields = {"error": f"{type(e).__name__}: {e}"}
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
@@ -322,6 +369,37 @@ def status(_body):
return s
def comfort(body):
try:
frame_comfort.validate(body)
except ValueError as e:
raise Failure(str(e), 400)
# Content-addressed, user-only helper bundle. Desktop and phone use the same
# on-headset state/lock; no listener, service registration or third-party app.
import hashlib
files = {name: (HERE / name).read_text() for name in
("frame_comfort.py", "frame_status.py", "frame_steam.py")}
version = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()[:16]
script = """import json, os, pathlib, subprocess, sys
os.umask(0o077)
files = %r
root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r
root.mkdir(parents=True, exist_ok=True)
for name, source in files.items():
path = root / name
if not path.exists():
tmp = root / (name + '.' + str(os.getpid()))
tmp.write_text(source)
tmp.replace(path)
r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True)
print(r.stdout, end='')
""" % (files, version, json.dumps(body))
out = json.loads(ssh("python3 -", stdin=script, timeout=65))
if out.get("error") and "active" not in out:
raise Failure(out["error"], 409)
return out
def headset_view():
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
# Counts as work: the copy and clean-up must reach the headset that took the capture.
@@ -458,6 +536,7 @@ _stream_proc = None
def stream_command(query):
"""The ffmpeg that streams H.264: the headset view, or one panel's own window (src=panel)."""
q = parse_qs(query)
try:
height = int((q.get("h") or ["720"])[0])
@@ -467,6 +546,16 @@ def stream_command(query):
if height not in STREAM_HEIGHTS or fps not in STREAM_FPS:
raise Failure(f"h must be one of {STREAM_HEIGHTS} and fps one of {STREAM_FPS}", 400)
rate = 3 if height == 720 else 6 # Mbit/s
if q.get("src") == ["panel"]:
# The panel's own pixels (x11grab of its window: gamescope keeps them), fitted
# to the height asked for. It stays still however the wearer moves their head.
window, display = panel_target(q)
return (f"DISPLAY={display} ffmpeg -hide_banner -loglevel error -nostdin -f x11grab -framerate {fps} "
f"-window_id {window} -i {display} "
f"-vf \"scale=-2:'trunc(min({height},ih)/2)*2',format=yuv420p\" -c:v libx264 -preset ultrafast "
f"-tune zerolatency -g {fps * 2} -bf 0 -b:v {rate}M -maxrate {rate}M -bufsize {rate // 2 or 1}M "
f"-x264-params aud=1:repeat-headers=1 -f h264 - & p=$!; "
f"exec >&-; cat >/dev/null; kill $p 2>/dev/null; wait $p")
return (f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
f"ffmpeg -hide_banner -loglevel error -nostdin -f v4l2 -video_size 1920x1080 -i {STREAM_DEVICE} "
f"-vf fps={fps},scale=-2:{height},format=yuv420p -c:v libx264 -preset ultrafast -tune zerolatency "
@@ -519,6 +608,27 @@ def steam(body):
return res
def vr(body):
try:
action = frame_vr.validate(body)
except ValueError as e:
raise Failure(str(e), 400)
sources = {name: (HERE / (name + '.py')).read_text() for name in ('frame_status', 'frame_vr')}
script = "import sys, types, json, os\n"
for name, source in sources.items():
script += f"m = types.ModuleType({name!r}); sys.modules[{name!r}] = m\nexec({source!r}, m.__dict__)\n"
# Only the optional HUD needs files: its terminal child survives this SSH call.
if action == 'hud-start':
script += "m.ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)\n"
for name, source in sources.items():
script += f"p = m.ROOT / {name + '.py'!r}; t = p.with_suffix('.' + str(os.getpid()) + '.tmp')\nt.write_text({source!r}); os.replace(t, p)\n"
script += f"try:\n print(json.dumps(m.dispatch({body!r})))\nexcept Exception as e:\n print(json.dumps({{'error': str(e)}}))\n"
result = json.loads(ssh('python3 -', stdin=script, timeout=20))
if result.get('error'):
raise Failure(result['error'])
return result
def steam_search(query):
q = parse_qs(query)
cc = (q.get("cc") or [""])[0].upper()
@@ -686,7 +796,7 @@ class InputAgent:
"""
def __init__(self, source=HERE / "frame_input_agent.py", packages=None):
self.source, self.proc, self.lock = source, None, threading.Lock()
self.source, self.proc, self.lock, self.write_lock = source, None, threading.Lock(), threading.Lock()
self.packages = kdeconnect_packages() if packages is None else packages
# generation counts stop()s; launching is the generation a launch is under way for.
self.status, self.launching, self.generation = {"state": "off"}, None, 0
@@ -859,8 +969,10 @@ class InputAgent:
self.start()
elif ready and events:
try:
proc.stdin.write((json.dumps(events) + "\n").encode())
proc.stdin.flush()
# One writer at a time: two devices sending at once mustn't tear a line.
with self.write_lock:
proc.stdin.write((json.dumps(events) + "\n").encode())
proc.stdin.flush()
sent = True
except (BrokenPipeError, OSError, ValueError):
pass # _watch reports how it ended
@@ -900,6 +1012,115 @@ def remote_input(body):
return _input.send([input_event(e) for e in events])
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
PANEL_WINDOW = re.compile(r"^\d{1,10}$")
PANEL_DISPLAY = re.compile(r"^:\d{1,2}$")
TOUCH_BUTTONS = ("left", "right", "middle")
def panels():
"""The headset's app panels (window, display, name, size) and which has focus."""
return json.loads(ssh("python3 - panels", stdin=(HERE / "frame_touch.py").read_text(), timeout=20))
def panel_target(q):
window, display = (q.get("window") or [""])[0], (q.get("display") or [""])[0]
if not PANEL_WINDOW.match(window) or not PANEL_DISPLAY.match(display):
raise Failure("window must be a window id and display an X display such as :1", 400)
return window, display
def panel_capture(query):
"""One frame of a panel's own window, as PNG (its pixels, without the room around it)."""
window, display = panel_target(parse_qs(query))
return ssh(f"DISPLAY={display} timeout 10 ffmpeg -hide_banner -loglevel error -nostdin -f x11grab "
f"-window_id {window} -i {display} -frames:v 1 -f image2pipe -c:v png -", timeout=20, text=False)
def touch_event(event):
"""A frame_touch.py event with only the fields it knows, in range."""
if not isinstance(event, dict):
raise Failure("each touch event must be an object", 400)
def num(name, limit):
value = event.get(name)
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
raise Failure(f"{name} must be a number", 400)
return max(-limit, min(limit, round(float(value), 4)))
out = {}
if "fx" in event or "fy" in event:
if "window" not in event:
raise Failure("a position needs the panel's window and display", 400)
out.update(fx=num("fx", 1), fy=num("fy", 1))
# Any event can name the panel it's meant for; the Frame drops it if another has focus.
if "window" in event:
window, display = event.get("window"), event.get("display")
if isinstance(window, bool) or not isinstance(window, int) or window <= 0:
raise Failure("window must be the panel's window id", 400)
if not isinstance(display, str) or not PANEL_DISPLAY.match(display):
raise Failure("display must be an X display such as :1", 400)
out.update(window=window, display=display)
for name in ("dx", "dy"):
if name in event:
out[name] = num(name, INPUT_MOVE_LIMIT)
if "button" in event:
if event["button"] not in TOUCH_BUTTONS:
raise Failure(f"button must be one of {', '.join(TOUCH_BUTTONS)}", 400)
out["button"], out["down"] = event["button"], event.get("down") is not False
if "scroll" in event:
sc = event["scroll"]
if not isinstance(sc, list) or len(sc) != 2:
raise Failure("scroll must be [dx, dy]", 400)
out["scroll"] = [num_value(v, 5000) for v in sc]
if "key" in event:
key = event["key"]
if isinstance(key, bool) or not isinstance(key, int) or not 0 < key < 768:
raise Failure("key must be a Linux key code", 400)
out["key"], out["down"] = key, event.get("down") is not False
if "text" in event:
text = event["text"]
if not isinstance(text, str) or not 0 < len(text) <= INPUT_TEXT_LIMIT:
raise Failure(f"text must be 1 to {INPUT_TEXT_LIMIT} characters", 400)
out["text"] = text
if not set(out) - {"window", "display"}:
raise Failure("touch event has nothing to do", 400)
return out
def num_value(value, limit):
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
raise Failure("scroll values must be numbers", 400)
return max(-limit, min(limit, round(float(value), 2)))
class TouchAgent(InputAgent):
"""frame_touch.py on the Frame, fed events over one long-lived ssh. Nothing to install:
it uses gamescope's own input socket and the libei that's on the image."""
def __init__(self):
super().__init__(source=HERE / "frame_touch.py", packages=[])
def deliver(self, report, force=False):
return ""
def command(self, folder=""):
code = base64.b64encode(self.source.read_bytes()).decode()
return "python3 -u -c " + shlex.quote(
f"import base64;exec(compile(base64.b64decode('{code}'),'frame_touch','exec'))")
_touch = TouchAgent()
def remote_touch(body):
"""{"events": [...]} points, clicks, scrolls and types into the focused panel."""
events = body.get("events", [])
if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT:
raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400)
return _touch.send([touch_event(e) for e in events])
def flatpak(body):
app, action = str(body.get("id", "")), body.get("action")
if not FLATPAK_ID.match(app):
@@ -1001,6 +1222,14 @@ def android(body):
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 == "refresh-art":
if not pkg and not body.get("all"):
raise Failure('choose a package or all apps', 400)
if not body.get('all'):
return start_job('Refresh Steam artwork', lambda: {'apps': [frame_android.refresh_art(pkg)]})
# Everything Frame Control sideloaded: Android apps and devkit titles.
return start_job('Refresh Steam artwork', lambda: {
'apps': frame_android.refresh_art(), 'titles': frame_titles.refresh_art()})
if action in ("launch", "stop"):
m = getattr(frame_android, action)(pkg)
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
@@ -1148,12 +1377,14 @@ def titles(body):
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
busy_thread(_run_title_install, token, entry, opt("name"), opt("exe"), opt("runtime")).start()
return {"message": f"Installing {entry['plan']['source']}", "job": token}
if action not in ("launch", "remove"):
if action not in ("launch", "remove", "refresh-art"):
raise Failure("unknown action", 400)
gid = str(body.get("id", ""))
if not frame_titles.ID_RE.match(gid):
raise Failure("bad title id", 400)
ensure_master()
if action == "refresh-art":
return start_job(f"Steam artwork for {gid}", lambda: {"titles": [frame_titles.refresh_art(gid)]})
try:
m = getattr(frame_titles, action)(gid)
except frame_android.FrameError as e:
@@ -1677,6 +1908,32 @@ def _sweep_one(prefix, d):
pass
# ---- Panel switcher (same helper on the companion and in the headset) ----
def panels_action(body):
action = body.get("action", "list")
script = (HERE / "frame_panels.py").read_text()
if action == "open":
# Installed in the user account so the page can outlive this SSH call.
remote = ('umask 077; mkdir -p ~/.local/share/frame-control/panels && '
'tmp=$(mktemp ~/.local/share/frame-control/panels/install.XXXXXX) && '
'cat > "$tmp" && mv "$tmp" ~/.local/share/frame-control/panels/switcher.py && '
'python3 ~/.local/share/frame-control/panels/switcher.py --open')
elif action == "list":
remote = "python3 -"
elif action == "focus":
key = body.get("key")
if not isinstance(key, str) or not frame_panels.KEY.fullmatch(key):
raise Failure("Choose an open panel.", 400)
remote = "python3 - --focus " + shlex.quote(key)
else:
raise Failure("Unknown panel action", 400)
result = json.loads(ssh(remote, stdin=script, timeout=45))
if "error" in result:
raise Failure(result["error"], 502)
return result
# ---- Our Frame-side media player -----------------------------------------
_MEDIA_LOCK = threading.Lock()
@@ -1812,18 +2069,94 @@ def agent_approval(body):
return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept"))
POST = {"/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
"/api/assistant/chat": assistant_chat, "/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/input": remote_input,
def source_text(body, key, optional=False):
value = body.get(key)
if optional and value in (None, ''):
return None
if not isinstance(value, str) or not value.strip() or len(value) > 2000:
raise Failure('Provide a valid ' + key, 400)
return value.strip()
def source_search(query):
args = parse_qs(query)
q = args.get('q', [''])[0]
vr = args.get('vr', [''])[0]
installable = args.get('installable', [''])[0]
if len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0'):
raise Failure('Invalid search filters', 400)
try:
return apk_search.search(q, vr=None if not vr else vr in ('true', '1'),
source=args.get('source', [None])[0], installable=installable in ('true', '1'))
except SourceError as e:
raise Failure(str(e), 400)
def source_install(body):
source, entry = source_text(body, 'source'), source_text(body, 'id')
version = body.get('version_code')
if version is not None and (type(version) is not int or version < 0):
raise Failure('version_code must be a non-negative integer', 400)
try:
_, selected = apk_search.resolve(source)
if not selected['enabled']:
raise SourceError('This source is disabled')
except SourceError as e:
raise Failure(str(e), 400)
return start_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
def source_manage(body):
action = body.get('action')
try:
if action == 'enable':
if type(body.get('enabled')) is not bool:
raise Failure('enabled must be true or false', 400)
return apk_search.set_enabled(source_text(body, 'source'), body['enabled'])
if action == 'add':
url = source_text(body, 'url')
fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True)
parts = urlparse(url)
if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname or parts.username:
raise Failure('Use an HTTPS or fdroidrepos:// repository URL without credentials', 400)
apk_search.repo_module() # fail now if this build can't manage repositories
def add(): # downloads and verifies the whole index: a job, not a request
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name)
message = 'Added ' + source['name']
if source.get('trust_on_first_use'):
message += '. Trusted on first use: ' + source['fingerprint'].upper()
return {'message': message, 'source': {k: source.get(k) for k in
('id', 'name', 'fingerprint', 'trust_on_first_use')}}
return start_job('Add repository', add)
if action == 'game-data':
package = source_text(body, 'package')
return start_job('Add game data', lambda: apk_search.add_game_data(package))
if action == 'remove':
apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source'))
return {'message': 'Repository removed'}
raise Failure('Unknown source action', 400)
except SourceError as e:
raise Failure(str(e), 400)
POST = {
"/api/vr": vr,
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
"/api/input": remote_input, "/api/touch": remote_touch,
"/api/settings/artwork": frame_steamgriddb.save_settings,
"/api/sources": source_manage, "/api/sources/install": source_install, "/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/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel,
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action,
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action,
"/api/devices": lambda body: devices_post(body)}
# ---- headsets and the connection (frame_devices.py, frame_link.py) ----------
def open_setup(alias, host=None):
@@ -1973,6 +2306,10 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/settings/artwork":
self.send_json(frame_steamgriddb.settings())
elif path == "/artwork-settings.js":
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
elif path == "/assistant":
page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c"))
self.send_bytes(page.encode(), "text/html; charset=utf-8")
@@ -1989,18 +2326,43 @@ class Handler(BaseHTTPRequestHandler):
self.connection_events()
elif path in ("/api/devices", "/api/devices/tailscale", "/api/devices/mdns"):
self.send_json(devices_get(path, url.query))
elif path.startswith("/source-image/"):
from apk_sources import _images
try:
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
except Exception:
self.send_json({"error": "Artwork unavailable"}, 404)
elif path == "/api/sources/details":
args = parse_qs(url.query)
try:
self.send_json(apk_search.details(source_text({k: v[0] for k, v in args.items()}, "source"),
source_text({k: v[0] for k, v in args.items()}, "id")))
except SourceError as e:
raise Failure(str(e), 400)
elif path == "/api/sources":
self.send_json({"sources": apk_search.sources()})
elif path == "/api/search":
self.send_json(source_search(url.query))
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()})
apps = frame_android.list_apps()
backfill_art(apps=apps)
self.send_json({"apps": apps})
elif path == "/api/titles":
ensure_master()
self.send_json({"titles": frame_titles.list_titles()})
titles_list = frame_titles.list_titles()
backfill_art(titles=titles_list)
self.send_json({"titles": titles_list})
elif path == "/api/titles/job":
self.send_json(title_job(url.query))
elif path == "/api/licenses":
self.send_json({"notices": licenses()})
elif path == "/api/panels":
self.send_json(panels())
elif path == "/api/touch":
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
elif path == "/api/input":
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
elif path == "/api/job":
@@ -2012,6 +2374,8 @@ class Handler(BaseHTTPRequestHandler):
"shared": frame_catalog.compat_db.shared()})
elif path == "/api/android/catalog":
self.send_json({"apps": frame_catalog.catalog()})
elif path == "/api/vr/utilities":
self.send_json(frame_utilities.catalogue(steam_frame("utilities")["utilities"], frame_compat_db.load()))
elif path == "/api/macview":
self.send_json(macview_state(parse_qs(url.query)))
elif path == "/api/telemetry":
@@ -2032,6 +2396,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_bytes(*shot_image(url.query))
elif path == "/api/stream":
self.stream_video(url.query)
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["panel"]:
self.send_bytes(panel_capture(url.query), "image/png", headers=[("X-Capture-Source", "panel")])
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
elif path == "/api/screenshot":
@@ -2136,7 +2502,7 @@ class Handler(BaseHTTPRequestHandler):
proc.wait()
errors.seek(0)
err = strip_ansi(errors.read().decode(errors="replace")).strip()
raise Failure(err or "The headset view sent no video for 20 s")
raise Failure(err or "The Frame sent no video for 20 s")
chunk = first
try:
self.send_response(200)
@@ -2276,6 +2642,7 @@ def main():
args = ap.parse_args()
httpd = LoopbackServer(("127.0.0.1", args.port), Handler)
sweep_tmp()
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
frame_telemetry.start()
global LINK, _ONE_SERVER
if not LOCAL: