Merge remote-tracking branch 'origin/main' into theatre-media

# Conflicts:
#	ui/server.py
This commit is contained in:
saphid committed 2026-09-29 10:21:27 +10:00
commit ae7f733b90
72 files changed
+47716 -18

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
"""APK sources: every place Frame Control can find and download APKs.
One module per source kind in this package. Each module exposes the same small
interface so ``search.py`` can query them all and show where every result came
from. Python stdlib only; must run on Python 3.9.
Module interface
----------------
KIND = 'sidequest' # stable id of the source kind
def sources() -> list[dict] # configured sources of this kind (a kind can have
# several, e.g. one per F-Droid-format repo)
def search(source, query, limit=50) -> list[dict] # Entry dicts, best first
def details(source, entry_id) -> dict # Entry with 'versions'
def download(source, entry_id, version_code=None) -> dict
# {'apk': local path, 'obb': [paths], 'sha256': hex or None, 'verified': bool}
# Raise SourceError with a user-readable message on failure.
Source dict
-----------
{'id': 'sidequest', 'kind': KIND, 'name': 'SideQuest', 'url': 'https://...',
'builtin': True, 'enabled': True, 'trust': 'official' | 'community' | 'user'}
Entry dict (missing facts are None, never guessed)
----------
{'source': source id, 'id': source-local id, 'package': 'org.example.app' or None,
'name': str, 'summary': str, 'icon': url or None, 'page': url or None,
'version': '1.2', 'version_code': 12, 'min_sdk': 24, 'abis': ['arm64-v8a'],
'vr': True/False/None, 'size': bytes, 'free': True, 'license': 'GPL-3.0' or None,
'updated': 'YYYY-MM-DD', 'downloadable': bool, # False = open page only
'versions': [ {version, version_code, min_sdk, size, updated}, ... ]} # details() only
Rules
-----
- Only sources that distribute APKs with the developer's consent: free listings,
never paid apps re-hosted, no licence or entitlement workarounds.
- Honour each site's terms and robots rules; if automated download isn't allowed,
return entries with 'downloadable': False and a 'page' link instead.
- Cache indexes under frame_host.cache_dir('apk-sources'); send a clear
User-Agent ('FrameControl/<version>'); keep requests modest.
- Tests use recorded fixtures, never the network.
"""
class SourceError(Exception):
"""User-readable failure from a source (network, format, verification)."""
+90 -7
View File
@@ -6,12 +6,18 @@ apps, the lepton-show-flatscreen marker; plus a non-Steam shortcut, so it shows
in the Steam library and gets its own SteamVR panel. Nothing goes through
Lepton Development, which wipes its apps on exit. See docs/apks.md.
Python stdlib only. CLI: python3 ui/frame_android.py {info APK|versions APK-or-PKG|install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
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
"""
import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
import frame_apk
import frame_host
import tempfile
import zipfile
from frame_apk_vr import add_launcher_category
from frame_apk_sign import repack
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
@@ -20,6 +26,13 @@ COMPAT = '.local/share/Steam/steamapps/compatdata'
SHADERS = '.local/share/Steam/steamapps/shadercache'
LAUNCHER = os.path.join(ROOT, 'frame', 'android', 'lepton-app.sh')
SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py')
# OpenXR API layer that lets OpenXR 1.1 apps run on SteamVR's 1.0-only Android
# runtime (frame/openxr-compat, docs/vr-apks.md). Injected into VR APKs.
XR_COMPAT = os.path.join(ROOT, 'frame', 'openxr-compat')
XR_COMPAT_FILES = {
'assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json': 'XrApiLayer_FRAME_compat.json',
'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so',
}
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
@@ -65,6 +78,22 @@ def apk_info(path):
raise FrameError(f'{os.path.basename(path)}: {e}')
def xr_compat_files(apk_path):
"""The layer's files to add, or {} if the APK has no OpenXR loader or already has the layer."""
with zipfile.ZipFile(apk_path) as z:
names = set(z.namelist())
if 'lib/arm64-v8a/libopenxr_loader.so' not in names or names & set(XR_COMPAT_FILES):
return {}
add = {}
for entry, rel in XR_COMPAT_FILES.items():
try:
with open(os.path.join(XR_COMPAT, rel), 'rb') as f:
add[entry] = f.read()
except OSError:
raise FrameError("the OpenXR compatibility layer isn't built; run frame/openxr-compat/build.sh")
return add
def check_installable(info):
if info['min_sdk'] and info['min_sdk'] > 30:
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
@@ -111,7 +140,7 @@ def _write_meta(d, meta):
install_hooks = []
def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None):
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None):
start, info = time.time(), None
try:
info = apk_info(apk_path)
@@ -121,8 +150,20 @@ def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None):
pkg = info['package']
if not PKG_RE.match(pkg):
raise FrameError(f'unexpected package name {pkg!r}')
if flatscreen is None:
flatscreen = not info['vr']
# VR apps get the OpenXR compatibility layer unless told otherwise; it only
# changes calls SteamVR would otherwise reject.
add = xr_compat_files(apk_path) if (info['vr'] if xr_compat is None else xr_compat) else {}
with _install_lock:
meta = _install(apk_path, info, pkg, flatscreen, name, source)
if add or info['repairable']:
with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
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))
else:
meta = _install(apk_path, info, pkg, flatscreen, name, source)
except FrameError as e:
_after_install(info, None, e, start)
raise
@@ -163,6 +204,8 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
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)}
_write_meta(d, meta)
@@ -293,19 +336,59 @@ def probe(pkg, wait=20):
'container_up': ctr in running_instances()}
def patch(src, dst, add=None):
try:
info = apk_info(src)
with zipfile.ZipFile(src) as z:
original = frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST)
manifest = add_launcher_category(original) if info['repairable'] else original
if not info['launchable'] and not info['repairable']:
raise FrameError('APK has no MAIN/LAUNCHER activity that Frame Control can patch')
repack(src, dst, replace={'AndroidManifest.xml': manifest}, add=add)
result = apk_info(dst)
result.pop('icon_png', None)
result['patched'] = (['launcher'] if manifest != original else []) + \
(['openxr-compat'] if add and set(XR_COMPAT_FILES) <= set(add) else [])
return result
except (OSError, ValueError, IndexError, struct.error, zipfile.BadZipFile, frame_apk.ApkError) as e:
raise FrameError(str(e)) from e
def main():
cmd, *args = sys.argv[1:] or ['help']
try:
if cmd in ('info', 'versions'):
import frame_apk_versions
if cmd == 'info':
print(frame_apk_versions.describe(apk_info(args[0])))
info = apk_info(args[0])
print(frame_apk_versions.describe(info))
fix = '; Frame Control adds the LAUNCHER entry Lepton needs' if info.get('repairable') else ''
if info.get('vr') or fix:
print(('VR app' if info.get('vr') else 'Android app') + fix)
for note in info.get('vr_issues', []):
print(note)
return
info = apk_info(args[0]) if os.path.isfile(args[0]) or args[0].lower().endswith('.apk') else None
r = frame_apk_versions.alternatives(
info['package'] if info else args[0], info.get('version_code') if info else None)
elif cmd == 'install':
r = install(args[0], flatscreen='--vr' not in args)
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 == 'patch':
import argparse
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
parser.add_argument('src')
parser.add_argument('dst')
parser.add_argument('--add', action='append', default=[], metavar='NAME=PATH')
opts = parser.parse_args(args)
additions = {}
for item in opts.add:
if '=' not in item:
raise FrameError('--add requires NAME=PATH')
entry, path = item.split('=', 1)
with open(path, 'rb') as f:
additions[entry] = f.read()
r = patch(opts.src, opts.dst, additions)
elif cmd == 'list':
r = list_apps()
elif cmd in ('launch', 'stop', 'probe'):
@@ -314,7 +397,7 @@ def main():
r = remove(args[0], keep_data='--keep-data' in args)
else:
sys.exit(__doc__)
except FrameError as e:
except (FrameError, OSError) as e:
sys.exit(f'error: {e}')
print(json.dumps(r, indent=1))
+7 -2
View File
@@ -10,7 +10,8 @@ import zipfile
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion'}
0x01010024: 'value', 0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion',
0x01010202: 'targetActivity'}
T_REF, T_STRING, T_INT_DEC, T_INT_HEX = 0x01, 0x03, 0x10, 0x11
# APKs can come from websites (install links), so nothing read from one may be
# unbounded. zipfile stops at a member's declared size, so checking it is enough.
@@ -215,8 +216,11 @@ def apk_info(path):
if 'AndroidManifest.xml' not in names:
raise ApkError('not an APK: no AndroidManifest.xml')
try:
elements = manifest_elements(_read(z, 'AndroidManifest.xml', MAX_MANIFEST))
manifest_data = _read(z, 'AndroidManifest.xml', MAX_MANIFEST)
elements = manifest_elements(manifest_data)
res = Resources(_read(z, 'resources.arsc', MAX_ARSC) if 'resources.arsc' in names else b'')
from frame_apk_vr import detection
vr_info = detection(manifest_data, names)
except (struct.error, IndexError, zipfile.BadZipFile) as e:
raise ApkError(f'could not read the APK manifest: {e}')
tags = {}
@@ -236,6 +240,7 @@ def apk_info(path):
'min_sdk': min_sdk[1] if min_sdk and min_sdk[0] in (T_INT_DEC, T_INT_HEX) else None,
'icon_png': None,
}
info.update(vr_info)
try:
info['icon_png'] = _icon_png(z, names, _icons(app.get('icon'), res))
except Exception: # noqa: BLE001 - any unreadable icon just means no icon
+361
View File
@@ -0,0 +1,361 @@
"""Lossless ZIP repacking and APK v2 RSA/SHA-256 signing, Python 3.9 stdlib.
Spec: https://source.android.com/docs/security/features/apksigning/v2
Sections: APK Signing Block; APK Signature Scheme v2 Block; Integrity-protected
contents; Verification. No verity algorithm is used, so no verity padding.
"""
import hashlib
import io
import json
import math
import os
from pathlib import Path
import re
import secrets
import struct
import tempfile
import zipfile
import zlib
import frame_host
MAGIC = b'APK Sig Block 42'
V2 = 0x7109871a
ALG = 0x0103
SHA256_DER = bytes.fromhex('3031300d060960864801650304020105000420')
def u32(n):
return struct.pack('<I', n)
def lp(b):
return u32(len(b)) + b
def der(tag, b):
n = len(b)
length = bytes([n]) if n < 128 else bytes([128 + (n.bit_length() + 7) // 8]) + n.to_bytes((n.bit_length() + 7) // 8, 'big')
return bytes([tag]) + length + b
def integer(n):
b = n.to_bytes((n.bit_length() + 7) // 8 or 1, 'big')
return der(2, (b'\0' if b[0] & 128 else b'') + b)
def sequence(*items):
return der(0x30, b''.join(items))
RSA_ALG = bytes.fromhex('300d06092a864886f70d0101010500')
CERT_ALG = bytes.fromhex('300d06092a864886f70d01010b0500')
def public_key(key):
return sequence(RSA_ALG, der(3, b'\0' + sequence(integer(key['n']), integer(key['e']))))
def encoded_hash(data, size):
digest = SHA256_DER + hashlib.sha256(data).digest()
return b'\0\1' + b'\xff' * (size - len(digest) - 3) + b'\0' + digest
def rsa_sign(data, key):
size = (key['n'].bit_length() + 7) // 8
return pow(int.from_bytes(encoded_hash(data, size), 'big'), key['d'], key['n']).to_bytes(size, 'big')
def rsa_verify(data, sig, n, e):
size = (n.bit_length() + 7) // 8
if len(sig) != size or int.from_bytes(sig, 'big') >= n:
raise ValueError('invalid RSA signature size/value')
actual = pow(int.from_bytes(sig, 'big'), e, n).to_bytes(size, 'big')
if actual != encoded_hash(data, size):
raise ValueError('RSA signature mismatch')
def certificate(key):
name = sequence(der(0x31, sequence(bytes.fromhex('0603550403'), der(12, b'Frame Control APK signer'))))
validity = sequence(der(0x17, b'200101000000Z'), der(0x18, b'21200101000000Z'))
tbs = sequence(der(0xa0, integer(2)), integer(1), CERT_ALG, name, validity, name, public_key(key))
return sequence(tbs, CERT_ALG, der(3, b'\0' + rsa_sign(tbs, key)))
def _prime(bits):
small = (3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47)
while True:
n = secrets.randbits(bits) | (3 << (bits - 2)) | 1
if any(n % p == 0 for p in small) or (n - 1) % 65537 == 0:
continue
d, s = n - 1, 0
while d % 2 == 0:
d //= 2
s += 1
for _ in range(40): # Miller-Rabin error bound <= 2^-80
x = pow(secrets.randbelow(n - 3) + 2, d, n)
if x in (1, n - 1):
continue
for _ in range(s - 1):
x = pow(x, 2, n)
if x == n - 1:
break
else:
break
else:
return n
def signing_key(path=None):
"""Persistent identity in app data, never an evictable cache. Atomic publication.
Hard-linking a fully written private temp file prevents concurrent first-use
callers from selecting different identities or reading a partial key.
"""
path = Path(path) if path is not None else frame_host.data_dir('apk-signing-key.json')
if not path.exists():
p, q = _prime(1024), _prime(1024)
while q == p:
q = _prime(1024)
key = {'n': p * q, 'e': 65537, 'd': pow(65537, -1, math.lcm(p - 1, q - 1))}
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix='.apk-key-', dir=str(path.parent))
try:
with os.fdopen(fd, 'w') as f:
json.dump(key, f)
f.flush()
os.fsync(f.fileno())
try:
os.link(tmp, path) # never replaces a key another process wrote first
except FileExistsError:
pass
except OSError: # no hard links (FAT/exFAT): plain rename
if not path.exists():
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
os.chmod(path, 0o600) # Windows ignores this; the per-user app-data folder is the protection there
key = json.loads(path.read_text())
if key['n'].bit_length() != 2048 or key['e'] != 65537:
raise ValueError(f'invalid cached APK signing key; delete {path} to make a new one '
'(re-signed apps then need reinstalling)')
rsa_verify(b'key check', rsa_sign(b'key check', key), key['n'], key['e'])
return key
def _eocd(data):
# ZIP comments can contain the EOCD signature; accept only an exact EOF fit.
for at in range(len(data) - 22, max(-1, len(data) - 65558), -1):
if data[at:at + 4] == b'PK\5\6' and at + 22 + struct.unpack_from('<H', data, at + 20)[0] == len(data):
disk, cd_disk, count_disk, count, size, cd = struct.unpack_from('<HHHHII', data, at + 4)
if disk or cd_disk or count_disk != count or count == 65535 or cd + size != at:
raise ValueError('multi-disk/ZIP64 or invalid APK directory')
return at, cd
raise ValueError('missing ZIP end record')
def content_digest(sections):
chunks = []
for section in sections:
for off in range(0, len(section), 1024 * 1024):
part = section[off:off + 1024 * 1024]
chunks.append(hashlib.sha256(b'\xa5' + u32(len(part)) + part).digest())
return hashlib.sha256(b'\x5a' + u32(len(chunks)) + b''.join(chunks)).digest()
def sign_apk(data, key):
eo, cd = _eocd(data)
digest = content_digest((memoryview(data)[:cd], memoryview(data)[cd:eo], data[eo:]))
signed = lp(lp(u32(ALG) + lp(digest))) + lp(lp(certificate(key))) + lp(b'')
signer = lp(signed) + lp(lp(u32(ALG) + lp(rsa_sign(signed, key)))) + lp(public_key(key))
value = lp(lp(signer))
pair = struct.pack('<Q', 4 + len(value)) + u32(V2) + value
size = len(pair) + 24
block = struct.pack('<Q', size) + pair + struct.pack('<Q', size) + MAGIC
end = bytearray(data[eo:])
struct.pack_into('<I', end, 16, cd + len(block))
return data[:cd] + block + data[cd:eo] + end
def _parts(data):
result, off = [], 0
while off < len(data):
if off + 4 > len(data):
raise ValueError('truncated length prefix')
size = struct.unpack_from('<I', data, off)[0]
off += 4
if off + size > len(data):
raise ValueError('length prefix outside block')
result.append(data[off:off + size])
off += size
return result
def _der_parts(data):
result, off = [], 0
while off < len(data):
start = off
tag, size = data[off:off + 2]
off += 2
if size & 128:
count = size & 127
if not count or count > 4:
raise ValueError('invalid DER length')
size = int.from_bytes(data[off:off + count], 'big')
off += count
if off + size > len(data):
raise ValueError('truncated DER')
result.append((tag, data[off:off + size], data[start:off + size]))
off += size
return result
def _cert_key(cert):
outer = _der_parts(cert)
if len(outer) != 1 or outer[0][0] != 0x30:
raise ValueError('invalid certificate')
fields = _der_parts(outer[0][1])
tbs = _der_parts(fields[0][1])
spki = tbs[6 if tbs[0][0] == 0xa0 else 5][2]
pub = _der_parts(_der_parts(spki)[0][1])
if pub[0][2] != RSA_ALG or pub[1][1][:1] != b'\0':
raise ValueError('certificate is not RSA')
numbers = _der_parts(_der_parts(pub[1][1][1:])[0][1])
n, e = [int.from_bytes(item[1], 'big') for item in numbers]
return n, e, spki
def verify(path):
"""Verify this v2-only format; return True or raise ValueError on corruption.
A valid signature establishes integrity, not trust in the APK publisher.
"""
data = Path(path).read_bytes()
try:
eo, cd = _eocd(data)
if data[cd - 16:cd] != MAGIC:
raise ValueError('no APK signing block')
size = struct.unpack_from('<Q', data, cd - 24)[0]
start = cd - size - 8
if start < 0 or struct.unpack_from('<Q', data, start)[0] != size:
raise ValueError('invalid signing block size')
off, values = start + 8, []
while off < cd - 24:
length = struct.unpack_from('<Q', data, off)[0]
if length < 4 or off + 8 + length > cd - 24:
raise ValueError('invalid signing pair')
ident = struct.unpack_from('<I', data, off + 8)[0]
if ident == V2:
values.append(data[off + 12:off + 8 + length])
off += 8 + length
if off != cd - 24 or len(values) != 1:
raise ValueError('missing or duplicate v2 signer block')
end = bytearray(data[eo:])
struct.pack_into('<I', end, 16, start)
digest = content_digest((memoryview(data)[:start], memoryview(data)[cd:eo], end))
wrappers = _parts(values[0])
if len(wrappers) != 1:
raise ValueError('invalid signers sequence')
signers = _parts(wrappers[0])
if not signers:
raise ValueError('no signers')
for signer in signers:
signed, signatures, pub = _parts(signer)
digests, certs, attrs = _parts(signed)
cert = _parts(certs)[0]
n, e, cert_pub = _cert_key(cert)
if cert_pub != pub:
raise ValueError('public key differs from certificate')
sigs, digs = _parts(signatures), _parts(digests)
if len(sigs) != 1 or len(digs) != 1 or sigs[0][:4] != u32(ALG) or digs[0][:4] != u32(ALG):
raise ValueError('unsupported signature algorithm')
if _parts(digs[0][4:]) != [digest]:
raise ValueError('APK content digest mismatch')
sig = _parts(sigs[0][4:])
if len(sig) != 1:
raise ValueError('invalid signature sequence')
rsa_verify(signed, sig[0], n, e)
return True
except (IndexError, struct.error, OverflowError) as exc:
raise ValueError('malformed APK signature: ' + str(exc)) from exc
def repack(src, dst, replace=None, add=None, sign=True):
"""Copy raw compressed members; reconstruct ZIP headers without descriptors.
Reject ZIP64/encrypted archives. Output is atomically replaced after signing.
"""
replace, add = dict(replace or {}), dict(add or {})
central, output = [], io.BytesIO()
with open(src, 'rb') as raw, zipfile.ZipFile(raw) as archive:
names = archive.namelist()
if len(set(names)) != len(names):
raise ValueError('duplicate ZIP member names')
if set(replace) - set(names) or set(add) & set(names) or set(add) & set(replace):
raise ValueError('replace must exist and add must be new')
items = archive.infolist() + [zipfile.ZipInfo(name) for name in add]
for info in items:
name = info.filename
if re.match(r'^META-INF/(?:[^/]+\.(?:SF|RSA|EC|DSA)|MANIFEST\.MF)$', name, re.I):
continue
if info.flag_bits & 1 or info.compress_type not in (0, 8):
raise ValueError('unsupported ZIP encryption/compression')
method = info.compress_type
content = add.get(name) if name in add else replace.get(name)
if content is None:
raw.seek(info.header_offset)
header = raw.read(30)
if header[:4] != b'PK\3\4':
raise ValueError('invalid local ZIP header')
nl, el = struct.unpack_from('<HH', header, 26)
raw.seek(nl + el, 1)
compressed = raw.read(info.compress_size)
crc, usize = info.CRC, info.file_size
if len(compressed) != info.compress_size:
raise ValueError('truncated ZIP member')
else:
crc, usize = zlib.crc32(content), len(content)
if method == 8:
compressor = zlib.compressobj(6, zlib.DEFLATED, -15)
compressed = compressor.compress(content) + compressor.flush()
else:
compressed = content
encoded = name.encode('utf-8')
offset = output.tell()
align = 16384 if name.endswith('.so') else 4
needs_alignment = method == 0 or name.endswith('.so')
padding = (-(offset + 30 + len(encoded) + 6) % align) if needs_alignment else 0
# Android zipalign extra: alignment (uint16), then padding bytes.
extra = struct.pack('<HHH', 0xd935, padding + 2, align) + bytes(padding) if needs_alignment else b''
dt = info.date_time
dos_time = (dt[3] << 11) | (dt[4] << 5) | (dt[5] // 2)
dos_date = ((dt[0] - 1980) << 9) | (dt[1] << 5) | dt[2]
csize = len(compressed)
if max(offset, csize, usize) >= 0xffffffff:
raise ValueError('ZIP64 APKs are unsupported')
output.write(struct.pack('<IHHHHHIIIHH', 0x04034b50, 20, 0x800, method, dos_time, dos_date,
crc, csize, usize, len(encoded), len(extra)) + encoded + extra + compressed)
central.append(struct.pack('<IHHHHHHIIIHHHHHII', 0x02014b50, 0x314, 20, 0x800, method,
dos_time, dos_date, crc, csize, usize, len(encoded), 0, len(info.comment),
0, info.internal_attr, info.external_attr, offset) + encoded + info.comment)
cd = output.tell()
directory = b''.join(central)
if len(central) >= 65535 or cd + len(directory) >= 0xffffffff:
raise ValueError('ZIP64 APKs are unsupported')
output.write(directory)
output.write(struct.pack('<IHHHHIIH', 0x06054b50, 0, 0, len(central), len(central), len(directory), cd, len(archive.comment)) + archive.comment)
data = output.getvalue()
if sign:
data = sign_apk(data, signing_key())
dst = Path(dst)
fd, tmp = tempfile.mkstemp(prefix='.apk-', dir=str(dst.parent))
try:
with os.fdopen(fd, 'wb') as f:
f.write(data)
if sign:
verify(tmp)
os.replace(tmp, dst)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
+128
View File
@@ -0,0 +1,128 @@
"""Binary-manifest VR inspection and minimal launcher repair (stdlib only)."""
import struct
import frame_apk
MAIN = 'android.intent.action.MAIN'
LAUNCHER = 'android.intent.category.LAUNCHER'
VR = {'com.oculus.intent.category.VR', 'org.khronos.openxr.intent.category.IMMERSIVE_HMD'}
def inspect(data):
elements = iter(frame_apk.manifest_elements(data))
stack, filters, samsung = [], [], False
current, owner, package = None, None, ''
for kind, hs, off, size in frame_apk._chunks(data, 8, len(data)):
if kind == 0x0102:
tag, attrs = next(elements)
value = attrs.get('name', (None, None, None))[2]
if tag == 'manifest':
package = attrs.get('package', (None, None, None))[2] or ''
if tag in ('activity', 'activity-alias'):
owner = attrs.get('targetActivity', (None, None, None))[2] if tag == 'activity-alias' else value
if owner and '.' not in owner: # PackageParser.buildClassName: bare names are relative too
owner = '.' + owner
owner = package + owner if owner and owner.startswith('.') else owner
if tag == 'intent-filter' and stack and stack[-1] in ('activity', 'activity-alias'):
current = {'actions': set(), 'categories': set(), 'templates': [], 'alias': stack[-1] == 'activity-alias', 'activity': owner}
if current is not None and stack and stack[-1] == 'intent-filter':
if tag == 'action':
current['actions'].add(value)
if tag == 'category':
current['categories'].add(value)
current['templates'].append((off, size, hs))
if tag == 'meta-data' and stack and stack[-1] == 'application':
samsung |= value == 'com.samsung.android.vr.application.mode' and attrs.get('value', (0, 0, None))[2] == 'vr_only'
stack.append(tag)
elif kind == 0x0103 and stack:
tag = stack.pop()
if tag == 'intent-filter' and current is not None:
current['end'] = off
filters.append(current)
current = None
mains = [f for f in filters if MAIN in f['actions']]
vr_filters = [f for f in mains if VR & f['categories']]
# Lepton's apk-info-extractor ignores <activity-alias>; Godot 4 puts LAUNCHER only there.
real = [f for f in mains if not f['alias']]
targets = [f for f in real if VR & f['categories']]
if not targets and any(LAUNCHER in f['categories'] or VR & f['categories'] for f in mains if f['alias']):
aimed = {f['activity'] for f in mains if f['alias'] and (LAUNCHER in f['categories'] or VR & f['categories'])}
targets = [f for f in real if f['templates']] # the patch copies an existing <category>
targets = [f for f in targets if f['activity'] in aimed] or targets
launchable = any(LAUNCHER in f['categories'] for f in real)
return {'launchable': launchable, 'repairable': not launchable and bool(targets),
'vr_activity': bool(vr_filters), 'vr': bool(vr_filters) or samsung}, targets
def _append_string(chunk, text):
_, hs, size, count, styles, flags, start, style_start = struct.unpack_from('<HHIIIIII', chunk)
strings_end = style_start or size
encoded = text.encode('utf-8' if flags & 0x100 else 'utf-16-le')
# LAUNCHER is short enough for both single-unit length encodings.
new = (bytes([len(text), len(encoded)]) + encoded + b'\0' if flags & 0x100
else struct.pack('<H', len(text)) + encoded + b'\0\0')
string_data = chunk[start:strings_end] + new
string_data += bytes(-len(string_data) % 4)
header = bytearray(chunk[:hs])
new_start = start + 4
new_styles = new_start + len(string_data) if style_start else 0
body = (chunk[hs:hs + count * 4] + struct.pack('<I', strings_end - start)
+ chunk[hs + count * 4:start] + string_data + (chunk[style_start:] if style_start else b''))
struct.pack_into('<IIIII', header, 8, count + 1, styles, flags & ~1, new_start, new_styles)
struct.pack_into('<I', header, 4, len(header) + len(body))
return bytes(header) + body, count
def add_launcher_category(axml_bytes):
info, filters = inspect(axml_bytes)
if info['launchable']:
return axml_bytes
if not filters:
raise frame_apk.ApkError('no activity with a MAIN intent filter that Frame Control can patch')
target = filters[0]
chunks = list(frame_apk._chunks(axml_bytes, 8, len(axml_bytes)))
pool = next(c for c in chunks if c[0] == 1)
_, _, po, ps = pool
new_pool, index = _append_string(axml_bytes[po:po + ps], LAUNCHER)
off, size, hs = target['templates'][0]
start = bytearray(axml_bytes[off:off + size])
attr_start, attr_size, count = struct.unpack_from('<HHH', start, hs + 8)
strings = frame_apk._string_pool(axml_bytes, po)
resmap = []
for kind, header_size, offset, chunk_size in chunks:
if kind == 0x180:
resmap = struct.unpack_from('<%dI' % ((chunk_size - header_size) // 4),
axml_bytes, offset + header_size)
for i in range(count):
a = hs + attr_start + i * attr_size
name = struct.unpack_from('<I', start, a + 4)[0]
if (name < len(resmap) and resmap[name] == 0x01010003) or strings[name] == 'name':
struct.pack_into('<I', start, a + 8, index)
struct.pack_into('<HBBI', start, a + 12, 8, 0, 3, index)
break
else:
raise frame_apk.ApkError('category has no name attribute')
end = struct.pack('<HHI', 0x0103, hs, hs + 8) + start[8:hs] + start[hs:hs + 8]
result = bytearray(axml_bytes[:8])
for _, _, off, size in chunks:
if off == target['end']:
result += start + end
result += new_pool if off == po else axml_bytes[off:off + size]
struct.pack_into('<I', result, 4, len(result))
result = bytes(result)
if not inspect(result)[0]['launchable']:
raise frame_apk.ApkError('launcher repair failed verification')
return result
def detection(data, names):
info, _ = inspect(data)
issues = []
if 'lib/arm64-v8a/libvrapi.so' in names:
issues.append("Uses Meta's legacy VrApi, which the Frame doesn't have; it won't run.")
if any(n.endswith('/libovrplatformloader.so') for n in names):
issues.append("Uses Meta's platform SDK; if it checks your Quest store licence it will quit.")
if 'lib/arm64-v8a/libopenxr_loader.so' in names:
info['vr'] = True
issues.append('Uses OpenXR (good).')
info['vr_issues'] = issues
return info
+456
View File
@@ -0,0 +1,456 @@
"""Mac in the headset: stream Mac windows or displays into the Steam Frame as
panels you can place anywhere, with the laser, wheel and keys driving the Mac.
Runs on the Mac, inside Frame Control's server. The pieces:
- mac/bin/frame-mac-view (Swift, built from mac/frame-mac-view): captures with
ScreenCaptureKit, encodes with VideoToolbox, serves ui/mac-view.html and one
WebSocket per stream on 127.0.0.1, and plays input back with CGEvent.
- An `ssh -R` tunnel, so the Frame reaches the agent on its own 127.0.0.1.
Every request carries a random token, so other programs on the Frame
(Android apps included) can't watch or drive the Mac.
- A Chromium app window on the Frame per stream, on gamescope's X display
with its own STEAM_GAME id, which makes it its own SteamVR panel (see
docs/panels.md). Chromium XR (~/chromium-xr) is preferred because it's
built with H.264; Flathub Chromium is the fallback.
Stdlib only. MacView gets a plain ssh argv for the tunnel (its own
connection) and the server's `run(remote, stdin=, timeout=)` for commands.
"""
import json
import os
import re
import secrets
import shutil
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
import zlib
from pathlib import Path
from urllib.parse import quote, urlencode # %20, not +: the agent's URLComponents keeps +
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
PAGE = HERE / "mac-view.html"
SOURCES = ROOT / "mac" / "frame-mac-view"
AGENT = Path(os.environ.get("FRAME_MAC_VIEW") or ROOT / "mac" / "bin" / "frame-mac-view")
REMOTE_PORTS = range(47900, 47920)
SUPPORTED = sys.platform == "darwin"
# Stream settings by name: long side in pixels, frames per second, H.264 bits
# per pixel per frame, codec. JPEG is for a Frame browser without H.264.
QUALITY = {
"sharp": {"max": 2560, "fps": 60, "bpp": 0.14, "codec": "h264"},
"balanced": {"max": 1920, "fps": 60, "bpp": 0.1, "codec": "h264"},
"light": {"max": 1280, "fps": 30, "bpp": 0.08, "codec": "h264"},
"compatible": {"max": 1280, "fps": 20, "bpp": 0.1, "codec": "jpeg"},
}
# Extra Chromium flags for viewers (see docs/mac-in-headset.md, "Measuring").
BROWSER_FLAGS = []
# Viewer windows are fitted inside a panel's size. gamescope made a 1280x720
# request 1920x1080 anyway (verified 2026-09-28, build 20260925.6191901).
PANEL_BOX = (1920, 1080)
# Opens one viewer on gamescope's X display and gives its window its own panel
# id. Args: appid url width height tag [browser flags...]. The page puts "[tag]" in its title at
# once, which is how its X window is found (Chromium may hand the URL to an
# instance that's already running, so there's no process to follow).
LAUNCH = r"""set -u
appid=$1 url=$2 w=$3 h=$4 tag=$5
shift 5
export DISPLAY=:0 LC_ALL=C.UTF-8
unset WAYLAND_DISPLAY
if ! xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1; then
echo "The headset isn't showing anything (gamescope's display :0 isn't up). Wake it and try again." >&2
exit 2
fi
common=(--ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-default-browser-check
--password-store=basic --disable-session-crashed-bubble --noerrdialogs --disable-infobars
--disable-features=Translate,MediaRouter --autoplay-policy=no-user-gesture-required
"--window-size=$w,$h" "$@" "--app=$url")
if [ -x "$HOME/chromium-xr/chrome" ]; then
cmd=("$HOME/chromium-xr/chrome" "--user-data-dir=$HOME/.local/share/frame-control/mac-view" "${common[@]}")
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
cmd=(flatpak run org.chromium.Chromium
"--user-data-dir=$HOME/.var/app/org.chromium.Chromium/data/frame-mac-view" "${common[@]}")
else
echo "NO_BROWSER"
exit 3
fi
log=/tmp/frame-mac-view.log
setsid nohup "${cmd[@]}" >>"$log" 2>&1 </dev/null &
for _ in $(seq 1 60); do
sleep 0.5
# xprop, not xwininfo: in a C locale xwininfo can't print a non-ASCII title
# at all, while xprop escapes those bytes and leaves the ASCII tag readable.
for win in $(xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}'); do
xprop -id "$win" _NET_WM_NAME WM_NAME 2>/dev/null | grep -qF "[$tag]" || continue
if xprop -id "$win" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null; then
echo "panel valve.steam.desktopgame.$appid window $win"
exit 0
fi
done
done
echo "The viewer started, but its window didn't appear within 30 s. Chromium's log:" >&2
tail -n 15 "$log" >&2
exit 1
"""
class MacViewError(Exception):
pass
def panel_id(src):
"""A stable panel id per source, in the range panel-on-frame.sh uses."""
return 2_001_000_000 + zlib.crc32(f"mac:{src}".encode()) % 1_000_000
def fit(w, h, box=PANEL_BOX):
s = min(box[0] / max(w, 1), box[1] / max(h, 1))
return max(320, round(w * s)), max(200, round(h * s))
class MacView:
def __init__(self, tunnel_ssh, run, frame, track=None):
self.tunnel_ssh = list(tunnel_ssh)
self.run = run
self.frame = frame
self.track = track or (lambda proc: None) # the server ends these on exit
self.lock = threading.Lock()
self.token = secrets.token_urlsafe(24)
self.agent = None
self.port = None
self.tunnel = None
self.remote_port = None
self.supervisor = None
self.closing = False
self.shows = 0 # counts Show presses, so a late cleanup can't close a new viewer
self.launching = 0 # Shows in progress (one may be replacing its own stream)
# Held by a Show while it counts itself in, and by the cleanup for its
# check and its pkill together, so a Show can't start in between.
self.viewer_lock = threading.Lock()
# Use the Frame's USB-C network when it's plugged into this Mac (see
# _usb_route); FRAME_MACVIEW_USB=0 turns that off.
self.prefer_usb = os.environ.get("FRAME_MACVIEW_USB") != "0"
self.route = "network"
self.shown = set() # sources with a viewer out there, connected or retrying
self.browser_flags = list(BROWSER_FLAGS)
# ---- the agent on this Mac ----
def unavailable(self):
"""Why this can't work here, or None."""
if not SUPPORTED:
return "Streaming your computer into the headset needs macOS."
if not AGENT.exists() and not (SOURCES.exists() and shutil.which("xcrun")):
return "The Mac streaming helper is missing from this copy of Frame Control."
return None
def build(self):
if AGENT.exists() and not self._stale():
return
if not (SOURCES / "build.sh").exists():
if AGENT.exists():
return
raise MacViewError("The Mac streaming helper is missing.")
r = subprocess.run(["/bin/sh", str(SOURCES / "build.sh"), str(AGENT)], capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=600)
if r.returncode != 0:
raise MacViewError("Couldn't build the Mac streaming helper: " + (r.stderr or r.stdout).strip()[-400:])
def _stale(self):
try:
built = AGENT.stat().st_mtime
return any(p.stat().st_mtime > built for p in (SOURCES / "Sources").glob("*.swift"))
except OSError:
return False
def ensure_agent(self):
with self.lock:
if self.agent and self.agent.poll() is None:
return
reason = self.unavailable()
if reason:
raise MacViewError(reason)
self.build()
env = {**os.environ, "FRAME_MAC_VIEW_TOKEN": self.token}
# The same port as before when restarting, so a running tunnel still
# fits; otherwise (or if it's gone) whatever the system gives.
for port in dict.fromkeys([self.port or 0, 0]):
self.agent = subprocess.Popen([str(AGENT), "serve", "--port", str(port), "--page", str(PAGE),
"--exit-on-eof"], env=env, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
line = self.agent.stdout.readline()
m = re.search(r"listening on 127\.0\.0\.1:(\d+)", line)
if m:
break
self.agent.kill()
else:
raise MacViewError(f"The Mac streaming helper didn't start: {line.strip() or 'no output'}")
if self.port != int(m.group(1)):
self._drop_tunnel()
self.port = int(m.group(1))
self.track(self.agent)
threading.Thread(target=self.agent.stdout.read, daemon=True).start() # drain
def call(self, path, method="GET", **query):
"""A request to the agent; starts it if needed."""
self.ensure_agent()
url = f"http://127.0.0.1:{self.port}{path}?{urlencode({**query, 'k': self.token}, quote_via=quote)}"
req = urllib.request.Request(url, method=method, data=b"" if method == "POST" else None)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.load(r)
except (urllib.error.URLError, OSError, ValueError) as e:
raise MacViewError(f"The Mac streaming helper didn't answer: {e}")
# ---- the tunnel from the Frame ----
def _drop_tunnel(self):
if self.tunnel and self.tunnel.poll() is None:
self.tunnel.terminate()
self.tunnel = None
def tunnel_up(self):
return self.tunnel is not None and self.tunnel.poll() is None
def ensure_tunnel(self, allow_new_port=False):
"""Open the tunnel, on the port viewers already use if there is one.
A new port only when nobody is watching, since viewers can't move."""
with self.lock:
if self.tunnel_up():
return
last = ""
ports = [self.remote_port] if self.remote_port else list(REMOTE_PORTS)
if self.remote_port and allow_new_port:
ports += [p for p in REMOTE_PORTS if p != self.remote_port]
usb = self._usb_route()
# USB-C first when it's there; if that fails (unplugged just now,
# something else at that address), the normal path.
for via in ([usb, []] if usb else [[]]):
self.route = "usb" if via else "network"
if self._open_tunnel(via, ports):
return
last = self._last_tunnel_error
raise MacViewError(f"Couldn't open a tunnel from {self.frame} to this Mac: {last or 'no answer through it'}")
def _open_tunnel(self, via, ports):
"""Tries the ports on one route; True once the tunnel answers. With self.lock held."""
last = ""
for port in ports:
proc = subprocess.Popen([*self.tunnel_ssh, *via, "-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3", "-N",
"-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", self.frame],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, text=True)
# A taken port makes ssh exit once it's connected; a working
# tunnel answers the agent's status from the Frame's side.
ok = False
for _ in range(15):
time.sleep(0.4)
if proc.poll() is not None:
break
if self._probe(port):
ok = True
break
if ok:
self.tunnel, self.remote_port = proc, port
self.track(proc)
self._supervise()
return True
if proc.poll() is None:
proc.terminate()
proc.wait()
last = (proc.stderr.read() or "").strip()
if "forward" not in last.lower():
break # not a port clash: the Frame is unreachable this way
self._last_tunnel_error = last
return False
def _usb_route(self):
"""ssh options to reach the Frame over its USB-C network, or [].
Plugged into a Mac, the Frame is a USB network device (macOS lists it
as "Steam Frame"): the Frame's usb0 answers at about 1 ms, with none
of Wi-Fi's stalls. Measured 2026-09-28: content latency 7 ms instead
of 10, click to screen 17 ms instead of 27 (docs/mac-in-headset.md).
The host key is the same, so it's checked against the usual name."""
if not self.prefer_usb:
return []
try:
out = self.run("ip -4 -o addr show usb0 2>/dev/null", timeout=8)
except Exception:
return []
m = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/", out or "")
if not m:
return []
ip = m.group(1)
try:
socket.create_connection((ip, 22), timeout=1).close()
except OSError:
return [] # not plugged into this Mac
alias = self.frame
try:
cfg = subprocess.run(["ssh", "-G", self.frame], capture_output=True, text=True, timeout=5).stdout
opts = dict(line.split(None, 1) for line in cfg.splitlines() if " " in line)
alias = opts.get("hostkeyalias") or opts.get("hostname") or alias # a configured alias wins
except (OSError, subprocess.SubprocessError):
pass
return ["-o", f"HostName={ip}", "-o", f"HostKeyAlias={alias}"]
def _supervise(self):
"""Reopen the tunnel on the same port after the headset sleeps or the
network drops, so viewers that are retrying find the Mac again."""
if self.supervisor and self.supervisor.is_alive():
return
def loop():
while not self.closing:
time.sleep(5)
if self.closing or self.tunnel_up() or not (self.agent and self.agent.poll() is None):
continue
try:
self.ensure_tunnel()
except MacViewError:
pass # still unreachable; try again shortly
self.supervisor = threading.Thread(target=loop, daemon=True)
self.supervisor.start()
def _probe(self, port):
"""Whether the Frame reaches the agent through the tunnel on `port`."""
# /ping needs no key, so none appears on the Frame's command lines.
cmd = f"curl -s -m 2 'http://127.0.0.1:{port}/ping'"
try:
return self.run(cmd, timeout=8).strip() == "frame-mac-view"
except Exception: # noqa: BLE001 - the server's Failure, timeouts: all mean "not yet"
return False
# ---- viewers on the Frame ----
def show(self, src, quality="balanced", width=None, height=None):
with self.viewer_lock:
self.launching += 1
try:
return self._show(src, quality, width, height)
finally:
with self.viewer_lock:
self.launching -= 1
def _show(self, src, quality, width, height):
if src != "test" and not src.startswith(("window:", "display:", "separate:")):
raise MacViewError("Pick a window or display to show.")
self.shows += 1
q = QUALITY.get(quality) or QUALITY["balanced"]
state = self.call("/status")
if src != "test" and not state.get("screen"):
raise MacViewError("Frame Control needs Screen Recording permission first (Allow… under Mac in the headset).")
if src.startswith("separate:") and not state.get("accessibility"):
raise MacViewError("A window on its own display needs the Accessibility permission too, to move it "
"(Allow… under Mac in the headset).")
self.shown -= set(state.get("finished", [])) # their Mac windows closed
if src in self.shown or any(st.get("src") == src for st in state.get("streams", [])):
# Showing it again replaces the old viewer, connected or not: this
# revokes its keys so it can't come back alongside the new one.
self.stop(src)
# Viewers that lost the tunnel keep retrying its old port, even though
# the agent no longer counts them, so only move when none are out there.
others = self.shown - {src}
self.ensure_tunnel(allow_new_port=not others)
appid = panel_id(src)
# Unique per launch, so a new window is never confused with an old one.
tag = "fc" + secrets.token_hex(4)
# A single-use ticket for this source, not Frame Control's key: the URL
# is visible in the Frame's process list.
ticket = self.call("/ticket", method="POST", src=src)["ticket"]
params = {"src": src, "t": ticket, "tag": tag, "codec": q["codec"], "max": q["max"], "fps": q["fps"],
"bpp": q["bpp"]}
url = f"http://127.0.0.1:{self.remote_port}/view?{urlencode(params)}"
w, h = fit(width or 1280, height or 720)
args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag, *self.browser_flags))
try:
out = self.run("bash -s -- " + args, stdin=LAUNCH, timeout=60)
except Exception as e: # noqa: BLE001 - the server's Failure carries the Frame's words
if "NO_BROWSER" in (getattr(e, "stdout", "") or ""):
raise MacViewError("The Frame needs a browser for this: install Chromium (Tools → Linux apps, "
"org.chromium.Chromium) or Chromium XR.")
raise MacViewError(str(e))
self.shown.add(src)
return {"panel": f"valve.steam.desktopgame.{appid}", "src": src, "detail": out.strip()}
def stop(self, src=None):
if src:
self.shown.discard(src)
else:
self.shown.clear()
if not (self.agent and self.agent.poll() is None):
return {"closed": 0}
out = self.call("/close", method="POST", **({"src": src} if src else {}))
if not self.shown:
threading.Thread(target=self._end_viewer_browser, args=(self.shows,), daemon=True).start()
return out
def _end_viewer_browser(self, shows):
"""Chromium on the Frame outlives its last viewer window (verified
2026-09-28), so once nothing is shown, end it. It runs with a profile
of its own, so nothing else is touched."""
time.sleep(2) # the viewers close their windows first
with self.viewer_lock:
if self.shown or self.shows != shows or self.launching:
return
try:
self.run("pkill -f '[f]rame-control/mac-view|[d]ata/frame-mac-view' || true", timeout=10)
except Exception:
pass
def state(self):
reason = self.unavailable()
if reason:
return {"available": False, "reason": reason}
status = self.call("/status")
windows = self.call("/windows").get("windows", []) if status.get("screen") else []
displays = self.call("/displays").get("displays", [])
return {"available": True, "screen": status.get("screen", False),
"accessibility": status.get("accessibility", False), "streams": status.get("streams", []),
"windows": windows, "displays": displays, "tunnel": self.tunnel_up(),
"route": self.route}
def restart_agent(self):
"""Only if it's missing a permission: a running stream would stop."""
with self.lock:
if not (self.agent and self.agent.poll() is None):
return
status = self.call("/status")
if status.get("screen") and status.get("accessibility"):
return
with self.lock:
self.agent.terminate()
self.agent.wait(5)
def request_permissions(self):
return self.call("/permissions", method="POST")
def shutdown(self):
self.closing = True
try:
self.stop()
except MacViewError:
pass
for proc in (self.tunnel, self.agent):
if proc and proc.poll() is None:
proc.terminate()
# The helper puts separated windows back before it exits (about 1 s).
if self.agent:
try:
self.agent.wait(3)
except subprocess.TimeoutExpired:
self.agent.kill()
def _quote(s):
return "'" + s.replace("'", "'\\''") + "'"
+112 -1
View File
@@ -329,6 +329,13 @@
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
.f-0 { color: var(--muted); }
/* ---- Android display ---- */
#mvQuality { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 4px 6px; font: inherit; font-size: 12px; }
.mv-windows { max-height: 420px; overflow-y: auto; }
.mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
.mv-perm .row { margin-top: 8px; }
.mv-live { color: var(--green, #7fd67f); font-size: 12px; }
.mv-opt { display: block; font-size: 13px; margin: 0 0 12px; }
.mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; }
.disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 10px; font: inherit; font-size: 13px; }
.disp select:focus { outline: none; border-color: var(--blue); }
@@ -641,6 +648,12 @@
<span class="desk-only">You can also drop files anywhere in this window.</span>
<input type="file" id="fileInput" multiple hidden>
</div>
<label class="sub" for="apkDisplay">Android display</label>
<select id="apkDisplay" aria-label="Android app display mode">
<option value="auto">Automatic (detect VR)</option>
<option value="flat">Flat screen</option>
<option value="vr">VR app</option>
</select>
<div class="progress" id="prog"><i></i></div>
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
@@ -708,6 +721,27 @@
</div>
</section>
<section class="panel" id="macview" hidden>
<div class="shelf-head"><h2>Mac in the headset</h2><span class="count" id="mvCount"></span><span class="spacer"></span>
<select id="mvQuality" title="Picture quality and bandwidth">
<option value="sharp">Sharp</option><option value="balanced" selected>Balanced</option>
<option value="light">Light</option><option value="compatible">Compatible (JPEG)</option>
</select>
<button class="small" id="mvRefresh">Refresh</button></div>
<label class="mv-opt"><input type="checkbox" id="mvSeparate" checked> Give each window its own display
<span class="sub">its menus and sheets come along, nothing covers it, and it goes back when you stop</span></label>
<div class="mv-perm" id="mvPerm" hidden></div>
<div class="list" id="mvDisplays"></div>
<div class="shelf-head" style="margin-top:16px"><h2>Windows</h2><span class="count" id="mvWinCount"></span></div>
<div class="list mv-windows" id="mvWindows"><div class="sub">Loading…</div></div>
<div class="row" style="margin-top:12px">
<button class="small" data-mv="show" data-src="test" data-title="Test pattern">Test pattern</button>
<button class="small danger" data-mv="stopall">Stop all</button>
</div>
<div class="hint">Each window or screen becomes its own panel in the headset. Place it with the SteamVR dashboard
(Float in World, Move, Size). Point and click with the laser, scroll with the thumbstick, and type on the Mac's keyboard.</div>
</section>
<section class="panel" id="privacy">
<div class="shelf-head"><h2>Privacy &amp; updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
@@ -1520,6 +1554,7 @@ function upload(file, mode) {
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
xhr.setRequestHeader("X-Mode", mode);
if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value);
const bar = $("prog").firstElementChild;
$("prog").style.display = "block"; bar.style.width = "0";
xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; };
@@ -2313,10 +2348,85 @@ $("shotsRefresh").onclick = loadShots;
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
// ---- Mac in the headset: windows and displays as panels (ui/frame_macview.py) ----
let mvSeq = 0, mvTimer = 0;
// streaming: the src it's shown as (a window may be "separate:<id>"), or "".
// How a live stream is doing: frames shown, Mac-to-headset delay, bitrate, and
// whether the network made it step down (fewer frames, then fewer pixels).
function mvHealth(st) {
if (!st) return "";
const x = st.stats || {}, c = st.controller || {}, bits = [];
if (x.fps) bits.push(`${Math.round(x.fps)} fps`);
if (x.total && x.total.p50 != null) bits.push(`${Math.round(x.total.p50)} ms`);
if (x.mbps) bits.push(`${x.mbps} Mbit/s`);
if (c.tier > 0) bits.push(`weak link: ${c.fps} fps${c.scale < 1 ? `, ${Math.round(c.scale * 100)}% size` : ""}`);
return bits.length ? " · " + esc(bits.join(" · ")) : "";
}
function mvRow(src, title, sub, w, h, streaming, st) {
return `<div class="item"><div class="grow"><div class="t">${esc(title)}</div>
<div class="s">${esc(sub)}${streaming ? ' · <span class="mv-live">in the headset</span>' + mvHealth(st) : ""}</div></div>
${streaming ? `<button class="small" data-mv="stop" data-src="${esc(streaming)}">Stop</button>`
: `<button class="small action" data-mv="show" data-src="${esc(src)}" data-title="${esc(title)}" data-w="${w}" data-h="${h}">Show</button>`}
</div>`;
}
async function loadMacView(restart) {
const seq = ++mvSeq;
let s, err;
try { s = await api("/api/macview" + (restart ? "?restart=1" : "")); } catch (e) { err = e; }
if (seq !== mvSeq) return;
const box = $("macview");
if (err) { box.hidden = false; return failed($("mvWindows"), err); }
if (!s.available) { box.hidden = true; return; }
box.hidden = false;
const live = new Set((s.streams || []).map(x => x.src));
const byStream = src => (s.streams || []).find(x => x.src === src);
$("mvCount").textContent = live.size ? `${live.size} showing${s.route === "usb" ? " · over USB-C" : ""}` : "";
const perm = [];
if (!s.screen) perm.push(`Frame Control needs <b>Screen Recording</b> permission to show your windows.`);
if (!s.accessibility) perm.push(`Allow <b>Accessibility</b> too, so clicks and keys from the headset reach the Mac.`);
$("mvPerm").hidden = !perm.length;
$("mvPerm").innerHTML = perm.join(" ") + `<div class="row"><button class="small action" data-mv="perm">Allow…</button>
<span class="sub">Then press Refresh. macOS may ask you to reopen Frame Control.</span></div>`;
const liveAs = src => live.has(src) ? src : src.startsWith("window:") && live.has("separate:" + src.slice(7)) ? "separate:" + src.slice(7) : "";
$("mvDisplays").innerHTML = (s.displays || []).map(d =>
mvRow(d.src, `Whole screen: ${d.name}`, `${d.w}×${d.h}${d.main ? " · main display" : ""}`, d.w, d.h, liveAs(d.src), byStream(liveAs(d.src)))).join("");
const wins = s.windows || [];
$("mvWinCount").textContent = wins.length ? `${wins.length}` : "";
$("mvWindows").innerHTML = !s.screen ? `<div class="sub">Windows appear here once Screen Recording is allowed.</div>`
: wins.length ? wins.map(w => mvRow(w.src, w.title || w.app, `${w.title ? w.app + " · " : ""}${w.w}×${w.h}`, w.w, w.h, liveAs(w.src), byStream(liveAs(w.src)))).join("")
: `<div class="sub">No windows open on this Mac's current desktop.</div>`;
// Keep the numbers fresh while something is in the headset and the card is on screen.
clearTimeout(mvTimer);
const tick = () => { mvTimer = document.hidden || !box.offsetParent ? setTimeout(tick, 3000) : (loadMacView(), 0); };
if (live.size) mvTimer = setTimeout(tick, 3000);
}
$("mvRefresh").onclick = () => loadMacView(true);
$("mvSeparate").checked = localStorage.getItem("mvSeparate") !== "0";
$("mvSeparate").onchange = e => localStorage.setItem("mvSeparate", e.target.checked ? "1" : "0");
$("macview").onclick = async e => {
const b = e.target.closest("[data-mv]"); if (!b) return;
const src = b.dataset.src;
if (b.dataset.mv === "show") {
const as = $("mvSeparate").checked && src.startsWith("window:") ? "separate:" + src.slice(7) : src;
await act(`Show ${b.dataset.title} in the headset`, async () => {
const r = await api("/api/macview", { action: "show", src: as, quality: $("mvQuality").value,
w: +b.dataset.w || undefined, h: +b.dataset.h || undefined });
return { message: `${b.dataset.title} is a panel in the headset now. Float it with the SteamVR dashboard.`, ...r };
}, b);
} else if (b.dataset.mv === "stop") {
await act("Stop showing it", () => api("/api/macview", { action: "stop", src }), b);
} else if (b.dataset.mv === "stopall") {
await act("Stop everything in the headset", () => api("/api/macview", { action: "stop" }), b);
} else if (b.dataset.mv === "perm") {
await act("Ask macOS for permission", () => api("/api/macview", { action: "permissions" }), b);
}
setTimeout(loadMacView, 800);
};
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
const PAGES = ["home", "games", "android", "tools"];
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
display: "android", transfer: "tools", apps: "tools", power: "tools", privacy: "tools" };
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools" };
let page = "home";
function showPage() {
const id = location.hash.slice(1);
@@ -2328,6 +2438,7 @@ function showPage() {
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
const section = !PAGES.includes(id) && id && $(id);
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
if (page === "tools") loadMacView();
}
window.addEventListener("hashchange", showPage);
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
+398
View File
@@ -0,0 +1,398 @@
<!doctype html>
<!-- Frame Control: one Mac window (or display) inside the Steam Frame.
Served by the Mac's frame-mac-view agent through an SSH tunnel and opened
on the Frame as its own Chromium app window, which gamescope turns into a
SteamVR panel. Video arrives over a WebSocket (H.264 Annex B for
WebCodecs, or JPEG); pointer, wheel and keys go back the same way. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mac</title>
<style>
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; cursor: default; }
canvas { position: fixed; inset: 0; width: 100%; height: 100%; object-fit: contain; image-rendering: auto; }
#note { position: fixed; left: 50%; top: 16px; transform: translateX(-50%); max-width: 80%;
font: 15px/1.4 system-ui, sans-serif; color: #eee; background: rgba(20,22,26,.88);
padding: 8px 14px; border-radius: 10px; pointer-events: none; transition: opacity .4s; }
#note[hidden] { display: block; opacity: 0; }
#hud { position: fixed; left: 8px; top: 8px; font: 12px/1.35 ui-monospace, monospace; color: #dfe;
background: rgba(0,0,0,.72); padding: 6px 9px; border-radius: 6px; pointer-events: none; white-space: pre; }
</style>
</head>
<body>
<canvas id="c" width="16" height="9"></canvas>
<div id="note">Connecting to the Mac…</div>
<div id="hud" hidden></div>
<script>
"use strict";
const q = new URLSearchParams(location.search);
// t: a single-use ticket from Frame Control. After the first connection the
// Mac sends a reconnect key, kept only in memory.
const src = q.get("src") || "test", ticket = q.get("t") || "", tag = q.get("tag") || "";
let reconnectKey = "";
const c = document.getElementById("c"), ctx = c.getContext("2d", { alpha: false, desynchronized: true });
const note = document.getElementById("note");
// Counters for Frame Control's tests (read over DevTools).
const stats = window.stats = { frames: 0, keyFrames: 0, bytes: 0, dropped: 0, codec: "", errors: [], info: null };
// ---- timing: the Mac measures each frame's journey on its own clock ----
// Clock sync, NTP-style: the Mac's time minus ours, from the ping with the
// shortest round trip lately (the least queueing, so the least error).
const clock = { off: null, rtt: 0, samples: [], reported: 0 };
function toMac(ms) { return clock.off === null ? null : Math.round(ms * 1000 + clock.off); }
function onPong(m) {
const now = performance.now(), rtt = now - m.c;
clock.samples.push({ rtt, off: m.a - (m.c + now) / 2 * 1000 });
if (clock.samples.length > 16) clock.samples.shift();
const best = clock.samples.reduce((a, b) => (b.rtt < a.rtt ? b : a));
clock.off = best.off;
clock.rtt = best.rtt;
if (now - clock.reported > 1000) {
clock.reported = now;
send({ t: "clock", rtt: +best.rtt.toFixed(2), dec: `${decoderInfo}; ${glInfo}; raf ${rafHz} Hz` });
}
}
let pingTimer = 0;
function startPings() {
clearInterval(pingTimer);
clock.samples = [];
for (let i = 0; i < 10; i++) setTimeout(() => send({ t: "ping", c: performance.now() }), i * 40);
pingTimer = setInterval(() => send({ t: "ping", c: performance.now() }), 1000);
}
// Decoded, drawn and next-animation-frame times go back in batches.
let reports = [], dropsToReport = 0, shownPending = null, rafQueued = false;
setInterval(() => {
if (!reports.length && !dropsToReport) return;
send({ t: "fd", f: reports, drop: dropsToReport });
reports = [];
dropsToReport = 0;
}, 250);
function dropped() { stats.dropped++; dropsToReport++; }
// A drawn frame is on screen from the next animation frame, unless another
// replaces it first (then it was never seen).
function shown(seq, decodedAt, drawnAt) {
if (shownPending) reports.push([shownPending.seq, toMac(shownPending.dec), toMac(shownPending.drawn), 0]);
shownPending = { seq, dec: decodedAt, drawn: drawnAt };
if (rafQueued) return;
rafQueued = true;
requestAnimationFrame(() => {
rafQueued = false;
const p = shownPending, now = performance.now();
shownPending = null;
if (p && clock.off !== null) reports.push([p.seq, toMac(p.dec), toMac(p.drawn), toMac(now)]);
});
}
let decoderInfo = "";
// What this browser draws with, and how often it gives an animation frame
// when nothing else is going on (both only for the numbers).
let glInfo = "", rafHz = 0;
try {
const gl = document.createElement("canvas").getContext("webgl");
const ext = gl && gl.getExtension("WEBGL_debug_renderer_info");
glInfo = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl ? "webgl" : "no webgl";
} catch (e) { glInfo = "?"; }
(function measureRaf() {
let n = 0, t0 = 0;
const tick = t => {
if (!t0) t0 = t;
if (t - t0 < 1000) { n++; return requestAnimationFrame(tick); }
rafHz = Math.round(n * 1000 / (t - t0));
};
requestAnimationFrame(tick);
})();
const hud = document.getElementById("hud");
function showHud(on) { hud.hidden = !on; }
function ms(o) { return o && o.p50 !== undefined ? `${o.p50}/${o.p95}` : "–"; }
function onStats(m) {
if (hud.hidden) return;
hud.textContent = `${m.size} ${m.fps} fps shown (${m.sentFps} sent) ${m.mbps} Mbit/s` +
(m.bitrate ? ` of ${(m.bitrate / 1e6).toFixed(1)}` : "") + (m.tier !== undefined ? ` tier ${m.tier}` : "") +
`\nlatency p50/p95 ms total ${ms(m.total)}\n capture ${ms(m.capture)} queue ${ms(m.queue)} encode ${ms(m.encode)}` +
`\n network ${ms(m.network)} decode ${ms(m.decode)} draw ${ms(m.draw)}` +
`\ninput→shown p50 ${m.input ? m.input.p50.toFixed(0) + " ms" : "–"} rtt ${m.rtt} ms` +
`\nskipped ${m.skipped}/${m.captured} dropped ${m.dropped} ${decoderInfo}`;
}
showHud(q.get("stats") === "1");
// Frame Control finds this window on the Frame by the tag in its title.
document.title = tag ? `Mac [${tag}]` : "Mac";
let failedStarts = 0;
let ws = null, dec = null, codecString = "", needKey = true, closing = false, retry = 0, noteTimer = 0, lastError = "";
function show(text, ms) {
note.textContent = text;
note.hidden = false;
clearTimeout(noteTimer);
if (ms) noteTimer = setTimeout(() => { note.hidden = true; }, ms);
}
function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
let lastKeyAsk = 0;
function askKeyFrame() {
const now = performance.now();
if (now - lastKeyAsk < 500) return;
lastKeyAsk = now;
send({ t: "key-frame" });
}
let hideNoteOnFrame = true; // "Connecting…"/"Reconnecting…" go once video flows again
function drawFrame(img, w, h, seq, decodedAt) {
if (c.width !== w || c.height !== h) { c.width = w; c.height = h; }
ctx.drawImage(img, 0, 0);
shown(seq, decodedAt, performance.now());
stats.frames++;
if (hideNoteOnFrame) { hideNoteOnFrame = false; note.hidden = true; }
}
// ---- H.264 ----
function startCode(b, i) { return b[i] === 0 && b[i + 1] === 0 && (b[i + 2] === 1 || (b[i + 2] === 0 && b[i + 3] === 1)); }
function spsCodec(au) {
for (let i = 0; i + 7 < au.length; i++) {
if (!startCode(au, i)) continue;
const n = au[i + 2] === 1 ? i + 3 : i + 4;
if ((au[n] & 0x1f) === 7) return "avc1." + [au[n + 1], au[n + 2], au[n + 3]].map(b => b.toString(16).padStart(2, "0")).join("");
i = n;
}
return "";
}
function makeDecoder() {
if (dec) try { dec.close(); } catch (e) {}
codecString = "";
dec = new VideoDecoder({
// The chunk's timestamp is the Mac's sequence number, to match reports up.
output: frame => { drawFrame(frame, frame.displayWidth, frame.displayHeight, frame.timestamp, performance.now()); frame.close(); },
error: e => {
stats.errors.push(String(e.message || e));
needKey = true;
makeDecoder();
askKeyFrame();
},
});
}
function onH264(isKey, seq, au) {
if (isKey) {
const cs = spsCodec(au);
if (cs && (cs !== codecString || dec.state !== "configured")) {
if (dec.state === "closed") makeDecoder();
dec.configure({ codec: cs, optimizeForLatency: true, hardwareAcceleration: "no-preference" });
codecString = stats.codec = cs;
}
stats.keyFrames++;
}
if (dec.state !== "configured" || (needKey && !isKey)) { dropped(); askKeyFrame(); return; }
// Far behind: skip to the next keyframe rather than show old pictures late.
// A few queued frames are fine: decoding catches up faster than a keyframe
// crosses a slow link, and only the newest decoded frame gets drawn.
if (!isKey && dec.decodeQueueSize > 10) { needKey = true; dropped(); askKeyFrame(); return; }
needKey = false;
dec.decode(new EncodedVideoChunk({ type: isKey ? "key" : "delta", timestamp: seq, data: au }));
}
// ---- JPEG ----
let jpegBusy = false;
function onJPEG(seq, data) {
if (jpegBusy) { dropped(); return; }
jpegBusy = true;
createImageBitmap(new Blob([data], { type: "image/jpeg" })).then(bmp => {
drawFrame(bmp, bmp.width, bmp.height, seq, performance.now());
bmp.close();
}).catch(e => stats.errors.push(String(e))).finally(() => { jpegBusy = false; });
}
async function pickCodec() {
const want = q.get("codec");
if (want === "jpeg") return "jpeg";
if (!("VideoDecoder" in window)) return "jpeg";
try {
const r = await VideoDecoder.isConfigSupported({ codec: "avc1.640028", optimizeForLatency: true });
if (!r.supported) return "jpeg";
// Whether this browser has a hardware H.264 decoder (for the numbers).
const hw = await VideoDecoder.isConfigSupported({ codec: "avc1.640028", hardwareAcceleration: "prefer-hardware" })
.catch(() => ({ supported: false }));
decoderInfo = hw.supported ? "h264 hardware" : "h264 software";
return "h264";
} catch (e) { return "jpeg"; }
}
async function connect() {
const codec = await pickCodec();
stats.codec = codec;
if (codec === "h264") makeDecoder();
needKey = true;
const p = new URLSearchParams({ src, codec, max: q.get("max") || "1920", fps: q.get("fps") || "60" });
if (reconnectKey) p.set("r", reconnectKey); else p.set("t", ticket);
if (q.get("bpp")) p.set("bpp", q.get("bpp"));
ws = new WebSocket(`ws://${location.host}/stream?${p}`);
ws.binaryType = "arraybuffer";
ws.onopen = () => { retry = 0; lastError = ""; startPings(); };
ws.onmessage = ev => {
if (typeof ev.data === "string") return control(JSON.parse(ev.data));
const now = performance.now(), b = new Uint8Array(ev.data);
stats.bytes += b.length;
if (b.length < 18) return;
// flags (1 = keyframe), pts µs, sequence number, input echoed; big-endian.
const v = new DataView(ev.data), isKey = (b[0] & 1) === 1, seq = v.getUint32(9);
send({ t: "rx", s: seq, r: toMac(now) });
const payload = b.subarray(17);
if (codec === "h264") onH264(isKey, seq, payload); else onJPEG(seq, payload);
};
ws.onclose = () => {
ws = null;
clearInterval(pingTimer);
if (closing) return;
// Refused before ever getting a key: the ticket expired or was revoked,
// and retrying can't help.
if (!reconnectKey && ++failedStarts >= 3) {
show("This view has expired. Press Show in Frame Control on the Mac to open it again.");
return;
}
hideNoteOnFrame = true;
// The Mac may be asleep or the tunnel restarting: keep trying.
retry = Math.min(retry + 1, 6);
// Keep the Mac's reason (a missing permission, a closed window) on screen.
show(lastError ? `${lastError} Retrying…` : "Lost the Mac. Reconnecting…");
setTimeout(connect, 500 * 2 ** retry);
};
}
function control(m) {
if (m.t === "pong") return onPong(m);
if (m.t === "stats") return onStats(m);
if (m.t === "bench") return bench(m);
if (m.t === "hello") {
reconnectKey = m.r;
send({ t: "ack" }); // the ticket is spent only now
} else if (m.t === "info") {
stats.info = m;
warmMs = Math.max(0, +m.warm || 0);
const name = m.title && m.app && m.title !== m.app ? `${m.title} — ${m.app}` : (m.title || m.app || "Mac");
document.title = tag ? `${name} [${tag}]` : name;
if (!m.input) {
hideNoteOnFrame = false; // a warning, not a connection notice: let it stay its 8 s
show("Clicks and keys need Accessibility permission on the Mac (Frame Control asks for it).", 8000);
}
} else if (m.t === "error") {
stats.errors.push(m.message);
lastError = m.message.replace(/\.?$/, ".");
show(m.message);
} else if (m.t === "close" || m.t === "closed") {
closing = true;
if (ws) ws.close();
show(m.reason ? `Stopped: ${m.reason}.` : "Stopped.");
window.close();
}
}
// ---- input ----
// Where the picture sits inside the window (object-fit: contain letterboxes it).
function toPicture(e) {
const r = c.getBoundingClientRect(), s = Math.min(r.width / c.width, r.height / c.height);
const w = c.width * s, h = c.height * s, left = r.left + (r.width - w) / 2, top = r.top + (r.height - h) / 2;
const x = (e.clientX - left) / w, y = (e.clientY - top) / h;
return { x, y, inside: x >= 0 && x <= 1 && y >= 0 && y <= 1 };
}
// Discrete input carries an id and when it happened (the Mac's clock), so
// the Mac can tag the first frame that could show its effect.
let inputId = 0;
// Keep the Frame's Wi-Fi awake for a few seconds after input, when the agent asks.
let warmMs = 0, warmUntil = 0, warmTimer = 0;
function keepWarm() {
if (!warmMs) return;
warmUntil = performance.now() + 5000;
if (warmTimer) return;
warmTimer = setInterval(() => {
if (performance.now() > warmUntil) { clearInterval(warmTimer); warmTimer = 0; return; }
send({ t: "w" });
}, warmMs);
}
function stamp(m, e) {
keepWarm();
m.i = ++inputId;
m.tv = toMac(e && e.timeStamp ? e.timeStamp : performance.now());
return m;
}
// Moves go at most every 8 ms, on a timer rather than the next animation frame:
// the Frame throttles a panel's animation frames to 15-36 Hz when it thinks
// nobody is looking, which would hold a move back by up to 60 ms.
let pendingMove = null, moveQueued = false, lastMove = 0;
function flushMove() {
moveQueued = false;
if (pendingMove) { send(pendingMove); lastMove = performance.now(); }
pendingMove = null;
}
addEventListener("pointermove", e => {
const p = toPicture(e);
if (!p.inside && !e.buttons) return;
pendingMove = { t: "m", e: "move", x: p.x, y: p.y };
if (moveQueued) return;
const wait = lastMove + 8 - performance.now();
if (wait <= 0) return flushMove();
moveQueued = true;
setTimeout(flushMove, wait);
});
addEventListener("pointerdown", e => {
const p = toPicture(e);
if (!p.inside) return;
if (e.pointerId !== undefined) try { c.setPointerCapture(e.pointerId); } catch (err) {}
pendingMove = null;
send(stamp({ t: "m", e: "down", b: e.button < 0 ? 0 : e.button, x: p.x, y: p.y }, e));
e.preventDefault();
});
addEventListener("pointerup", e => {
const p = toPicture(e);
send({ t: "m", e: "up", b: e.button < 0 ? 0 : e.button, x: p.x, y: p.y });
});
// Let go of any held button where the pointer is on the Mac, not at a corner.
addEventListener("pointercancel", () => send({ t: "release" }));
addEventListener("contextmenu", e => e.preventDefault());
addEventListener("wheel", e => {
const p = toPicture(e), unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? innerHeight : 1;
send(stamp({ t: "wheel", dx: e.deltaX * unit, dy: e.deltaY * unit, x: p.x, y: p.y }, e));
e.preventDefault();
}, { passive: false });
function mods(e) {
return ["shift", "ctrl", "alt", "meta"].filter(m => e[m + "Key"]);
}
function onKey(e, down) {
// Ctrl+Alt+Shift+S shows the numbers; it isn't sent to the Mac.
if (e.code === "KeyS" && e.ctrlKey && e.altKey && e.shiftKey) {
if (down) showHud(hud.hidden);
return e.preventDefault();
}
const m = { t: "k", e: down ? "down" : "up", code: e.code, key: e.key, mods: mods(e) };
send(down ? stamp(m, e) : m);
e.preventDefault();
}
// ---- benchmarks: the Mac asks the viewer to act as if someone did ----
function keyCode(ch) {
if (/[a-z]/i.test(ch)) return "Key" + ch.toUpperCase();
if (/[0-9]/.test(ch)) return "Digit" + ch;
return { " ": "Space", "\n": "Enter", ".": "Period", ",": "Comma" }[ch] || "";
}
function bench(m) {
if (m.action === "overlay") return showHud(m.on !== 0 && m.on !== "0");
if (m.action === "click") {
const at = { x: +m.x || 0.5, y: +m.y || 0.5 };
send(stamp({ t: "m", e: "down", b: 0, ...at }));
setTimeout(() => send({ t: "m", e: "up", b: 0, ...at }), 40);
} else if (m.action === "type") {
const text = String(m.text || ""), gap = +m.interval || 150;
[...text].forEach((ch, n) => setTimeout(() => {
const code = keyCode(ch);
if (!code) return send(stamp({ t: "text", s: ch }));
const mods = ch !== ch.toLowerCase() ? ["shift"] : [];
send(stamp({ t: "k", e: "down", code, key: ch, mods }));
setTimeout(() => send({ t: "k", e: "up", code, key: ch, mods }), 30);
}, n * gap));
}
}
addEventListener("keydown", e => onKey(e, true));
addEventListener("keyup", e => onKey(e, false));
addEventListener("blur", () => send({ t: "release" }));
show("Connecting to the Mac…");
connect();
</script>
</body>
</html>
+53 -3
View File
@@ -43,6 +43,7 @@ import frame_android # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_host # noqa: E402
import frame_macview # noqa: E402
import frame_media # noqa: E402
import frame_report # noqa: E402
import frame_store # noqa: E402
@@ -1358,6 +1359,46 @@ def push_media(path):
"id": token + "/" + name}
# ---- Mac in the headset (frame_macview.py) ----------------------------------
# The tunnel gets its own connection: the shared master's options would win
# over anything added after them.
macview = frame_macview.MacView(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"],
lambda remote, stdin=None, timeout=30: ssh(remote, stdin=stdin, timeout=timeout),
FRAME, track=_live_tunnels.add)
def macview_state(query=None):
if LOCAL:
return {"available": False, "reason": "Runs on your computer, not the headset."}
try:
# Refresh restarts the helper, which picks up a permission just granted.
if (query or {}).get("restart") == ["1"]:
macview.restart_agent()
return macview.state()
except frame_macview.MacViewError as e:
raise Failure(str(e), 500)
def macview_action(body):
"""{action: show|stop|permissions, src, quality, w, h}."""
if LOCAL:
raise Failure("Runs on your computer, not the headset.", 400)
action = body.get("action")
try:
if action == "show":
w, h = body.get("w"), body.get("h")
return macview.show(str(body.get("src") or ""), str(body.get("quality") or "balanced"),
int(w) if w else None, int(h) if h else None)
if action == "stop":
return macview.stop(body.get("src") or None)
if action == "permissions":
return macview.request_permissions()
except frame_macview.MacViewError as e:
raise Failure(str(e), 502)
raise Failure("unknown action", 400)
# ---- Report a problem (frame_report.py) --------------------------------------
def report_preview(body):
@@ -1390,7 +1431,7 @@ POST = {"/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval
"/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/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action}
# ---- HTTP ------------------------------------------------------------------
@@ -1526,6 +1567,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/macview":
self.send_json(macview_state(parse_qs(url.query)))
elif path == "/api/telemetry":
self.send_json(frame_telemetry.state())
elif path == "/api/computer/state":
@@ -1708,10 +1751,16 @@ class Handler(BaseHTTPRequestHandler):
raise Failure(str(e), 400, {"package": info["package"], "version_code": info.get("version_code"), "blocker": str(e)})
ensure_master()
try:
m = frame_android.install(str(dest), source=name)
display = self.headers.get("X-APK-Display", "auto")
if display not in ("auto", "flat", "vr"):
raise frame_android.FrameError("invalid APK display mode")
m = frame_android.install(str(dest), source=name,
flatscreen=None if display == "auto" else display == "flat")
except frame_android.FrameError as e:
raise Failure(str(e), 400)
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
kind = "VR app" if not m['flatscreen'] else "app"
notes = " ".join(m.get("vr_issues", []))
return {"message": f"Installed {m['label']} as its own {kind} in the Steam library. {notes}".strip(), "app": m}
return {"message": push_file(dest)}
finally:
if not keep:
@@ -1756,6 +1805,7 @@ def main():
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
webinstall_shutdown()
macview.shutdown() # close the headset's viewers before the agent goes
# The master was started with -N, so it stays up until told to exit.
if CONTROL:
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)