# SPDX-License-Identifier: GPL-3.0-only """Windows desktop wizard. Passwords are memory-only, never persisted.""" from pathlib import Path import json import os import queue import threading import tkinter as tk from tkinter import ttk, messagebox, filedialog from bridge import APP, ROOT, Bridge, write_json, settings_text, artwork_candidates, suggested_title from desktop_ui import DesktopUI class Wizard(DesktopUI, tk.Tk): def __init__(self): super().__init__() self.title('Quest → Frame'); self.geometry('960x790'); self.minsize(830, 680) self.events = queue.Queue(); self.busy = False; self.build = None; self.serial = None; self.package_list = [] self.bridge = Bridge(lambda line: self.events.put(('log', line)), lambda stage, percent: self.events.put(('progress', (stage, percent)))) self.vars = {k: tk.StringVar(value=v) for k, v in {'host':'frame', 'port':'22', 'username':'steamos', 'password':'', 'title':'', 'steam_id':'', 'filter':'', 'scale':'100'}.items()} self.controllers = tk.BooleanVar(value=True); self.foveation = tk.BooleanVar(value=True); self.add_steam = tk.BooleanVar(value=True) self.auto_art = tk.BooleanVar(value=True); self.art_candidates = [] self.wireless_install = tk.BooleanVar(value=True) self.package_names={};self.package_icons={};self.visible_packages=[] config = APP / 'settings.json' if config.exists(): try: for k,v in json.loads(config.read_text()).items(): if k in ('host','port','username'): self.vars[k].set(v) except (ValueError, OSError): pass self.build_ui() self.protocol('WM_DELETE_WINDOW', self.close) self.after(100, self.drain) def credentials(self): return {k:self.vars[k].get() for k in ('host','username','password','port')} def manage_installed(self): if self.busy:return if getattr(self,'installed_window',None) and self.installed_window.winfo_exists(): self.installed_window.lift();return from manage_ui import InstalledGames self.installed_window=InstalledGames(self) def run_job(self, title, fn, done=None): if self.busy: messagebox.showinfo('Working','Wait for the current operation to finish.');return self.busy=True;self.lock_actions(True);self.status.set(title);self.show_progress(title,None) def worker(): try: self.events.put(('done',(fn(),done))) except Exception as exc: self.events.put(('error',str(exc))) threading.Thread(target=worker,daemon=True).start() def show_progress(self, stage, percent): mode = 'indeterminate' if percent is None else 'determinate' if str(self.progress['mode']) != mode: self.progress.stop();self.progress.configure(mode=mode);self.progress_value.set(0) if percent is None:self.progress.start(15) if percent is not None:self.progress_value.set(max(0,min(100,percent))) self.progress_text.set(stage + (' — working…' if percent is None else f' — {percent:.0f}% of this stage')) def drain(self): try: while True: kind,value=self.events.get_nowait() if kind=='log': self.logbox.configure(state='normal');self.logbox.insert('end',value+'\n');self.logbox.see('end');self.logbox.configure(state='disabled') elif kind=='progress': self.show_progress(*value) else: self.busy=False;self.lock_actions(False);self.progress.stop() self.progress.configure(mode='determinate') if kind=='error': self.progress_value.set(0);self.progress_text.set('Stopped — operation failed; see error details') self.status.set('Needs attention — see details below.');messagebox.showerror('Quest → Frame',value) else: self.progress_value.set(100);self.progress_text.set('Operation finished — see status and log for results') result,done=value;self.status.set('Ready') if done: done(result) except queue.Empty: pass self.after(100,self.drain) def test_ssh(self): credentials=self.credentials() def work(): with self.bridge.connect(**credentials) as client:self.bridge.remote(client,'test -x "$HOME/.local/share/Steam/steamapps/common/Lepton/lepton"') write_json(APP/'settings.json',{k:v for k,v in credentials.items() if k!='password'}) def done(_): self.device_status.set('FRAME / Verified · '+credentials['host']) self.status.set('Frame verified. Connect Quest by USB, then choose your games.') self.tabs.select(1) self.run_job('Verifying Frame SSH…',work,done) def scan(self): def work(): devices=self.bridge.quests() if len(devices)!=1: raise RuntimeError('Connect exactly one Quest and accept USB debugging. Found: '+str(len(devices))) return devices[0][0],self.bridge.installed_apps(devices[0][0]) def done(result): self.serial,apps=result self.package_list=[a['package'] for a in apps];self.package_names={a['package']:a['title'] for a in apps} self.package_icons={a['package']:a['icon'] for a in apps if a.get('icon')} self.filter_packages();self.status.set(f'{len(self.package_list)} installed apps. Select a game.') self.run_job('Scanning Quest…',work,done) def filter_packages(self): query=self.vars['filter'].get().lower();self.listbox.delete(0,'end') self.visible_packages=[] for package in self.package_list: title=self.package_names.get(package,package) if query in package.lower() or query in title.lower(): self.visible_packages.append(package) self.listbox.insert('end',suggested_title(package) if title==package else title,self.package_icons.get(package)) self.library_count.set(f'{len(self.visible_packages)} of {len(self.package_list)} apps · Quest library') self.library_selection_changed() def select_game(self): 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.visible_packages[i] for i in indexes]);return self.build=None self.ready.set('Convert this selection before installing, or open a saved build.') self.package=self.visible_packages[indexes[0]];self.selected.set(self.package_names.get(self.package,self.package)+' — '+self.package) label=self.package_names.get(self.package,'') self.vars['title'].set(label if label!=self.package else '') self.vars['steam_id'].set('') self.art_candidates=[];self.art_matches.configure(values=[]);self.art_matches.set('No artwork selected') 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() if not query: messagebox.showinfo('Artwork search','Enter a game title first.');return title_before=self.vars['title'].get();id_before=self.vars['steam_id'].get() self.art_candidates=[];self.art_matches.configure(values=[]);self.art_matches.set('Searching Steam…') def work(): try:return artwork_candidates(query),None except Exception as exc:return [],str(exc) def done(result): if self.vars['title'].get()!=title_before or self.vars['steam_id'].get()!=id_before: self.art_matches.set('Details changed — click Find artwork to search again');return matches,error=result;self.art_candidates=matches self.art_matches.configure(values=[f"{m['title']} — App ID {m['id']}" for m in matches]) if error: self.art_matches.set('Search unavailable — retry or enter an App ID manually') self.status.set('Artwork search failed; conversion is still available.') self.events.put(('log','Artwork search: '+error)) elif matches: self.art_matches.set('Choose a candidate — verify the game and edition') self.status.set(f'{len(matches)} artwork candidates found. Choose one or keep your manual App ID.') else: self.art_matches.set('No matches — edit the title and search again') self.status.set('No artwork match. You can continue without artwork.') self.run_job('Searching Steam for artwork candidates…',work,done) def choose_artwork(self,event=None): index=self.art_matches.current() if self.busy or not 0<=index