Files
vesper8--Quest2Frame/bridge.py
T
MichaelScottsman 6b014289d3 Update UI, wireless installs, app names, refresh rates and progress
Include managed uninstall and tests. Keep-awake integration is work in progress: Frame authorization currently rejects the inhibitor and blocks wireless installation until resolved.
2026-09-27 14:55:47 -04:00

591 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: GPL-3.0-only
"""Quest backup, OVR Port conversion and per-game Lepton deployment."""
from pathlib import Path, PurePosixPath
import base64
import difflib
import hashlib
import io
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
import time
import urllib.request
import urllib.parse
import zipfile
import uuid
import paramiko
from steam_shortcuts import upsert, app_id, remove as remove_shortcut
from storage import PROBE, select_storage, card_launcher
from keep_awake import FrameAwake
APP = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path(__file__).resolve().parent
ROOT = APP
ASSETS = Path(getattr(sys, '_MEIPASS', Path(__file__).resolve().parent))
LIB_VERSION = '3.4.3-23204ea'
NO_WINDOW = getattr(subprocess, 'CREATE_NO_WINDOW', 0)
def artwork_query(title):
return re.sub(r'\s*\(Quest\)\s*$', '', title, flags=re.I).strip()
def suggested_title(package):
known = {'com.enhanceexperience.tetriseffect':'Tetris Effect: Connected',
'com.beatgames.beatsaber':'Beat Saber', 'com.Armature.VR4':'Resident Evil 4'}
if package in known: return known[package]
name = package.rsplit('.', 1)[-1].replace('_', ' ')
return re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', name).strip()
def artwork_candidates(title):
"""Return suggestions only; the user must confirm the correct Steam edition."""
query = artwork_query(title)[:150]
if not query: return []
url = 'https://store.steampowered.com/api/storesearch/?' + urllib.parse.urlencode(
{'term':query, 'l':'english', 'cc':'US'})
request = urllib.request.Request(url, headers={'User-Agent':'Quest2Frame/1.0'})
with urllib.request.urlopen(request, timeout=15) as response:
payload = json.load(response)
def normalized(text): return re.sub(r'[^a-z0-9]', '', text.casefold())
candidates = []; seen = set()
for item in payload.get('items', [])[:30]:
ident = str(item.get('id', '')); name = item.get('name')
if not ident.isdigit() or not isinstance(name, str) or ident in seen: continue
seen.add(ident)
score = difflib.SequenceMatcher(None, normalized(query), normalized(name)).ratio()
candidates.append({'id':ident, 'title':name, 'score':score})
return sorted(candidates, key=lambda item:item['score'], reverse=True)[:10]
def valid_package(value):
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+', value):
raise ValueError('Invalid Android package name')
return value
def refresh_value(value=0):
if str(value).strip().lower() == 'default': return 0
rate = float(value)
if rate not in (0, 72, 80, 90, 120, 144):
raise ValueError('Refresh rate must be Default, 72, 80, 90, 120, or 144 Hz')
return int(rate)
def check_refresh_adapter(folder, rate):
if not refresh_value(rate): return
with zipfile.ZipFile(Path(folder) / 'game.apk') as apk:
name = 'lib/arm64-v8a/libopenxr_loader_generic.so'
supported = name in apk.namelist() and b'Q2F_REFRESH_RATE_V1' in apk.read(name)
if not supported:
raise ValueError('This older build needs reconversion for refresh-rate control. Keep Default to retain its existing compatibility fixes.')
def settings_text(scale=100, controllers=True, foveation=True, refresh_rate=0):
if not 50 <= float(scale) <= 200: raise ValueError('Resolution must be 50–200%')
return f'scale={float(scale)/100:.3f}\ncontroller_fix={int(controllers)}\nfoveation_fix={int(foveation)}\nrefresh_rate={refresh_value(refresh_rate)}\n'
def digest(path):
with open(path, 'rb') as stream: return hashlib.file_digest(stream, 'sha256').hexdigest()
def write_json(path, value):
path = Path(path); path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix('.tmp')
temporary.write_text(json.dumps(value, indent=2), encoding='utf-8')
temporary.replace(path)
def launcher_text(base, ident, lepton, storage_setup=''):
return (ASSETS / 'launcher.sh').read_text(encoding='utf-8').replace('@APP_DIR@', shlex.quote(base)).replace('@APP_ID@', str(int(ident))).replace('@LEPTON@', shlex.quote(lepton)).replace('@STORAGE_SETUP@',storage_setup)
class Bridge:
def __init__(self, log=print, progress=None):
self.log = log
self.progress_callback = progress or (lambda stage, percent: None)
self.progress_stage = ''
self.progress_prefix = ''
self.awake_guard = None
self.adb = shutil.which('adb') or str(Path(os.environ.get('LOCALAPPDATA', '')) / 'Android/Sdk/platform-tools/adb.exe')
def progress(self, stage, percent=None):
self.progress_stage = stage
self.progress_callback(self.progress_prefix + stage, percent)
def run(self, args, check=True):
p = subprocess.Popen([str(a) for a in args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
encoding='utf-8', errors='replace', creationflags=NO_WINDOW)
lines = []; previous = None
for line in p.stdout:
lines.append(line)
clean = re.sub(r'^[|/\\-] +(?:\[[^\]]*\] +)?', '', line.strip())
if str(args[0]) == str(self.adb) and any(str(a) in ('push','pull') for a in args):
match = re.search(r'\[\s*(\d+(?:\.\d+)?)%', line)
if match: self.progress(self.progress_stage, min(100, float(match[1])))
if clean and clean != previous: self.log(clean)
previous = clean
p.wait(); output = ''.join(lines)
if check and p.returncode: raise RuntimeError(f'Command failed ({Path(str(args[0])).name}):\n{output[-2500:]}')
return output
def adb_run(self, serial, *args, check=True):
return self.run([self.adb, '-s', serial, *args], check)
def shell(self, serial, *args, check=True):
return self.adb_run(serial, 'shell', shlex.join(str(a) for a in args), check=check)
def devices(self):
text = self.run([self.adb, 'devices'])
return [line.split()[0] for line in text.splitlines() if len(line.split()) == 2 and line.split()[1] == 'device']
def quests(self):
result = []
for serial in self.devices():
model = self.shell(serial, 'getprop', 'ro.product.model', check=False).strip()
maker = self.shell(serial, 'getprop', 'ro.product.manufacturer', check=False).strip()
if 'quest' in model.lower() or maker.lower() in ('oculus', 'meta'):
result.append((serial, model))
return result
def packages(self, serial):
return sorted(x[8:].strip() for x in self.shell(serial, 'pm', 'list', 'packages', '-3').splitlines() if x.startswith('package:'))
def label_helper(self):
source=ASSETS/'native/QuestAppLabels.java'
cache=ROOT/'tools/quest-labels'/digest(source)[:16]
dex=cache/'classes.dex'
if dex.exists():return dex
java,_,build,_=self.toolchain()
platforms=sorted((build.parent.parent/'platforms').glob('*/android.jar'))
if not platforms:raise RuntimeError('Android SDK platform android.jar is required for app names')
cache.mkdir(parents=True,exist_ok=True)
javac=java.with_name('javac.exe')
compiler=[javac]
if not javac.exists():
ecj=ROOT/'tools/ecj-3.40.0.jar'
expected='05cc22a24e7982970f63a405fc6c820bc80b806f27f3c5a6236fc475f8f7152b'
if not ecj.exists():
self.log('Downloading Eclipse Java compiler 3.40.0 for app-name lookup (EPL-2.0).')
with urllib.request.urlopen('https://repo.maven.apache.org/maven2/org/eclipse/jdt/ecj/3.40.0/ecj-3.40.0.jar',timeout=60) as response:
data=response.read()
if hashlib.sha256(data).hexdigest()!=expected:raise RuntimeError('Java compiler checksum mismatch')
ecj.write_bytes(data)
if digest(ecj)!=expected:raise RuntimeError('Java compiler checksum mismatch')
compiler=[java,'-jar',ecj,'-proc:none']
self.run([*compiler,'-encoding','UTF-8','-source','8','-target','8',
'-classpath',platforms[-1],'-d',cache,source])
self.run([java,'-cp',build/'lib/d8.jar','com.android.tools.r8.D8','--min-api','29',
'--lib',platforms[-1],'--output',cache,cache/'QuestAppLabels.class'])
return dex
def installed_apps(self, serial):
packages=self.packages(serial);labels={}
if not packages:return []
remote='/data/local/tmp/q2f-labels-'+uuid.uuid4().hex+'.dex'
try:
helper=self.label_helper()
self.adb_run(serial,'push',helper,remote)
for offset in range(0,len(packages),50):
group=[valid_package(p) for p in packages[offset:offset+50]]
output=self.shell(serial,'env','CLASSPATH='+remote,'app_process','/system/bin','QuestAppLabels',*group)
for line in output.splitlines():
if line.startswith('Q2F_LABELS='):
found=json.loads(line[len('Q2F_LABELS='):])
labels.update({p:n for p,n in found.items() if p in group and isinstance(n,str) and n.strip()})
except Exception as exc:
self.log('App name lookup unavailable; showing package IDs where necessary: '+str(exc))
finally:
try:self.shell(serial,'rm','-f',remote,check=False)
except (OSError,RuntimeError):pass
return sorted([dict(package=p,title=labels.get(p,p)) for p in packages],key=lambda a:(a['title'].casefold(),a['package']))
def backup(self, serial, package):
valid_package(package)
paths = [x[8:] for x in self.shell(serial, 'pm', 'path', package).splitlines() if x.startswith('package:')]
if not paths: raise RuntimeError('Package disappeared or Quest is disconnected')
if len(paths) != 1: raise RuntimeError('This game uses split APKs. This version supports single-APK games only; nothing was changed.')
folder = ROOT / 'games' / package / time.strftime('backup-%Y%m%d-%H%M%S')
folder.mkdir(parents=True, exist_ok=False)
self.log('Backing up APK. Keep the Quest connected until conversion finishes.')
self.progress('Backup: copying Quest APK')
self.adb_run(serial, 'pull', paths[0], folder / 'base.apk')
warnings = []
for kind in ('obb', 'data'):
remote = f'/sdcard/Android/{kind}/{package}'
exists = self.shell(serial, 'sh', '-c', f'test -d {shlex.quote(remote)} && echo PRESENT', check=False)
if 'PRESENT' not in exists: continue
target = folder / kind; target.mkdir()
self.progress('Backup: copying Quest ' + kind)
result = self.adb_run(serial, 'pull', remote + '/.', target, check=False)
if 'error:' in result.lower() or 'permission denied' in result.lower():
if kind == 'obb': raise RuntimeError(f'OBB backup incomplete. Retry with Quest unlocked. Partial backup: {folder}')
warnings.append('Some external save/cache files were inaccessible. Private Android app data is not backed up.')
self.progress('Backup: verifying APK')
write_json(folder / 'backup.json', {'package': package, 'apk_sha256': digest(folder / 'base.apk'), 'warnings': warnings})
for warning in warnings: self.log('WARNING: ' + warning)
return folder
def toolchain(self):
sdk = Path(os.environ.get('ANDROID_SDK_ROOT') or Path(os.environ['LOCALAPPDATA']) / 'Android/Sdk')
def pick(pattern):
found = sorted(sdk.glob(pattern), reverse=True)
if not found: raise RuntimeError(f'Android SDK tool missing: {sdk / pattern}')
return found[0]
java = next((ROOT / 'tools/java21').glob('*/bin/java.exe'), None)
jar = ROOT / 'tools/ovrport-cli/overportcli-1.2.3-all.jar'
if not java or not jar.exists(): raise RuntimeError('Keep this app beside the existing tools folder (Java 21 and OVR Port CLI).')
clangs = sorted(sdk.glob('ndk/*/toolchains/llvm/prebuilt/windows-x86_64/bin/clang.exe'), reverse=True)
return java, jar, pick('build-tools/*/aapt.exe').parent, clangs[0] if clangs else None
def convert(self, backup, title, scale=100, controllers=True, foveation=True, steam_id='', refresh_rate=0):
self.progress('Conversion 1/7: inspecting APK')
refresh_rate = refresh_value(refresh_rate)
backup = Path(backup)
java, jar, build, clang = self.toolchain()
source_apk = backup / 'base.apk'
if not source_apk.exists(): source_apk = backup / 'apk/base.apk'
badging = self.run([build / 'aapt.exe', 'dump', 'badging', source_apk])
package = valid_package(re.search(r"package: name='([^']+)'", badging)[1])
with zipfile.ZipFile(source_apk) as apk:
if not any(n.startswith('lib/arm64-v8a/') for n in apk.namelist()):
raise RuntimeError('Game has no ARM64 libraries; this conversion profile does not support it.')
if steam_id and not str(steam_id).isdigit(): raise ValueError('Steam artwork App ID must contain digits only')
folder = ROOT / 'games' / package / time.strftime('build-%Y%m%d-%H%M%S')
folder.mkdir(parents=True, exist_ok=False)
workspace = ROOT / 'tools/ovrport-workspace'
self.log('Converting with pinned OVR Port ' + LIB_VERSION)
self.progress('Conversion 2/7: OVR Port patching (percentage unavailable)')
self.run([java, '-jar', jar, 'patch', f'--input={source_apk}', f'--output={folder}',
'--output-name=ovrport.apk', f'--workspace={workspace}', f'--version={LIB_VERSION}'])
self.log('Building Frame compatibility adapter and resolution control…')
self.progress('Conversion 3/7: preparing Frame adapter')
library = folder / 'libopenxr_loader_generic.so'
prebuilt = ASSETS / 'native/libopenxr_loader_generic.so'
if prebuilt.exists():
if b'Q2F_REFRESH_RATE_V1' not in prebuilt.read_bytes():
raise RuntimeError('Bundled adapter is outdated. Run build-native.ps1 and retry conversion.')
shutil.copy2(prebuilt, library)
else:
if not clang: raise RuntimeError('Missing bundled adapter and Android NDK compiler')
include = ASSETS / 'native/include'
if not (include / 'openxr/openxr.h').exists():
raise RuntimeError('Run build-native.ps1 to obtain verified OpenXR headers and build the adapter.')
self.run([clang, '--target=aarch64-linux-android29', f'--sysroot={clang.parent.parent / "sysroot"}',
'-shared', '-fPIC', '-O2', '-Wall', '-Wextra', '-Werror', '-Wl,-Bsymbolic',
'-Wl,-soname,libopenxr_loader_generic.so', '-I', include,
ASSETS / 'native/frame_bridge.c', '-ldl', '-llog', '-o', library])
prefix = 'lib/arm64-v8a/'
original = prefix + 'libopenxr_loader_generic.so'
with zipfile.ZipFile(folder / 'ovrport.apk') as src, zipfile.ZipFile(folder / 'unsigned.apk', 'w', zipfile.ZIP_DEFLATED) as dst:
if original not in src.namelist(): raise RuntimeError('OVR Port output has no generic loader; incompatible game/profile')
total = sum(item.file_size for item in src.infolist() if item.filename != original and not item.filename.startswith('META-INF/'))
completed = 0
self.progress('Conversion 4/7: repacking APK', 0)
for item in src.infolist():
if item.filename == original or item.filename.startswith('META-INF/'): continue
dst.writestr(item, src.read(item.filename))
completed += item.file_size
self.progress('Conversion 4/7: repacking APK', 100 * completed / max(total,1))
dst.writestr(prefix + 'libopenxr_loader_original.so', src.read(original))
dst.write(library, original)
dst.writestr(prefix + 'libframe_settings.so', settings_text(scale, controllers, foveation, refresh_rate))
self.progress('Conversion 5/7: aligning APK')
self.run([build / 'zipalign.exe', '-f', '-P', '16', '4', folder / 'unsigned.apk', folder / 'aligned.apk'])
key = workspace / 'signatures' / (package + '.keystore')
self.progress('Conversion 6/7: signing APK')
self.run([java, '-jar', build / 'lib/apksigner.jar', 'sign', '--ks', key, '--ks-pass', 'pass:password', '--out', folder / 'game.apk', folder / 'aligned.apk'])
self.progress('Conversion 7/7: verifying signature and checksum')
self.run([java, '-jar', build / 'lib/apksigner.jar', 'verify', '--verbose', folder / 'game.apk'])
label = re.search(r"application-label:'([^']+)'", badging)
manifest = {'package': package, 'title': title.strip() or (label[1] if label else package),
'backup': str(backup.resolve()), 'scale': float(scale), 'controllers': controllers,
'foveation': foveation, 'steam_id': str(steam_id), 'ovrport': LIB_VERSION, 'refresh_rate': refresh_rate,
'sha256': digest(folder / 'game.apk')}
write_json(folder / 'game.json', manifest)
self.progress('Conversion complete', 100)
self.log('Conversion complete. Wake Frame for Wi-Fi installation, or connect it by USB, then press Install.')
return folder
def connect(self, host, username, password, port=22):
"""Trust an SSH key only after comparing it to the physically connected Frame."""
if not host.strip() or not username.strip(): raise ValueError('Enter the Frame SSH host and username')
client = paramiko.SSHClient()
known = APP / 'known_hosts'
if known.exists(): client.load_host_keys(str(known))
key_host = host if int(port) == 22 else f'[{host}]:{port}'
if not client.get_host_keys().lookup(key_host):
if 'frame' not in self.devices():
raise RuntimeError('First SSH connection: plug in Frame by USB so its host key can be verified securely.')
key = self.shell('frame', 'cat', '/etc/ssh/ssh_host_ed25519_key.pub').strip().split()
if len(key) < 2 or key[0] != 'ssh-ed25519': raise RuntimeError('Could not read Frame SSH host key')
client.get_host_keys().add(key_host, key[0], paramiko.Ed25519Key(data=base64.b64decode(key[1])))
client.set_missing_host_key_policy(paramiko.RejectPolicy())
client.connect(host.strip(), port=int(port), username=username.strip(), password=password,
look_for_keys=False, allow_agent=False, timeout=10, auth_timeout=15)
client.save_host_keys(str(known))
self.log('SSH identity and authentication verified.')
return client
def remote(self, client, command, check=True):
if self.awake_guard is not None:self.awake_guard.check()
_, out, err = client.exec_command(command, timeout=60)
output = out.read().decode('utf-8', 'replace') + err.read().decode('utf-8', 'replace')
code = out.channel.recv_exit_status()
if output.strip(): self.log(output.strip())
if check and code: raise RuntimeError(f'Frame command failed ({code}): {output[-2000:]}')
return output.strip()
def put_text(self, client, remote, text):
with client.open_sftp() as sftp:
with sftp.file(remote + '.tmp', 'w') as f: f.write(text.encode('utf-8'))
sftp.posix_rename(remote + '.tmp', remote)
def artwork(self, steam_id, folder):
if not str(steam_id).isdigit(): raise ValueError('Steam App ID must be numeric')
folder = Path(folder); folder.mkdir(exist_ok=True)
assets = {'portrait': 'library_600x900.jpg', 'landscape': 'header.jpg', 'hero': 'library_hero.jpg', 'logo': 'logo.png'}
result = {}
for kind, filename in assets.items():
path = folder / filename
try:
url = f'https://cdn.akamai.steamstatic.com/steam/apps/{steam_id}/{filename}'
with urllib.request.urlopen(url, timeout=30) as response: data = response.read()
if not (data.startswith(b'\x89PNG') or data.startswith(b'\xff\xd8')): raise ValueError('Not an image')
path.write_bytes(data); result[kind] = path
except Exception as exc: self.log(f'Artwork {kind} unavailable: {exc}')
return result
def storage_locations(self, client):
return json.loads(self.remote(client,'python3 -c '+shlex.quote(PROBE)))
def manage_game(self, client, action, **values):
request=dict(action=action,mounts=self.storage_locations(client),**values)
code=(ASSETS/'manage_remote.py').read_text(encoding='utf-8')+'\nmain(json.loads('+repr(json.dumps(request))+'))\n'
return json.loads(self.remote(client,'python3 -c '+shlex.quote(code)))
def installed_games(self, client):
return self.manage_game(client,'list')
def uninstall(self, client, package, keep_saves=True, expected=None):
package=valid_package(package)
game=self.manage_game(client,'check',package=package)
if expected is not None and game!={k:v for k,v in expected.items() if k!='error'}:
raise RuntimeError('Installation changed since refresh. Refresh and review it again.')
running=self.remote(client,'podman ps --format "{{.Names}}"').splitlines()
if 'lepton-steamlaunch-'+str(game['appid']) in running:
raise RuntimeError('Close the game before uninstalling it.')
home=self.remote(client,'printf %s "$HOME"')
with client.open_sftp() as sftp:
users=[n for n in sftp.listdir(home+'/.local/share/Steam/userdata') if n!='0' and n.isdigit()]
if len(users)!=1:raise RuntimeError('Expected exactly one Steam user; no files removed.')
path=home+'/.local/share/Steam/userdata/'+users[0]+'/config/shortcuts.vdf'
service=self.remote(client,'systemctl --user is-active steam.service',check=False)=='active'
try:
if service:self.remote(client,'systemctl --user stop steam.service')
else:self.remote(client,'steam -shutdown >/dev/null 2>&1',check=False)
for _ in range(40):
if not self.remote(client,'pgrep -x steam',check=False):break
time.sleep(1)
else:raise RuntimeError('Steam did not close; no files removed.')
with client.open_sftp() as sftp:
try:
with sftp.file(path,'rb') as stream:original=stream.read()
except FileNotFoundError:original=b''
updated=remove_shortcut(original,'"'+game['anchor']+'/launch.sh"',game['appid'])
# Validate the VDF before removing any game data; retain a restore copy.
if original:
with sftp.file(path+'.backup-uninstall-'+str(time.time_ns()),'wb') as stream:stream.write(original)
result=self.manage_game(client,'delete',package=package,expected=game,keep_saves=bool(keep_saves))
if original!=updated:
with sftp.file(path+'.quest-frame.tmp','wb') as stream:stream.write(updated)
sftp.posix_rename(path+'.quest-frame.tmp',path)
self.log('Uninstalled '+package)
self.log('Saves preserved at '+result['saves'] if result['saves'] else 'No save backup retained on Frame.')
return result
finally:
if service:self.remote(client,'systemctl --user start steam.service',check=False)
else:self.remote(client,'systemd-run --user --collect /usr/bin/steam',check=False)
def deployment_base(self, client, deployment):
uuid=deployment.get('storage_uuid','')
if not uuid:return deployment['remote']
choice=select_storage(self.storage_locations(client),'uuid:'+uuid)
return choice['mount']+'/Applications/quest-frame/'+valid_package(deployment['package'])
def transfer_file(self, client, local, remote, transport):
if self.awake_guard is not None:self.awake_guard.check()
stage = 'Installing: ' + Path(local).name
self.progress(stage)
if transport == 'usb':
self.adb_run('frame', 'push', '-Z', local, remote)
elif transport == 'wifi':
self.log('Uploading over SSH: ' + Path(local).name)
last = [0.0]
def transferred(done, total):
if self.awake_guard is not None:self.awake_guard.check()
now = time.monotonic()
if done == total or now - last[0] >= .1:
last[0] = now
self.progress(f'{stage} ({done/1048576:.1f} / {total/1048576:.1f} MiB)', 100 * done / total if total else 100)
with client.open_sftp() as sftp:
sftp.put(str(local), remote, callback=transferred)
else:
raise ValueError('Select USB or Wi-Fi installation')
self.progress(stage, 100)
def install(self, folder, client, add_steam=True, artwork_id=None, storage_id='internal', transport='usb'):
if transport == 'wifi':
self.progress('Installation: keeping Frame awake')
with FrameAwake(client, self.log) as guard:
self.awake_guard=guard
try:return self._install(folder,client,add_steam,artwork_id,storage_id,transport)
finally:self.awake_guard=None
return self._install(folder,client,add_steam,artwork_id,storage_id,transport)
def _install(self, folder, client, add_steam=True, artwork_id=None, storage_id='internal', transport='usb'):
self.progress('Installation: checking APK, connection and storage')
if transport not in ('usb', 'wifi'): raise ValueError('Select USB or Wi-Fi installation')
folder = Path(folder); game = json.loads((folder / 'game.json').read_text())
check_refresh_adapter(folder, game.get('refresh_rate', 0))
package = valid_package(game['package']); backup = Path(game['backup'])
obb_source = backup / 'obb'
data_source = backup / 'data'
if not obb_source.exists(): obb_source = backup / 'Android/obb' / package
if not data_source.exists(): data_source = backup / 'Android/data' / package
if digest(folder / 'game.apk') != game['sha256']: raise RuntimeError('APK checksum differs from build manifest')
if transport == 'usb':
if 'frame' not in self.devices(): raise RuntimeError('Plug in Frame by USB or select Wi-Fi installation.')
# Verify SSH and USB point to the same physical device.
usb_key = self.shell('frame', 'cat', '/etc/machine-id').strip()
ssh_key = self.remote(client, 'cat /etc/machine-id')
if usb_key != ssh_key: raise RuntimeError('USB Frame and SSH Frame do not match')
home = self.remote(client, 'printf %s "$HOME"')
storage=select_storage(self.storage_locations(client),storage_id)
anchor=home+'/Applications/quest-frame/'+package
base = storage['mount'] + '/Applications/quest-frame/' + package
q = shlex.quote
launcher = anchor + '/launch.sh'; exe = '"' + launcher + '"'
ident = app_id(exe, game['title'])
# Existing IDs must survive title changes; use the previous deployment metadata.
old = self.remote(client, f'cat {q(anchor + "/deployment.json")}', check=False)
try: old_deployment=json.loads(old)
except ValueError: old_deployment={}
if old_deployment and old_deployment.get('storage_uuid','')!=storage['uuid']:
raise RuntimeError('Game already installed on a different storage device. Choose its existing location; moving existing games/saves is not supported automatically.')
if old_deployment.get('appid'):ident=int(old_deployment['appid'])
if storage['uuid'] and not old_deployment:
if self.remote(client,'test -d '+q(anchor+'/lepton-app')+' && echo EXISTS',check=False):
raise RuntimeError('Existing internal game found. Select internal storage to preserve its saves.')
running = self.remote(client, f'podman ps --format "{{{{.Names}}}}"', check=False)
if f'lepton-steamlaunch-{ident}' in running.splitlines():
raise RuntimeError('Close this game on Frame before updating it. Other games will not be stopped.')
lepton = home + '/.local/share/Steam/steamapps/common/Lepton/lepton'
self.remote(client, f'test -x {q(lepton)}')
required = (folder / 'game.apk').stat().st_size + sum(p.stat().st_size for p in obb_source.rglob('*') if p.is_file())
required += sum(p.stat().st_size for p in data_source.rglob('*') if p.is_file())
if storage['free'] < required*2 + 512*1024**2: raise RuntimeError('Not enough free space on selected storage for safe staged deployment')
stamp = time.strftime('%Y%m%d-%H%M%S')
stage = base + '/staging-' + stamp
if storage['uuid']:
self.remote(client,'mountpoint -q -- '+q(storage['mount']))
self.remote(client, f'mkdir -p {q(anchor)} {q(stage + "/obb")} {q(base + "/lepton-data/external/Android/data/" + package + "/files")}')
self.log('Installing over '+('Wi-Fi SSH/SFTP. Keep Frame awake and on the network.' if transport=='wifi' else 'USB ADB. Keep Frame connected.'))
self.transfer_file(client, folder / 'game.apk', stage + '/game.apk', transport)
for file in obb_source.rglob('*'):
if file.is_file():
relative = file.relative_to(obb_source).as_posix()
dest = stage + '/obb/' + relative
self.remote(client, 'mkdir -p ' + q(str(PurePosixPath(dest).parent)))
self.transfer_file(client, file, dest, transport)
self.progress('Installation: verifying ' + file.name)
actual = self.remote(client, 'sha256sum ' + q(dest)).split()[0]
if actual != digest(file): raise RuntimeError('OBB verification failed; previous installation untouched')
self.progress('Installation: verifying transferred APK')
if self.remote(client, 'sha256sum ' + q(stage + '/game.apk')).split()[0] != game['sha256']:
raise RuntimeError('APK transfer verification failed; previous installation untouched')
# Never overwrite Frame progress with an older Quest save on a repeat install.
data_root = base + '/lepton-data/external/Android/data/' + package
for file in data_source.rglob('*'):
if not file.is_file(): continue
relative = file.relative_to(data_source).as_posix()
if relative == 'files/framebridge.conf': continue
destination = data_root + '/' + relative
self.progress('Installation: checking existing save ' + file.name)
exists = self.remote(client, f'if test -e {q(destination)}; then echo EXISTS; fi')
if exists: continue
self.remote(client, 'mkdir -p ' + q(str(PurePosixPath(destination).parent)))
self.transfer_file(client, file, destination, transport)
self.progress('Installation: applying settings and preserving previous installation')
self.put_text(client, data_root + '/files/framebridge.conf', settings_text(game['scale'], game['controllers'], game['foveation'], game.get('refresh_rate', 0)))
self.put_text(client, base + '/settings.conf', settings_text(game['scale'], game['controllers'], game['foveation'], game.get('refresh_rate', 0)))
# Preserve all prior application and baked private data on updates.
self.remote(client, f'if test -d {q(base + "/lepton-app")}; then mv {q(base + "/lepton-app")} {q(base + "/previous-app-" + stamp)}; fi; mv {q(stage)} {q(base + "/lepton-app")}')
self.remote(client, f'if test -d {q(base + "/lepton-data/baked")}; then cp -a --reflink=auto {q(base + "/lepton-data/baked")} {q(base + "/previous-baked-" + stamp)}; fi')
script = launcher_text(base, ident, lepton,card_launcher(storage['uuid'],package) if storage['uuid'] else '')
self.put_text(client, launcher, script)
self.remote(client, 'chmod 755 ' + q(launcher))
deployment={'remote':base,'anchor':anchor,'appid':ident,'package':package,'storage_uuid':storage['uuid']}
write_json(folder / 'deployment.json',deployment)
self.put_text(client, anchor + '/deployment.json', json.dumps(deployment))
if add_steam:
self.progress('Installation: artwork and Steam shortcut (Steam may restart)')
arts = self.artwork(artwork_id or game['steam_id'], folder / 'artwork') if (artwork_id or game['steam_id']) else {}
self.add_shortcut(client, anchor, game['title'], ident, arts)
self.log('Installed. Launch from Steam; Lepton initializes the dedicated Android container on first launch.')
self.progress('Installation complete', 100)
return base, ident
def add_shortcut(self, client, base, title, expected_id, arts):
q = shlex.quote
home = self.remote(client, 'printf %s "$HOME"')
steam = home + '/.local/share/Steam'
with client.open_sftp() as sftp:
users = [n for n in sftp.listdir(steam + '/userdata') if n != '0' and n.isdigit()]
if len(users) != 1: raise RuntimeError('Multiple/no Steam users found; game installed, but select a Steam account manually before adding its shortcut.')
config = steam + '/userdata/' + users[0] + '/config'
self.log('Closing Steam briefly to update its library safely. It will restart afterward.')
service = self.remote(client, 'systemctl --user is-active steam.service', check=False) == 'active'
if service: self.remote(client, 'systemctl --user stop steam.service')
else: self.remote(client, 'steam -shutdown >/dev/null 2>&1', check=False)
for _ in range(40):
if not self.remote(client, 'pgrep -x steam', check=False): break
time.sleep(1)
else:
if service: self.remote(client, 'systemctl --user start steam.service', check=False)
raise RuntimeError('Steam did not close; library not modified. Close it and retry.')
try:
self.remote(client, 'mkdir -p ' + q(config + '/grid'))
with client.open_sftp() as sftp:
path = config + '/shortcuts.vdf'
try:
with sftp.file(path, 'rb') as f: original = f.read()
except FileNotFoundError: original = b''
icon = base + '/artwork/' + arts.get('logo', arts.get('landscape', Path(''))).name if arts else ''
updated, ident = upsert(original, '"' + base + '/launch.sh"', title, base, icon)
if ident != expected_id: raise RuntimeError('Existing Steam shortcut ID differs from launcher; library not modified')
if original:
with sftp.file(path + '.backup-' + time.strftime('%Y%m%d-%H%M%S'), 'wb') as f: f.write(original)
with sftp.file(path + '.quest-frame.tmp', 'wb') as f: f.write(updated)
sftp.posix_rename(path + '.quest-frame.tmp', path)
self.remote(client, 'mkdir -p ' + q(base + '/artwork'))
suffixes = {'portrait': 'p', 'landscape': '', 'hero': '_hero', 'logo': '_logo'}
for kind, local in arts.items():
dest = config + '/grid/' + str(ident) + suffixes[kind] + local.suffix
try: sftp.stat(dest)
except FileNotFoundError: pass
else: sftp.posix_rename(dest, dest + '.backup-' + time.strftime('%Y%m%d-%H%M%S'))
sftp.put(str(local), dest)
sftp.put(str(local), base + '/artwork/' + local.name)
self.log(f'Steam VR shortcut added: {title} ({ident}); {len(arts)} artwork assets.')
finally:
if service: self.remote(client, 'systemctl --user start steam.service', check=False)
else: self.remote(client, 'systemd-run --user --collect --unit=quest-frame-steam-' + str(int(time.time())) + ' /usr/bin/steam', check=False)
def update_settings(self, folder, client, scale, controllers, foveation, refresh_rate=0):
folder = Path(folder)
check_refresh_adapter(folder, refresh_rate)
deployment = json.loads((folder / 'deployment.json').read_text())
package = valid_package(deployment['package'])
base=self.deployment_base(client,deployment)
path = base + '/lepton-data/external/Android/data/' + package + '/files/framebridge.conf'
self.put_text(client, path, settings_text(scale, controllers, foveation, refresh_rate))
self.put_text(client, base + '/settings.conf', settings_text(scale, controllers, foveation, refresh_rate))
game = json.loads((folder / 'game.json').read_text())
game.update(scale=float(scale), controllers=bool(controllers), foveation=bool(foveation), refresh_rate=refresh_value(refresh_rate))
write_json(folder / 'game.json', game)
self.log('Resolution/settings saved. Restart the game to apply. No APK rebuild is needed.')