mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
Merge remote-tracking branch 'origin/main' into theatre-media
# Conflicts: # ui/server.py
This commit is contained in:
commit
ae7f733b90
72 files changed
+47716
-18
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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 & 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).
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user