# 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 headset_profile import headset_value, patch_runtime, patch_unreal_smali 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 rect_fix_value(game): return bool(game.get('rect_fix', game.get('compatibility_fix') == 'swapchain-rectangle-rounding')) def check_rect_build(game, selected=None): built = bool(game.get('built_rect_fix', game.get('compatibility_fix') == 'swapchain-rectangle-rounding')) if (rect_fix_value(game) if selected is None else bool(selected)) != built: raise ValueError('Black-screen fix changed. Use Rebuild APK only before installing or applying settings.') def check_headset_build(game, headset=None): selected = headset_value(game.get('headset', 'Default') if headset is None else headset) if selected != game.get('built_headset', 'Default'): raise ValueError('Reported headset changed. Use Rebuild APK only before installing or applying settings.') 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.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 and not clean.startswith('Q2F_ICON='): 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={};icons={} 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_ICON='): try: record=json.loads(line[len('Q2F_ICON='):]) if isinstance(record,dict) and record.get('package') in group and isinstance(record.get('icon'),str) and len(record['icon'])<100000: data=base64.b64decode(record['icon'],validate=True) if data.startswith(b'\x89PNG\r\n\x1a\n') and len(data)>=24 and 0/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): 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): 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'): 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_headset_build(game) check_rect_build(game) 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, headset=None, rect_fix=None): folder = Path(folder) check_refresh_adapter(folder, refresh_rate) if headset is not None: check_headset_build(json.loads((folder/'game.json').read_text()), headset) if rect_fix is not None: check_rect_build(json.loads((folder/'game.json').read_text()), rect_fix) 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.')