Add resumable multi-game Quest backup and Frame installation queues

This commit is contained in:
MichaelScottsman committed 2026-09-26 23:55:53 -04:00
1 parent 2ce46d164b
commit 6883cb86dc
6 files changed
+285 -2

No files matched your search

+2
View File
@@ -4,6 +4,8 @@ Launch `Launch.cmd` or run `python app.py` from the repository root. Local `tool
## Workflow
For multiple games, Ctrl/Shift-select packages and choose **Use selected game(s)**. The batch queue supports per-game settings and artwork, sequential conversion/installation, saved progress, and retrying failed entries. See the [batch instructions](README.md#multiple-games--batch-queue). Keep Quest connected until all requested conversions finish, then switch to Frame. Steam integration currently restarts Steam for each installed game.
0. Enter your Frame SSH host (default `frame`), port, username (`steamos`), and password. Test the connection. Only host/port/username are saved; the password stays in memory. On first connection, attach Frame over USB so the app can verify its SSH host key. Unknown or changed keys are never silently accepted.
1. Plug in Quest, unlock it, accept USB debugging, and click **Scan Quest**. Filter the installed third-party package list and select a game.
2. Choose a library title, resolution, compatibility options, and optionally the matching Steam store App ID for official artwork. Click **Back up Quest game and convert**. Leave Quest connected until conversion completes. The app copies the APK, OBB expansion files, and accessible external saves; originals remain untouched.
+12
View File
@@ -133,6 +133,18 @@ Open the new entry in Frame's Steam library. The first launch initializes its Le
## Change settings or resume later
### Multiple games / batch queue
1. On **Select Quest game**, Ctrl-click or Shift-click multiple packages (or use **Select all visible**), then **Use selected game(s)**. Changing the filter clears the selection.
2. In the batch window, select each row to edit its title, resolution, controller/foveation options, and artwork App ID. Click **Save game settings** before switching rows. Blank titles use the APK title. New games inherit the main window's resolution/compatibility settings, but never another game's title or artwork ID.
3. Select the games to process and click **Back up & convert selected**. Keep the original Quest attached for the entire batch. Games run sequentially; failures are recorded individually and remaining games continue.
4. If automatic artwork search is enabled on the main Convert tab, candidates are saved after each conversion. Select each row to review its candidates, choose the correct edition, and save. You can also enter a title and use **Find artwork by title** manually.
5. Switch the USB connection to Frame, select ready rows, and click **Install selected ready games**. Each game keeps its own settings/artwork. With Steam integration enabled, Steam currently restarts **once per game**; finish active games first.
Queues are automatically saved under `games/batches/` without SSH credentials. Use **Open batch / saved games** to reopen a queue after restarting the app. Cancel that file picker to create an empty queue, then use **Add saved conversions** to select existing `game.json` files. Duplicate packages are skipped.
Retries reuse completed backups/builds and skip already installed rows. Failed installs can be selected and retried; if a shortcut failed after file installation, the retry reinstalls the saved build. Interrupted jobs may need retrying. Select a failed row to see its error; the main activity log contains details. Wait for the active job to finish before closing either window.
Use **Open a previously converted game** and select `games/<package>/build-.../game.json`.
- Change resolution/controller options on step 2, then select **Apply resolution / compatibility settings only** on step 3. Restart the game afterward. With a verified host key, this needs SSH but not USB or a rebuild.
+21 -2
View File
@@ -72,8 +72,12 @@ class Wizard(tk.Tk):
ttk.Button(page,text='Scan Quest and list installed packages',command=self.scan).pack(anchor='w',pady=8)
entry=ttk.Entry(page,textvariable=self.vars['filter']);entry.pack(fill='x')
self.vars['filter'].trace_add('write',lambda *_:self.filter_packages())
self.listbox=tk.Listbox(page,height=8,exportselection=False);self.listbox.pack(fill='both',expand=True,pady=8)
ttk.Button(page,text='Use selected game →',command=self.select_game).pack(anchor='w')
self.listbox=tk.Listbox(page,height=8,exportselection=False,selectmode='extended');self.listbox.pack(fill='both',expand=True,pady=8)
ttk.Label(page,text='Ctrl-click or Shift-click to select multiple games. Filtering clears selection.').pack(anchor='w')
controls=ttk.Frame(page);controls.pack(fill='x')
ttk.Button(controls,text='Use selected game(s) →',command=self.select_game).pack(side='left')
ttk.Button(controls,text='Select all visible',command=lambda:self.listbox.selection_set(0,'end')).pack(side='left',padx=8)
ttk.Button(controls,text='Open batch / saved games…',command=self.open_batch).pack(side='left')
def convert_page(self):
page=self.pages[2]
@@ -162,6 +166,8 @@ class Wizard(tk.Tk):
if self.busy:return
indexes=self.listbox.curselection()
if not indexes: messagebox.showinfo('Select a game','Choose an installed package first.');return
if len(indexes)>1:
self.show_batch([self.listbox.get(i) for i in indexes]);return
self.package=self.listbox.get(indexes[0]);self.selected.set(self.package)
self.vars['title'].set('Tetris Effect: Connected (Quest)' if self.package=='com.enhanceexperience.tetriseffect' else '')
self.vars['steam_id'].set('')
@@ -169,6 +175,19 @@ class Wizard(tk.Tk):
self.tabs.select(2)
if self.auto_art.get():self.search_artwork(query=self.vars['title'].get() or suggested_title(self.package))
def show_batch(self,packages=(),path=None):
if self.busy:return
if getattr(self,'batch_window',None) and self.batch_window.winfo_exists():
self.batch_window.lift();return
from batch import BatchWindow
try:self.batch_window=BatchWindow(self,packages,self.serial,path)
except Exception as exc:messagebox.showerror('Batch queue',str(exc))
def open_batch(self):
if self.busy:return
path=filedialog.askopenfilename(title='Open saved batch (Cancel to create an empty queue)',initialdir=ROOT/'games/batches',filetypes=[('Batch queue','*.json')])
self.show_batch(path=path or None)
def search_artwork(self, query=None):
if self.busy:return
query = query or self.vars['title'].get().strip()
+172
View File
@@ -0,0 +1,172 @@
# SPDX-License-Identifier: GPL-3.0-only
"""Sequential, resumable multi-game work; no concurrent OVR Port workspaces."""
import json
import uuid
from pathlib import Path
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from bridge import ROOT, write_json, settings_text, valid_package, artwork_candidates
def run_batch(items, action, bridge, serial, credentials, add_steam, save, auto_art=False):
for index, item in enumerate(items, 1):
bridge.log(f'Batch {index}/{len(items)}: {item["package"]} — {action}')
item['error']=''
try:
item['status']='Working';save()
if action=='convert':
backup=item.get('backup')
if not backup:
backup=str(bridge.backup(serial,item['package']))
item['backup']=backup;save()
# Successful builds are reused, not reconverted on retry.
if not item.get('build'):
item['build']=str(bridge.convert(backup,**item['options']))
item['status']='Ready'
if auto_art and not item['options']['steam_id']:
try:
game=json.loads((Path(item['build'])/'game.json').read_text())
item['candidates']=artwork_candidates(game['title'])
if not item['options']['title']:item['options']['title']=game['title']
except Exception as exc:bridge.log('Artwork search unavailable: '+str(exc))
else:
folder=Path(item['build'])
game=json.loads((folder/'game.json').read_text())
if game['package']!=item['package']:raise ValueError('Build/package mismatch')
game.update({k:v for k,v in item['options'].items() if k!='title' or v.strip()})
write_json(folder/'game.json',game)
with bridge.connect(**credentials) as client:
bridge.install(folder,client,add_steam,item['options']['steam_id'])
item['status']='Installed'
except Exception as exc:
item['status']='Failed';item['error']=str(exc)
bridge.log(f'FAILED {item["package"]}: {exc}')
save()
return items
class BatchWindow(tk.Toplevel):
def __init__(self, parent, packages=(), serial=None, path=None):
super().__init__(parent);self.parent=parent;self.serial=serial
self.title('Quest → Frame — batch queue');self.geometry('1050x760');self.minsize(850,700)
self.path=Path(path) if path else ROOT/'games/batches'/f'{uuid.uuid4().hex}.json'
defaults=parent.options();self.items=[];self.active=None
if path:
data=json.loads(self.path.read_text());self.items=data['items'];self.serial=data.get('serial')
for item in self.items:
valid_package(item['package']);settings_text(item['options']['scale'])
if item['status']=='Working':item['status']='Interrupted'
else:
for package in packages:
opts=dict(defaults,title='',steam_id='')
self.items.append(dict(package=package,options=opts,status='Queued',error='',backup='',build=''))
box=ttk.Frame(self,padding=16);box.pack(fill='both',expand=True)
ttk.Label(box,text='1. Configure games → 2. Back up & convert → 3. Switch to Frame → 4. Install',font=('Segoe UI',12,'bold')).pack(anchor='w')
ttk.Label(box,text='Ctrl/Shift selects multiple rows. Settings below apply only to the highlighted editor game.').pack(anchor='w',pady=5)
self.tree=ttk.Treeview(box,columns=('package','status'),show='headings',selectmode='extended',height=8)
self.tree.heading('package',text='Quest package');self.tree.heading('status',text='Status')
self.tree.column('package',width=630);self.tree.column('status',width=150)
self.tree.pack(fill='both',expand=True)
self.tree.bind('<<TreeviewSelect>>',self.selected)
ttk.Button(box,text='Select all games',command=lambda:self.tree.selection_set(self.tree.get_children())).pack(anchor='w',pady=5)
form=ttk.LabelFrame(box,text='Per-game settings — Save before switching games',padding=10);form.pack(fill='x')
self.editing=tk.StringVar(value='Select a game to edit');ttk.Label(form,textvariable=self.editing).grid(row=0,column=0,columnspan=4,sticky='w')
self.values={name:tk.StringVar() for name in ('title','scale','steam_id')}
for row,(name,label) in enumerate((('title','Title (blank = APK title)'),('scale','Resolution 50–200%'),('steam_id','Steam artwork App ID')),1):
ttk.Label(form,text=label).grid(row=row,column=0,sticky='w',padx=5)
ttk.Entry(form,textvariable=self.values[name],width=45).grid(row=row,column=1,sticky='ew',pady=3)
form.columnconfigure(1,weight=1)
self.controllers=tk.BooleanVar();self.foveation=tk.BooleanVar()
ttk.Checkbutton(form,text='Physical controllers',variable=self.controllers).grid(row=1,column=2,padx=10)
ttk.Checkbutton(form,text='Quest foveation fix',variable=self.foveation).grid(row=2,column=2,padx=10)
ttk.Button(form,text='Save game settings',command=self.save_editor).grid(row=3,column=2,padx=10)
ttk.Button(form,text='Find artwork by title',command=self.search_art).grid(row=4,column=0,pady=5)
self.matches=ttk.Combobox(form,state='readonly',width=50);self.matches.grid(row=4,column=1,columnspan=2,sticky='ew')
self.candidates=[];self.matches.bind('<<ComboboxSelected>>',self.choose_art)
self.add_steam=tk.BooleanVar(value=parent.add_steam.get())
ttk.Checkbutton(box,text='Add each installed game to Steam (Steam restarts for each game)',variable=self.add_steam).pack(anchor='w',pady=8)
buttons=ttk.Frame(box);buttons.pack(fill='x')
ttk.Button(buttons,text='Back up & convert selected',command=lambda:self.start('convert')).pack(side='left',padx=3)
ttk.Button(buttons,text='Install selected ready games',command=lambda:self.start('install')).pack(side='left',padx=3)
ttk.Button(buttons,text='Add saved conversions…',command=self.add_builds).pack(side='left',padx=3)
self.message=tk.StringVar(value='Keep Quest connected throughout the conversion batch.')
ttk.Label(box,textvariable=self.message,wraplength=980).pack(anchor='w',pady=8)
self.protocol('WM_DELETE_WINDOW',self.close)
self.refresh();self.tree.selection_set(self.tree.get_children());self.save();self.poll_id=self.after(250,self.poll)
def save(self):write_json(self.path,dict(serial=self.serial,items=self.items))
def refresh(self):
for index,item in enumerate(self.items):
ident=str(index);values=(item['package'],item['status'])
if self.tree.exists(ident):self.tree.item(ident,values=values)
else:self.tree.insert('', 'end',iid=ident,values=values)
def poll(self):self.refresh();self.poll_id=self.after(250,self.poll)
def selected(self,event=None):
if self.parent.busy:return
selection=self.tree.selection()
if not selection:return
focus=self.tree.focus();ident=focus if focus in selection else selection[0]
self.active=int(ident);item=self.items[self.active]
self.editing.set(item['package'])
for k,v in self.values.items():v.set(str(item['options'][k]))
self.controllers.set(item['options']['controllers']);self.foveation.set(item['options']['foveation'])
self.candidates=item.get('candidates',[])
self.matches.configure(values=[f"{m['title']} — {m['id']}" for m in self.candidates])
self.matches.set('Choose a candidate and Save' if self.candidates else 'Enter a title to search for artwork')
self.message.set(item['error'] or item['status'])
def save_editor(self):
if self.parent.busy or self.active is None:return False
try:
opts={k:v.get().strip() for k,v in self.values.items()};opts['scale']=float(opts['scale'])
settings_text(opts['scale'])
if opts['steam_id'] and not opts['steam_id'].isdigit():raise ValueError('Steam App ID must be numeric')
opts.update(controllers=self.controllers.get(),foveation=self.foveation.get())
except ValueError as exc:messagebox.showerror('Invalid settings',str(exc),parent=self);return False
self.items[self.active]['options']=opts;self.save();return True
def search_art(self):
if not self.save_editor():return
title=self.values['title'].get().strip()
if not title:self.message.set('Enter a title to search. Blank titles use APK labels during conversion.');return
active=self.active
def work():
try:return artwork_candidates(title),None
except Exception as exc:return [],str(exc)
def done(result):
matches,error=result
if active!=self.active:return
self.candidates=matches;self.matches.configure(values=[f"{m['title']} — {m['id']}" for m in matches])
self.matches.set('Choose the matching edition' if matches else 'No matches')
self.message.set(error or 'Choose a candidate, then Save game settings.')
self.parent.run_job('Searching Steam artwork…',work,done)
def choose_art(self,event=None):
index=self.matches.current()
if not self.parent.busy and 0<=index<len(self.candidates):self.values['steam_id'].set(self.candidates[index]['id'])
def add_builds(self):
if self.parent.busy:return
paths=filedialog.askopenfilenames(parent=self,title='Select saved game manifests',initialdir=ROOT/'games',filetypes=[('Game manifests','game.json')])
for path in paths:
try:
game=json.loads(Path(path).read_text());package=valid_package(game['package'])
if any(i['package']==package for i in self.items):continue
self.items.append(dict(package=package,options={k:game[k] for k in ('title','scale','steam_id','controllers','foveation')},build=str(Path(path).parent),backup=game['backup'],status='Ready',error=''))
except Exception as exc:messagebox.showerror('Invalid manifest',str(exc),parent=self)
self.refresh();self.save()
def start(self,action):
if self.parent.busy:return
if self.active is not None and not self.save_editor():return
items=[self.items[int(i)] for i in self.tree.selection()]
items=[i for i in items if (not i.get('build') if action=='convert' else i.get('build') and i['status']!='Installed')]
if not items:self.message.set('Select unconverted games or ready builds; completed items are skipped.');return
credentials=self.parent.credentials();add=self.add_steam.get();auto_art=self.parent.auto_art.get()
def work():
if action=='convert':
if self.serial not in [s for s,_ in self.parent.bridge.quests()]:raise RuntimeError('Reconnect the original Quest and allow USB debugging.')
return run_batch(items,action,self.parent.bridge,self.serial,credentials,add,self.save,auto_art)
def done(result):
failed=sum(i['status']=='Failed' for i in result)
self.refresh();self.selected()
self.message.set(f'Batch finished: {len(result)-failed} completed, {failed} failed. '+('Switch to Frame, select ready games, then Install.' if action=='convert' else 'See the log for details.'))
self.parent.run_job(f'Batch {action}: {len(items)} games. Keep the headset connected…',work,done)
def close(self):
if self.parent.busy:messagebox.showinfo('Working','Wait for the batch to finish.',parent=self);return
if self.active is not None and not self.save_editor():return
self.after_cancel(self.poll_id)
self.destroy()
+20
View File
@@ -2,6 +2,9 @@
import unittest
from unittest.mock import patch
import tkinter as tk
import tempfile
import json
from pathlib import Path
from app import Wizard
class ArtworkUITests(unittest.TestCase):
@@ -39,3 +42,20 @@ class ArtworkUITests(unittest.TestCase):
with patch('app.artwork_candidates',side_effect=search):self.app.search_artwork()
self.assertEqual(self.app.art_candidates,[])
self.assertEqual(self.app.vars['steam_id'].get(),'')
def test_batch_settings_and_queue_reload(self):
from batch import BatchWindow
with tempfile.TemporaryDirectory() as tmp, patch('batch.ROOT',Path(tmp)):
batch=BatchWindow(self.app,['com.test.one','com.test.two'],'quest')
batch.tree.selection_set('0');batch.tree.focus('0');batch.selected()
batch.values['scale'].set('75');batch.values['steam_id'].set('123');batch.save_editor()
batch.tree.selection_set('1');batch.tree.focus('1');batch.selected()
self.assertEqual(batch.values['scale'].get(),'100.0')
batch.values['scale'].set('125');batch.save_editor()
path=batch.path;batch.close()
saved=json.loads(path.read_text())
self.assertNotIn('password',saved)
reopened=BatchWindow(self.app,path=path)
self.assertEqual([i['options']['scale'] for i in reopened.items],[75,125])
self.assertEqual(reopened.items[0]['options']['steam_id'],'123')
reopened.close()
+58
View File
@@ -0,0 +1,58 @@
# SPDX-License-Identifier: GPL-3.0-only
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from batch import run_batch
def item(package):
return dict(package=package,options=dict(title='',scale=100,steam_id='',controllers=True,foveation=True),status='Queued',error='',backup='',build='')
class BatchTests(unittest.TestCase):
def test_partial_failure_continues_and_saved_backup_is_reused(self):
items=[item('com.test.one'),item('com.test.two')];bridge=MagicMock();save=MagicMock()
bridge.backup.side_effect=['backup-one','backup-two']
bridge.convert.side_effect=[RuntimeError('bad APK'),'build-two']
run_batch(items,'convert',bridge,'quest',{},False,save)
self.assertEqual([i['status'] for i in items],['Failed','Ready'])
self.assertEqual(items[0]['backup'],'backup-one')
bridge.backup.reset_mock();bridge.convert.side_effect=None;bridge.convert.return_value='build-one'
run_batch(items,'convert',bridge,'quest',{},False,save)
bridge.backup.assert_not_called()
self.assertEqual([i['build'] for i in items],['build-one','build-two'])
self.assertGreater(save.call_count,4)
def test_install_uses_distinct_options_and_continues(self):
with tempfile.TemporaryDirectory() as tmp:
items=[item('com.test.one'),item('com.test.two')]
for index,entry in enumerate(items):
folder=Path(tmp)/str(index);folder.mkdir();entry['build']=str(folder)
(folder/'game.json').write_text(json.dumps({'package':entry['package'],'title':'APK title'}))
entry['options']['scale']=75+index*50
entry['options']['steam_id']=str(100+index)
bridge=MagicMock();bridge.install.side_effect=[RuntimeError('connection lost'),('remote',1)]
run_batch(items,'install',bridge,None,{'host':'frame'},True,lambda:None)
self.assertEqual([i['status'] for i in items],['Failed','Installed'])
self.assertEqual([c.args[3] for c in bridge.install.call_args_list],['100','101'])
for index,entry in enumerate(items):
game=json.loads((Path(entry['build'])/'game.json').read_text())
self.assertEqual(game['scale'],75+index*50)
self.assertEqual(game['title'],'APK title')
def test_package_mismatch_not_installed(self):
with tempfile.TemporaryDirectory() as tmp:
entry=item('com.test.one');entry['build']=tmp
(Path(tmp)/'game.json').write_text(json.dumps({'package':'com.test.other'}))
bridge=MagicMock();run_batch([entry],'install',bridge,None,{},False,lambda:None)
bridge.install.assert_not_called();self.assertEqual(entry['status'],'Failed')
def test_artwork_failure_does_not_fail_conversion(self):
bridge=MagicMock();bridge.backup.return_value='backup'
with tempfile.TemporaryDirectory() as tmp:
bridge.convert.return_value=tmp
(Path(tmp)/'game.json').write_text(json.dumps({'title':'Test'}))
entry=item('com.test.one')
with patch('batch.artwork_candidates',side_effect=TimeoutError()):
run_batch([entry],'convert',bridge,'quest',{},False,lambda:None,True)
self.assertEqual(entry['status'],'Ready')