mirror of
https://github.com/vesper8/Quest2Frame.git
synced 2026-10-08 13:04:30 +02:00
740 lines
48 KiB
Python
740 lines
48 KiB
Python
# 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<int.from_bytes(data[16:20],'big')<=96 and 0<int.from_bytes(data[20:24],'big')<=96:
|
||
icons[record['package']]=record['icon']
|
||
except (ValueError,TypeError):pass
|
||
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),**({'icon':icons[p]} if p in icons else {})) 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, headset='Default', rect_fix=False):
|
||
self.progress('Conversion 1/7: inspecting APK')
|
||
refresh_rate = refresh_value(refresh_rate)
|
||
headset = headset_value(headset)
|
||
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)
|
||
if headset != 'Default' or rect_fix:
|
||
return self.rebuild_apk(folder, title, scale, controllers, foveation, steam_id, refresh_rate, headset, rect_fix)
|
||
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 rebuild_apk(self, folder, title, scale=100, controllers=True, foveation=True,
|
||
steam_id='', refresh_rate=0, headset='Default', rect_fix=None):
|
||
"""Repack a converted APK; reuse OBBs and preserve game-specific native fixes."""
|
||
folder = Path(folder)
|
||
game = json.loads((folder/'game.json').read_text())
|
||
rect_fix = rect_fix_value(game) if rect_fix is None else bool(rect_fix)
|
||
headset = headset_value(headset)
|
||
config = settings_text(scale, controllers, foveation, refresh_rate)
|
||
if digest(folder/'game.apk') != game['sha256']:
|
||
raise ValueError('Source APK checksum mismatch; rebuild refused')
|
||
baseline = Path(game.get('identity_base_apk', folder/'game.apk'))
|
||
baseline_hash = game.get('identity_base_sha256', game['sha256'])
|
||
if digest(baseline) != baseline_hash:
|
||
raise ValueError('Original identity baseline changed; rebuild refused')
|
||
java, jar, build, _ = self.toolchain()
|
||
package = valid_package(game['package'])
|
||
output = ROOT/'games'/package/('rebuild-'+time.strftime('%Y%m%d-%H%M%S')+'-'+uuid.uuid4().hex[:6])
|
||
output.mkdir(parents=True, exist_ok=False)
|
||
prefix = 'lib/arm64-v8a/'
|
||
self.progress('APK rebuild: inspecting existing compatibility fixes')
|
||
with zipfile.ZipFile(baseline) as src:
|
||
wrapped = prefix+'libq2f_original.so' in src.namelist()
|
||
if wrapped and rect_fix_value(game) and not rect_fix:
|
||
raise ValueError('This original build already includes the black-screen fix. Open a build from before that fix to disable it.')
|
||
runtime = prefix+('libq2f_original.so' if prefix+'libq2f_original.so' in src.namelist() else 'libopenxr_loader.so')
|
||
replacements = {runtime: patch_runtime(src.read(runtime), headset),
|
||
prefix+'libframe_settings.so': config.encode()}
|
||
existing = src.read(prefix+'libopenxr_loader_generic.so')
|
||
if refresh_value(refresh_rate) and b'Q2F_REFRESH_RATE_V1' not in existing:
|
||
# Never silently replace a custom native fix to obtain a new capability.
|
||
raise ValueError('This build lacks refresh-rate support. Reconvert first; its custom adapter was not replaced.')
|
||
if headset == 'Quest 3':
|
||
for name in src.namelist():
|
||
if not re.fullmatch(r'classes\d*\.dex', name): continue
|
||
raw = src.read(name)
|
||
if b'Quest 2' not in raw or (b'com/epicgames/ue4/GameActivity' not in raw and b'com/epicgames/unreal/GameActivity' not in raw): continue
|
||
self.progress('APK rebuild: updating Unreal headset model')
|
||
dex = output/name; dex.write_bytes(raw)
|
||
smali = output/(name+'.smali')
|
||
self.run([java, '-cp', jar, 'org.jf.baksmali.Main', 'disassemble', dex, '-o', smali])
|
||
changed = False
|
||
for relative in ('com/epicgames/ue4/GameActivity.smali', 'com/epicgames/unreal/GameActivity.smali'):
|
||
path = smali/relative
|
||
if not path.exists(): continue
|
||
text = path.read_text(encoding='utf-8'); patched = patch_unreal_smali(text)
|
||
if text != patched:
|
||
path.write_text(patched, encoding='utf-8'); changed = True
|
||
if changed:
|
||
rebuilt = output/('rebuilt-'+name)
|
||
self.run([java, '-cp', jar, 'org.jf.smali.Main', 'assemble', smali, '-o', rebuilt])
|
||
replacements[name] = rebuilt.read_bytes()
|
||
self.progress('APK rebuild: repacking (OBBs untouched)', 0)
|
||
with zipfile.ZipFile(output/'unsigned.apk', 'w', zipfile.ZIP_DEFLATED) as dst:
|
||
entries = [i for i in src.infolist() if not i.filename.startswith('META-INF/')]
|
||
for index, item in enumerate(entries):
|
||
dst.writestr(item, replacements.pop(item.filename, None) if item.filename in replacements else src.read(item.filename))
|
||
self.progress('APK rebuild: repacking (OBBs untouched)', 100*(index+1)/len(entries))
|
||
for name, data in replacements.items(): dst.writestr(name, data)
|
||
self.progress('APK rebuild: aligning and signing')
|
||
self.run([build/'zipalign.exe', '-f', '-P', '16', '4', output/'unsigned.apk', output/'aligned.apk'])
|
||
key = ROOT/'tools/ovrport-workspace/signatures'/(package+'.keystore')
|
||
self.run([java, '-jar', build/'lib/apksigner.jar', 'sign', '--ks', key, '--ks-pass', 'pass:password', '--out', output/'game.apk', output/'aligned.apk'])
|
||
self.run([java, '-jar', build/'lib/apksigner.jar', 'verify', '--verbose', output/'game.apk'])
|
||
if rect_fix and not wrapped:
|
||
from build_passthrough import build as build_rect_adapter
|
||
self.progress('APK rebuild: adding black-screen compatibility fix')
|
||
fixed = output/'rect-adapter'
|
||
build_rect_adapter(output/'game.apk', fixed, bridge=self)
|
||
shutil.copy2(fixed/'game.apk', output/'game.apk')
|
||
game.update(title=title.strip() or game['title'], scale=float(scale), controllers=bool(controllers),
|
||
foveation=bool(foveation), steam_id=str(steam_id), refresh_rate=refresh_value(refresh_rate),
|
||
headset=headset, built_headset=headset, sha256=digest(output/'game.apk'),
|
||
rect_fix=rect_fix, built_rect_fix=rect_fix,
|
||
identity_base_apk=str(baseline.resolve()), identity_base_sha256=baseline_hash,
|
||
update_source_sha256=game.get('update_source_sha256', game['sha256']))
|
||
write_json(output/'game.json', game)
|
||
if (folder/'deployment.json').exists(): shutil.copy2(folder/'deployment.json', output/'deployment.json')
|
||
self.progress('APK-only rebuild complete; OBBs unchanged', 100)
|
||
return output
|
||
|
||
def install_apk_only(self, folder, client, transport='wifi'):
|
||
return self._install_apk_only(folder,client,transport)
|
||
|
||
def _install_apk_only(self, folder, client, transport='wifi'):
|
||
"""Update one existing deployment, without traversing/copying OBBs or saves."""
|
||
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'])
|
||
deployment = json.loads((folder/'deployment.json').read_text())
|
||
if deployment['package'] != package: raise ValueError('Deployment/package mismatch')
|
||
if digest(folder/'game.apk') != game['sha256']: raise ValueError('APK checksum mismatch')
|
||
if transport == 'usb':
|
||
if 'frame' not in self.devices(): raise ValueError('Connect Frame by USB or select Wi-Fi')
|
||
if self.shell('frame','cat','/etc/machine-id').strip() != self.remote(client,'cat /etc/machine-id'):
|
||
raise ValueError('USB Frame and SSH Frame do not match')
|
||
base = self.deployment_base(client, deployment)
|
||
anchor = deployment.get('anchor', deployment['remote'])
|
||
q = shlex.quote
|
||
# Resolve UUID again through deployment_base; never assume the SD label.
|
||
actual = json.loads(self.remote(client, 'cat '+q(anchor+'/deployment.json')))
|
||
if actual.get('package') != package or actual.get('appid') != deployment['appid'] or actual.get('storage_uuid','') != deployment.get('storage_uuid',''):
|
||
raise ValueError('Installed deployment changed; reopen its current build')
|
||
apk = base+'/lepton-app/game.apk'
|
||
expected = game.get('update_source_sha256', game['sha256'])
|
||
def preflight():
|
||
running = self.remote(client, 'podman ps --format "{{.Names}}"')
|
||
if 'lepton-steamlaunch-'+str(int(deployment['appid'])) in running.splitlines():
|
||
raise ValueError('Close this game on Frame before updating its APK')
|
||
if self.remote(client, 'sha256sum '+q(apk)).split()[0] != expected:
|
||
raise ValueError('Installed APK differs from this rebuild source. Refusing to overwrite other compatibility fixes.')
|
||
preflight()
|
||
stage = apk+'.staging-'+uuid.uuid4().hex
|
||
previous = base+'/previous-apk-'+uuid.uuid4().hex+'.apk'
|
||
self.progress('APK-only installation: uploading APK; OBBs and saves untouched')
|
||
self.transfer_file(client, folder/'game.apk', stage, transport)
|
||
if self.remote(client, 'sha256sum '+q(stage)).split()[0] != game['sha256']:
|
||
raise ValueError('APK verification failed; existing APK untouched')
|
||
preflight()
|
||
# Keep the old APK recoverable. Atomic rename exposes only a complete APK.
|
||
self.remote(client, 'set -e; cp -p --reflink=auto '+q(apk)+' '+q(previous)+'; chmod 644 '+q(stage)+'; mv -f '+q(stage)+' '+q(apk))
|
||
config = settings_text(game['scale'], game['controllers'], game['foveation'], game.get('refresh_rate',0))
|
||
self.put_text(client, base+'/settings.conf', config)
|
||
self.put_text(client, base+'/lepton-data/external/Android/data/'+package+'/files/framebridge.conf', config)
|
||
game.pop('update_source_sha256', None)
|
||
write_json(folder/'game.json', game)
|
||
self.progress('APK updated; OBBs and saves unchanged', 100)
|
||
self.log('Previous APK retained at '+previous+'. Restart the game to test the new settings.')
|
||
|
||
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):
|
||
_, 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):
|
||
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.')
|