mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
Merge branch 'sidequest' into apk-store
This commit is contained in:
commit
a24d27013c
10 files changed
+809
No files matched your search
@@ -0,0 +1,41 @@
|
||||
"""SideQuest page links only: its terms do not authorise third-party scraping.
|
||||
|
||||
No API calls, cached listings or automated downloads. See docs/sidequest.md.
|
||||
"""
|
||||
from . import SourceError
|
||||
|
||||
KIND = 'sidequest'
|
||||
URL = 'https://sidequestvr.com'
|
||||
REASON = ('SideQuest is page-only: its terms restrict scraping and unauthorised '
|
||||
'access. Browse and download with SideQuest, then import a developer-provided APK.')
|
||||
|
||||
|
||||
def sources():
|
||||
return [{'id': KIND, 'kind': KIND, 'name': 'SideQuest', 'url': URL,
|
||||
'builtin': True, 'enabled': True, 'trust': 'community',
|
||||
'page_only': True, 'reason': REASON}]
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
# Do not invent catalogue results or interpret a query as a verified free app.
|
||||
if limit <= 0:
|
||||
return []
|
||||
raise SourceError(REASON + ' ' + URL + '/apps')
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
entry_id = str(entry_id)
|
||||
if not entry_id.isascii() or not entry_id.isdecimal() or len(entry_id) > 12:
|
||||
raise SourceError('SideQuest listing ids must be numeric')
|
||||
return {'source': source['id'], 'id': entry_id, 'package': None,
|
||||
'name': 'SideQuest listing ' + entry_id, 'summary': REASON,
|
||||
'icon': None, 'page': URL + '/app/' + entry_id, 'version': None,
|
||||
'version_code': None, 'min_sdk': None, 'abis': None, 'vr': None,
|
||||
'size': None, 'free': None, 'license': None, 'updated': None,
|
||||
'downloadable': False, 'versions': [], 'tags': [], 'headsets': [],
|
||||
'images': {'icon': None, 'banner': None, 'screenshots': []}}
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
entry = details(source, entry_id)
|
||||
raise SourceError(REASON + ' ' + entry['page'])
|
||||
@@ -8,6 +8,7 @@ Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
||||
|
||||
Python stdlib only. CLI: python3 ui/frame_android.py
|
||||
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
|
||||
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
|
||||
patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
"""
|
||||
import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
@@ -333,6 +334,21 @@ def patch(src, dst, add=None):
|
||||
raise FrameError(str(e)) from e
|
||||
|
||||
|
||||
def install_obb(pkg, paths):
|
||||
import frame_android_data
|
||||
return frame_android_data.install_obb(pkg, paths)
|
||||
|
||||
|
||||
def backup_data(pkg, destination):
|
||||
import frame_android_data
|
||||
return frame_android_data.backup_data(pkg, destination)
|
||||
|
||||
|
||||
def restore_data(pkg, archive):
|
||||
import frame_android_data
|
||||
return frame_android_data.restore_data(pkg, archive)
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['help']
|
||||
try:
|
||||
@@ -368,6 +384,14 @@ def main():
|
||||
with open(path, 'rb') as f:
|
||||
additions[entry] = f.read()
|
||||
r = patch(opts.src, opts.dst, additions)
|
||||
elif cmd == 'install-obb':
|
||||
if len(args) < 2:
|
||||
raise FrameError('install-obb requires PACKAGE OBB [OBB ...]')
|
||||
r = install_obb(args[0], args[1:])
|
||||
elif cmd in ('backup-data', 'restore-data'):
|
||||
if len(args) != 2:
|
||||
raise FrameError(cmd + ' requires PACKAGE ARCHIVE.tar.gz')
|
||||
r = (backup_data if cmd == 'backup-data' else restore_data)(*args)
|
||||
elif cmd == 'list':
|
||||
r = list_apps()
|
||||
elif cmd in ('launch', 'stop', 'probe'):
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Expansion files and stopped-instance private-data backups. Python stdlib only."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
import frame_android as android
|
||||
|
||||
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
|
||||
|
||||
|
||||
def _stream(command, src=None, dst=None):
|
||||
try:
|
||||
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
||||
stdin=src if src else subprocess.DEVNULL,
|
||||
stdout=dst if dst else subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=1800)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise android.FrameError('app-data transfer timed out')
|
||||
except OSError as error:
|
||||
raise android.FrameError('app-data transfer failed: ' + str(error))
|
||||
if result.returncode:
|
||||
raise android.FrameError(result.stderr.decode(errors='replace').strip()[-600:] or
|
||||
'app-data transfer failed')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, 'rb') as src:
|
||||
for chunk in iter(lambda: src.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _meta(package):
|
||||
if not android.PKG_RE.fullmatch(package or ''):
|
||||
raise android.FrameError('invalid package name')
|
||||
meta = android._meta_or_fail(package)
|
||||
if meta['package'] != package:
|
||||
raise android.FrameError('installed app metadata has a different package')
|
||||
return meta
|
||||
|
||||
|
||||
def install_obb(package, paths):
|
||||
if not android.PKG_RE.fullmatch(package or ''):
|
||||
raise android.FrameError('invalid package name')
|
||||
paths = [Path(p).resolve() for p in paths]
|
||||
if not paths:
|
||||
raise android.FrameError('select at least one OBB file')
|
||||
names = set()
|
||||
for path in paths:
|
||||
if (not re.fullmatch(r'(main|patch)\.[0-9]+\.' + re.escape(package) + r'\.obb', path.name)
|
||||
or not path.is_file() or path.stat().st_size == 0 or path.name in names):
|
||||
raise android.FrameError('OBB must be a nonempty main/patch.<version>.' + package + '.obb file')
|
||||
names.add(path.name)
|
||||
with android._install_lock:
|
||||
meta = _meta(package)
|
||||
container = 'lepton-steamlaunch-' + str(int(meta['instance']))
|
||||
# No implicit launch. Android resolves /sdcard, never an assumed host path.
|
||||
running = android.ssh('podman ps --format "{{.Names}}"').splitlines()
|
||||
if container not in running:
|
||||
raise android.FrameError('start this app instance before installing OBB data')
|
||||
dest = '/sdcard/Android/obb/' + package
|
||||
results = []
|
||||
for path in paths:
|
||||
digest = _sha256(path)
|
||||
part = dest + '/.frame-' + uuid.uuid4().hex + '.part'
|
||||
target = dest + '/' + path.name
|
||||
script = (f'set -eu; mkdir -p {dest}; umask 002; '
|
||||
f'trap "rm -f {part}" EXIT; cat > {part}; '
|
||||
f'test "$(sha256sum {part} | cut -d " " -f 1)" = {digest}; '
|
||||
f'chmod 664 {part}; mv {part} {target}')
|
||||
command = shlex.join(['podman', 'exec', '-i', container, '/system/bin/sh', '-c', script])
|
||||
with path.open('rb') as src:
|
||||
_stream(command, src=src)
|
||||
results.append({'name': path.name, 'path': target, 'sha256': digest})
|
||||
return {'package': package, 'instance': meta['instance'], 'obb': results,
|
||||
'verified': True}
|
||||
|
||||
|
||||
def _data_command(action, meta):
|
||||
container = 'lepton-steamlaunch-' + str(int(meta['instance']))
|
||||
# Fail closed if podman cannot enumerate containers; don't mistake errors for stopped.
|
||||
guard = ('running=$(podman ps --format "{{.Names}}") || exit 1; '
|
||||
f'if printf "%s\\n" "$running" | grep -Fxq {shlex.quote(container)}; then '
|
||||
'echo "stop the app before backup or restore" >&2; exit 1; fi; ')
|
||||
return guard + shlex.join(['podman', 'unshare', 'python3', '-c', REMOTE.read_text(),
|
||||
action, meta['package'], str(int(meta['instance']))])
|
||||
|
||||
|
||||
def backup_data(package, destination):
|
||||
destination = Path(destination).expanduser().absolute()
|
||||
if destination.exists():
|
||||
raise android.FrameError('backup destination already exists')
|
||||
with android._install_lock:
|
||||
meta = _meta(package)
|
||||
fd, temporary = tempfile.mkstemp(prefix='.frame-backup-', dir=str(destination.parent))
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as dst:
|
||||
_stream(_data_command('backup', meta), dst=dst)
|
||||
result = _inspect(temporary, meta)
|
||||
# Exclusive publication: a concurrently created backup is never overwritten.
|
||||
os.link(temporary, str(destination))
|
||||
return dict(result, path=str(destination), sha256=_sha256(destination))
|
||||
finally:
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def _inspect(path, meta):
|
||||
import tarfile
|
||||
try:
|
||||
return runpy.run_path(str(REMOTE))['inspect_archive'](path, meta['package'], int(meta['instance']))
|
||||
except (OSError, EOFError, ValueError, tarfile.TarError) as error:
|
||||
raise android.FrameError('invalid app-data backup: ' + str(error))
|
||||
|
||||
|
||||
def restore_data(package, archive):
|
||||
with android._install_lock:
|
||||
meta = _meta(package)
|
||||
_inspect(archive, meta)
|
||||
with open(archive, 'rb') as src:
|
||||
result = _stream(_data_command('restore', meta), src=src)
|
||||
try:
|
||||
return json.loads(result)
|
||||
except (ValueError, TypeError):
|
||||
raise android.FrameError('could not read restore result; inspect app data before retrying')
|
||||
Reference in new issue
Block a user