mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 08:00:32 +02:00
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:
commit
02b9413f78
129 files changed
+12672
-148
No files matched your search
@@ -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
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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'])
|
||||
@@ -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
@@ -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':
|
||||
|
||||
@@ -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
@@ -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__':
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+380
-13
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user