diff --git a/GUIDE.md b/GUIDE.md index 999e3ec..3c7cfcd 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -9,7 +9,7 @@ For multiple games, Ctrl/Shift-select packages and choose **Use selected game(s) 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. -3. When prompted, unplug Quest and connect Frame. Click **Install on Frame**. Files transfer over USB ADB, are checksum-verified, and get a dedicated Lepton installation. SSH configures the launcher and optional Steam VR shortcut. Steam briefly closes and restarts to safely load the library changes. +3. Keep Frame awake on the same network and leave **Install wirelessly over Wi-Fi (SSH/SFTP)** checked. Quest can stay plugged into your PC. For USB transfers instead, uncheck Wi-Fi and connect Frame. Click **Install on Frame**. APK/expansion files are checksum-verified and get a dedicated Lepton installation. SSH configures the launcher and optional Steam VR shortcut. Steam briefly closes and restarts to safely load the library changes. The first Steam launch lets Lepton install/bake the APK into its dedicated Android container. Subsequent launches do not need this PC or either USB connection. @@ -25,6 +25,10 @@ The Frame adapter repairs the missing Android OpenXR instance extension. Optiona ## Files, safety, and limitations +**Headset Hz** in the conversion and batch settings requests a per-game display refresh rate (Default, 72, 80, 90, 120, or 144). It is not an FPS cap. Unsupported modes keep the runtime's choice; even supported requests are subject to runtime policy. Default preserves existing behavior. Older converted APKs require reconversion with the new adapter before selecting a rate; retain Default for specially patched games until their patches can be reapplied. New-adapter games can use **Apply resolution / refresh rate / compatibility settings**, then restart. No installed games are automatically rebuilt or changed. + +To remove an installation, use **Manage / uninstall Frame games** on the Install tab. Refresh, select a managed game, and confirm. Save/Lepton-data preservation is enabled by default; turning it off permanently deletes that game's data too. Steam restarts and its shortcuts file is backed up. See [uninstall instructions](README.md#uninstall-a-quest2frame-game) for retained recovery folders and limitations. + Single-game and batch install windows offer **Refresh storage** and an **Install location** dropdown. SD/removable storage is discovered from mounted devices and identified by filesystem UUID, not its label. Supported destinations are writable ext4/btrfs without `noexec`. Game files and per-game Lepton data go to the chosen drive; the stable Steam launcher and artwork stay internal. Existing games cannot silently move to another drive. See [installation instructions](README.md#3-install-and-play-on-frame) for details. No formatting or automatic mounting is performed. - `games//backup-*`: original Quest APK/data and backup metadata. diff --git a/README.md b/README.md index d4a3016..4c9a462 100644 --- a/README.md +++ b/README.md @@ -111,21 +111,29 @@ After the test succeeds, disconnect Frame and connect Quest to the PC. ### 1. Select a Quest game -Unlock Quest and authorize debugging if prompted. Click **Scan Quest and list installed packages**, filter the list, select a package, and click **Use selected game**. +Unlock Quest and authorize debugging if prompted. Click **Scan Quest and list installed apps**, filter by app name or package ID, select games, and click **Use selected game(s)**. The app reads display names from Quest's package manager with a temporary shell helper (no helper app is installed), and uses those names for titles and artwork searches. Package IDs remain visible to distinguish similarly named apps. If name lookup is unavailable, IDs are shown as a fallback. + +Name lookup requires an installed Android SDK platform (`platforms/*/android.jar`). If the local Java runtime lacks `javac`, the app downloads the pinned, SHA-256-verified Eclipse Java compiler 3.40.0 from Maven Central on first scan. It caches the compiler and generated helper under ignored `tools/`; subsequent scans do not require downloading them again. ### 2. Back up and convert Choose a library title and resolution scale (50–200%; start at 100%). Leave physical-controller compatibility enabled for controller games; disable it for hand-tracking games. The foveation option disables an unsupported Quest rendering path. +**Headset Hz** sets a per-game display refresh-rate preference: **Default**, **72**, **80**, **90**, **120**, or **144**. Default leaves the game's/runtime's choice unchanged. These are candidate values, not a guarantee that every mode is supported. The adapter enables `XR_FB_display_refresh_rate` only when advertised, checks the runtime's supported rates, and requests the selected mode when the VR session begins. Unsupported modes leave the runtime's choice alone; requests may also be declined by the runtime. This is not a game FPS cap or a guarantee of rendering performance. Runtime decisions are logged under `FrameBridge` in Android logs. + +The same option is available in each batch game's settings. New conversions include refresh-rate support; older APKs must be reconverted first, and the app rejects non-default refresh settings for them. Reconverting a specially patched game also requires reapplying its game-specific patches—keep Default for your existing working builds until then. For builds containing the new adapter, use **Apply resolution / refresh rate / compatibility settings** and restart the game; no further reconversion is needed. See the [OpenXR refresh-rate request specification](https://registry.khronos.org/OpenXR/specs/1.1/man/html/xrRequestDisplayRefreshRateFB.html). + With **Search Steam automatically** enabled, selecting a Quest package searches Steam for artwork candidates using its title or a package-name hint. Choose the correct game and edition from the candidate dropdown to fill the **Steam App ID**. Suggestions are ranked by title similarity, not guaranteed compatibility; no candidate is silently accepted. Edit the library title and click **Find artwork** to refine the search. If no ID has been selected, the app searches again using the APK's actual title after conversion. You can still enter an App ID manually (the number after `/app/` in a Steam store URL), or leave it blank to skip artwork. Search failures do not prevent conversion. Searches send the game title to Steam, not APKs or saves; uncheck automatic search to disable automatic requests. The **Find artwork** button still performs a search when clicked. Click **Back up Quest game and convert**. Leave Quest connected until conversion completes. The app copies the original APK, expansion files, and accessible external saves, runs OVR Port, applies the adapter, aligns/signs the APK, and verifies its signature. Original Quest files are not modified. +The progress bar shows the current stage, not an estimated overall completion percentage. Wi-Fi uploads report bytes and percentage per file; USB copies show percentages when ADB emits them. Conversion identifies each stage and reports APK repacking progress. Patching, signing, checksums, and other stages without a measurable percentage show an animated bar. The batch window also shows the current game number and the same live progress. A file reaching 100% does not mean installation is finished: verification and Steam setup still follow. + ### 3. Install and play on Frame -Disconnect Quest, connect **Frame to the PC**, and keep it awake with Wi-Fi enabled. Choose whether to add the game to Steam, then click **Install on Frame**. +Keep Frame awake on the same network as the PC for wireless installation, or connect it to the PC for USB installation. Choose the transfer mode and whether to add the game to Steam, then click **Install on Frame**. Before installing, use **Refresh storage** and choose **Internal storage** or a detected **SD card / removable storage** destination. Each entry shows available space; no drive label is hard-coded. The card must already be mounted, writable, and formatted as ext4 or btrfs with execution allowed. The app does not format, mount, or change permissions on storage. Unsupported/read-only/noexec filesystems are omitted. @@ -133,7 +141,9 @@ On an SD install, APKs, expansion files, external saves, and per-game Lepton dat **Existing games are not automatically moved between drives.** Choose their current destination when updating. Switching an existing install to another drive is refused to avoid splitting saves or overwriting progress. An SD card must be mounted before launching a game. Reformatting changes its UUID and is not equivalent to renaming it. -Files transfer over USB ADB; SSH configures the launcher and optional Steam shortcut/artwork. Adding the shortcut briefly closes and restarts Steam, so finish any active game first. Wait for completion before disconnecting USB. +**Wireless installation (default):** leave **Install wirelessly over Wi-Fi (SSH/SFTP)** checked. Keep Frame awake on the same network as the PC, enter its SSH credentials in step 0, and leave Quest connected to the PC if desired. After the first USB-verified SSH connection, Frame needs no USB cable for installs. Files transfer through authenticated SSH/SFTP into each game's dedicated Lepton installation, not into Lepton Development. No `adb connect`, open network ADB port, or running Lepton Development session is required. APK/OBB checksum verification, save preservation, internal/SD storage selection, and artwork work in both modes. + +**USB installation:** uncheck the wireless option and connect Frame's native USB ADB interface. SSH still configures the launcher and optional Steam shortcut/artwork. Adding the shortcut briefly closes and restarts Steam in either mode, so finish any active game first. Keep the chosen connection active until installation completes. A failed Wi-Fi transfer reports an error; it does not silently switch to USB. Open the new entry in Frame's Steam library. The first launch initializes its Lepton container and may take longer. Subsequent play does not require the PC. @@ -145,7 +155,7 @@ Open the new entry in Frame's Steam library. The first launch initializes its Le 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. +5. Choose wireless installation, or uncheck it and 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. The batch window has its own **Install location** selector and **Refresh storage** button. Its selected destination applies to that installation run. To split a batch across internal storage and SD, install one subset, change the destination, then install the remaining subset. Storage selection is not restored from queue files; refresh and select the intended destination after reopening a queue. @@ -157,12 +167,24 @@ Use **Open a previously converted game** and select `games//build-.../g - 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. - Select **Add / repair Steam shortcut only** to retry library integration. -- Reconnect Frame over USB and select **Install on Frame** to reinstall the saved build. +- Select Wi-Fi or connect Frame over USB, then select **Install on Frame** to reinstall the saved build. Back up `games/` and `tools/ovrport-workspace/signatures/`. Existing external Frame saves are not overwritten with older Quest saves; app updates retain snapshots of replaced app/baked data. See [GUIDE.md](GUIDE.md) for details. ## Troubleshooting +### Uninstall a Quest2Frame game + +On **Install on Frame**, open **Manage / uninstall Frame games**, then **Refresh installed games**. This uses the SSH credentials from Frame setup; USB is only needed for initial SSH verification. It detects managed internal and SD installations from deployment records and refuses unknown/unsafe paths. Insert and mount an SD card before uninstalling its games. + +Select one game and click **Uninstall selected game**. Review the package and destination in the confirmation. Close active games first: uninstalling removes the selected Steam shortcut and restarts Steam. Other game installations and shortcuts are preserved. + +**Preserve saves / Lepton data** is on by default. It retains the selected game's `lepton-data` and previous baked-data snapshots under a `.quest2frame-saves--` recovery folder beside the former game directory, on the same drive. The exact path is displayed afterward. This can retain substantial data, not just small save files; restoration is manual. Uncheck this option only if you also want to permanently delete that game's saves and baked data. + +Uninstall removes game APK/OBB files, prior app copies, shaders, and the managed launcher directory. Quest originals, PC backups/builds, shared Steam/Lepton runtimes, and unrelated games are not deleted. Steam's cached grid artwork and timestamped shortcut backups remain. Local batch queues/build manifests are not rewritten; a fresh queue can reinstall the saved build later. + +The Steam shortcuts file is backed up before removal. Interrupted cleanup may leave a `.quest2frame-uninstall-...` directory; the error reports recovery paths. Do not delete these blindly. Full uninstall testing uses temporary fixtures; installed-game discovery has been checked on Frame without removing real games. + | Symptom | What to check | | --- | --- | | No headset in ADB | Data cable, PC USB port, headset awake, developer mode, and the appropriate Windows ADB driver. | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 67fdfbe..9a24479 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -36,6 +36,7 @@ The source app lets users install and replace Paramiko normally. A frozen execut ## External tools and device-side software (not redistributed) - Eclipse Temurin/OpenJDK 21: local distribution carries GPLv2 with Classpath Exception and additional component notices under its `legal/` directory. Retain those if redistributing the runtime. +- [Eclipse Compiler for Java (ECJ) 3.40.0](https://central.sonatype.com/artifact/org.eclipse.jdt/ecj/3.40.0): EPL-2.0. Downloaded from Maven Central only when `javac` is unavailable, to compile the original GPL-3.0-only `native/QuestAppLabels.java` helper. Compiler binaries are cached under ignored `tools/` and are not redistributed. Pinned SHA-256: `05cc22a24e7982970f63a405fc6c820bc80b806f27f3c5a6236fc475f8f7152b`. The compiler's license does not replace the helper source's license. - Android SDK/NDK, build-tools and platform-tools: separately installed tools with component-specific notices and SDK terms. Do not apply this repository's license to their binaries. - Valve Lepton: device-installed README states its compatibility tool is MIT and its Android root filesystem includes GPL-3.0 components. The original Valve shell sources copied locally for inspection are excluded under `tools/`; this repository does not redistribute Lepton or its Android images. - Frida: used only for earlier local diagnostics; neither its server, scripts, nor packages are included or required by this source app. diff --git a/app.py b/app.py index efcb772..908150e 100644 --- a/app.py +++ b/app.py @@ -15,10 +15,13 @@ class Wizard(tk.Tk): 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))) + 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.visible_packages=[] config = APP / 'settings.json' if config.exists(): try: @@ -30,7 +33,7 @@ class Wizard(tk.Tk): style.configure('Step.TLabel', font=('Segoe UI', 11, 'bold')) outer = ttk.Frame(self, padding=20); outer.pack(fill='both', expand=True) ttk.Label(outer, text='Quest → Frame', style='Title.TLabel').pack(anchor='w') - ttk.Label(outer, text='Your games. Backed up, converted, and installed over USB.', padding=(0,3,0,14)).pack(anchor='w') + ttk.Label(outer, text='Back up Quest over USB. Install on Frame over Wi-Fi or USB.', padding=(0,3,0,14)).pack(anchor='w') ttk.Button(outer, text='About / licenses', command=lambda: messagebox.showinfo( 'Quest to Frame — licenses', 'Original project source: GPL-3.0-only. You may copy, modify, and redistribute it under GPL version 3.\n\n' @@ -44,7 +47,10 @@ class Wizard(tk.Tk): for label in ('0 Frame setup', '1 Select Quest game', '2 Convert', '3 Install on Frame'): page = ttk.Frame(self.tabs, padding=16); self.tabs.add(page, text=label); self.pages.append(page) self.setup_page(); self.quest_page(); self.convert_page(); self.install_page() - self.progress = ttk.Progressbar(outer, mode='indeterminate'); self.progress.pack(fill='x', pady=(12,6)) + self.progress_value = tk.DoubleVar(value=0) + self.progress_text = tk.StringVar(value='No operation running') + ttk.Label(outer,textvariable=self.progress_text,wraplength=850).pack(fill='x',pady=(8,0)) + self.progress = ttk.Progressbar(outer, mode='determinate',variable=self.progress_value,maximum=100); self.progress.pack(fill='x', pady=(4,6)) self.logbox = tk.Text(outer, height=9, font=('Consolas',9), wrap='word', state='disabled') self.logbox.pack(fill='x') self.protocol('WM_DELETE_WINDOW', self.close) @@ -70,7 +76,7 @@ class Wizard(tk.Tk): def quest_page(self): page=self.pages[1] ttk.Label(page,text='Plug in Quest, unlock it, and accept the USB debugging prompt.').pack(anchor='w') - ttk.Button(page,text='Scan Quest and list installed packages',command=self.scan).pack(anchor='w',pady=8) + ttk.Button(page,text='Scan Quest and list installed apps',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,selectmode='extended');self.listbox.pack(fill='both',expand=True,pady=8) @@ -81,6 +87,7 @@ class Wizard(tk.Tk): ttk.Button(controls,text='Open batch / saved games…',command=self.open_batch).pack(side='left') def convert_page(self): + self.vars['refresh_rate'] = tk.StringVar(value='Default') page=self.pages[2] self.selected=tk.StringVar(value='Select a package on step 1, or reopen a previous conversion below.') ttk.Label(page,textvariable=self.selected,wraplength=750).grid(row=0,column=0,columnspan=2,sticky='w',pady=(0,10)) @@ -88,6 +95,9 @@ class Wizard(tk.Tk): self.field(page,'steam_id','Steam App ID for official art (optional)',2) ttk.Label(page,text='Resolution scale (%)').grid(row=3,column=0,sticky='w') ttk.Combobox(page,textvariable=self.vars['scale'],values=('50','67','75','85','100','125','150','175','200'),width=12).grid(row=3,column=1,sticky='w',pady=8) + refresh=ttk.Frame(page);refresh.grid(row=3,column=2,sticky='w') + ttk.Label(refresh,text='Headset Hz ').pack(side='left') + ttk.Combobox(refresh,textvariable=self.vars['refresh_rate'],values=('Default','72','80','90','120','144'),state='readonly',width=9).pack(side='left') ttk.Label(page,text='100% = runtime recommendation. Higher values cost GPU performance. Restart to apply.').grid(row=4,column=0,columnspan=2,sticky='w') ttk.Checkbutton(page,text='Physical-controller compatibility (disable for hand-tracking games)',variable=self.controllers).grid(row=5,column=0,columnspan=2,sticky='w',pady=6) ttk.Checkbutton(page,text='Disable unsupported Quest foveation',variable=self.foveation).grid(row=6,column=0,columnspan=2,sticky='w') @@ -109,35 +119,58 @@ class Wizard(tk.Tk): self.storage_picker=StoragePicker(page,self);self.storage_picker.pack(fill='x',pady=(0,8)) self.ready=tk.StringVar(value='Convert a game first. You will be prompted to switch headsets.') ttk.Label(page,textvariable=self.ready,wraplength=740,style='Step.TLabel').pack(anchor='w',pady=(0,12)) - ttk.Label(page,text='Disconnect Quest. Connect Frame by USB, unlock it, and leave Wi-Fi on.\nThe SSH details from step 0 are used for setup; game files transfer over ADB.').pack(anchor='w') + ttk.Checkbutton(page,text='Install wirelessly over Wi-Fi (SSH/SFTP)',variable=self.wireless_install).pack(anchor='w') + ttk.Label(page,text='Wi-Fi: keep Frame awake on the same network; Quest can stay plugged into your PC.\nUSB: uncheck Wi-Fi and connect Frame. Both modes use the SSH details from step 0.',wraplength=720).pack(anchor='w') ttk.Checkbutton(page,text='Add to Steam with artwork (briefly closes and restarts Steam)',variable=self.add_steam).pack(anchor='w',pady=12) ttk.Button(page,text='Install on Frame',command=self.install).pack(anchor='w',pady=5) - ttk.Button(page,text='Apply resolution / compatibility settings only',command=self.update_settings).pack(anchor='w',pady=5) + ttk.Button(page,text='Apply resolution / refresh rate / compatibility settings',command=self.update_settings).pack(anchor='w',pady=5) ttk.Button(page,text='Add / repair Steam shortcut only',command=self.repair_shortcut).pack(anchor='w',pady=5) ttk.Button(page,text='Open game backups and conversions',command=lambda:os.startfile(ROOT/'games')).pack(anchor='w',pady=5) + ttk.Button(page,text='Manage / uninstall Frame games…',command=self.manage_installed).pack(anchor='w',pady=5) ttk.Label(page,text='Compatibility is game-dependent. DRM, online services, split APKs,\nand private Quest saves may not transfer. Original Quest files are never modified.',wraplength=720).pack(anchor='w',pady=10) 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.status.set(title);self.progress.start() + self.busy=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.progress.stop() - if kind=='error': self.status.set('Needs attention — see details below.');messagebox.showerror('Quest → Frame',value) + 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 @@ -154,24 +187,31 @@ class Wizard(tk.Tk): 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.packages(devices[0][0]) + return devices[0][0],self.bridge.installed_apps(devices[0][0]) def done(result): - self.serial,self.package_list=result;self.filter_packages();self.status.set(f'{len(self.package_list)} installed packages. Select a game.') + 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.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: - if query in package.lower(): self.listbox.insert('end',package) + 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',title if title==package else f'{title} — {package}') 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.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.show_batch([self.visible_packages[i] for i in indexes]);return + 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) @@ -226,7 +266,9 @@ class Wizard(tk.Tk): def options(self): options = dict(title=self.vars['title'].get(),scale=float(self.vars['scale'].get()),controllers=self.controllers.get(),foveation=self.foveation.get(),steam_id=self.vars['steam_id'].get()) - settings_text(options['scale'], options['controllers'], options['foveation']) + from bridge import refresh_value + options['refresh_rate'] = refresh_value(self.vars['refresh_rate'].get()) + settings_text(options['scale'], options['controllers'], options['foveation'], options['refresh_rate']) return options def convert(self): @@ -239,9 +281,10 @@ class Wizard(tk.Tk): def build_ready(self,folder): self.build=Path(folder);game=json.loads((self.build/'game.json').read_text()) for k in ('title','scale','steam_id'):self.vars[k].set(str(game[k])) + self.vars['refresh_rate'].set(str(game.get('refresh_rate') or 'Default')) self.controllers.set(game['controllers']);self.foveation.set(game['foveation']) - self.ready.set(game['title']+' is ready. Plug in your Frame.');self.selected.set(game['package']);self.tabs.select(3) - self.status.set('3 · Conversion ready. Switch from Quest to Frame, then Install.') + self.ready.set(game['title']+' is ready. Choose Wi-Fi or USB installation.');self.selected.set(game['package']);self.tabs.select(3) + self.status.set('3 · Conversion ready. Wake Frame, then Install.') self.art_candidates=[];self.art_matches.configure(values=[]);self.art_matches.set('Click Find artwork to search for this game') if self.auto_art.get() and not game.get('steam_id'): self.tabs.select(2) @@ -261,11 +304,12 @@ class Wizard(tk.Tk): try:storage_id=self.storage_picker.selected_id() except ValueError as exc:messagebox.showerror('Storage',str(exc));return credentials=self.credentials();folder=self.build;add=self.add_steam.get();art=self.vars['steam_id'].get() + transport='wifi' if self.wireless_install.get() else 'usb' def work(): game=json.loads((folder/'game.json').read_text()) game.update({k:v for k,v in options.items() if k!='title' or v.strip()}) write_json(folder/'game.json',game) - with self.bridge.connect(**credentials) as client:return self.bridge.install(folder,client,add,art,storage_id) + with self.bridge.connect(**credentials) as client:return self.bridge.install(folder,client,add,art,storage_id,transport) self.run_job('Installing on Frame…',work,lambda _:self.status.set('Installed. Launch the game from your Frame’s Steam library.')) def update_settings(self): @@ -274,7 +318,7 @@ class Wizard(tk.Tk): except ValueError:messagebox.showerror('Invalid scale','Enter a number between 50 and 200.');return credentials=self.credentials();folder=self.build def work(): - with self.bridge.connect(**credentials) as client:self.bridge.update_settings(folder,client,opts['scale'],opts['controllers'],opts['foveation']) + with self.bridge.connect(**credentials) as client:self.bridge.update_settings(folder,client,opts['scale'],opts['controllers'],opts['foveation'],opts['refresh_rate']) self.run_job('Updating game settings…',work,lambda _:self.status.set('Settings saved. Restart the game on Frame.')) def repair_shortcut(self): diff --git a/batch.py b/batch.py index 0b3d47d..31ad390 100644 --- a/batch.py +++ b/batch.py @@ -5,11 +5,12 @@ 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 +from bridge import ROOT, write_json, settings_text, valid_package, artwork_candidates, refresh_value from storage_ui import StoragePicker -def run_batch(items, action, bridge, serial, credentials, add_steam, save, auto_art=False, storage_id='internal'): +def run_batch(items, action, bridge, serial, credentials, add_steam, save, auto_art=False, storage_id='internal', transport='usb'): for index, item in enumerate(items, 1): + bridge.progress_prefix=f'Game {index}/{len(items)} · {item["options"].get("title") or item["package"]}: ' bridge.log(f'Batch {index}/{len(items)}: {item["package"]} — {action}') item['error']='' try: @@ -36,11 +37,13 @@ def run_batch(items, action, bridge, serial, credentials, add_steam, save, auto_ 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'],storage_id) + bridge.install(folder,client,add_steam,item['options']['steam_id'],storage_id,transport) item['status']='Installed' except Exception as exc: item['status']='Failed';item['error']=str(exc) bridge.log(f'FAILED {item["package"]}: {exc}') + finally: + bridge.progress_prefix='' save() return items @@ -57,13 +60,14 @@ class BatchWindow(tk.Toplevel): if item['status']=='Working':item['status']='Interrupted' else: for package in packages: - opts=dict(defaults,title='',steam_id='') + label=parent.package_names.get(package,'') + opts=dict(defaults,title=label if label!=package else '',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='1. Configure games → 2. Back up & convert → 3. Wake 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.heading('package',text='Game / 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('<>',self.selected) @@ -71,6 +75,7 @@ class BatchWindow(tk.Toplevel): 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')} + self.values['refresh_rate']=tk.StringVar(value='Default') 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) @@ -82,8 +87,12 @@ class BatchWindow(tk.Toplevel): 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('<>',self.choose_art) + ttk.Label(form,text='Headset refresh rate (Hz)').grid(row=5,column=0,sticky='w') + ttk.Combobox(form,textvariable=self.values['refresh_rate'],values=('Default','72','80','90','120','144'),state='readonly',width=12).grid(row=5,column=1,sticky='w') self.add_steam=tk.BooleanVar(value=parent.add_steam.get()) + self.wireless_install=tk.BooleanVar(value=parent.wireless_install.get()) self.storage_picker=StoragePicker(box,parent);self.storage_picker.pack(fill='x',pady=8) + ttk.Checkbutton(box,text='Install wirelessly over Wi-Fi (SSH/SFTP); Quest can stay connected to PC',variable=self.wireless_install).pack(anchor='w') 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) @@ -91,13 +100,16 @@ class BatchWindow(tk.Toplevel): 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) + ttk.Label(box,textvariable=parent.progress_text,wraplength=980).pack(fill='x') + ttk.Progressbar(box,variable=parent.progress_value,maximum=100).pack(fill='x',pady=4) 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']) + title=item['options'].get('title','') + ident=str(index);values=((title+' — ' if title else '')+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) @@ -108,7 +120,7 @@ class BatchWindow(tk.Toplevel): 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])) + for k,v in self.values.items():v.set(str((item['options'].get(k) or 'Default') if k=='refresh_rate' else 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]) @@ -118,7 +130,8 @@ class BatchWindow(tk.Toplevel): 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']) + opts['refresh_rate']=refresh_value(opts['refresh_rate']) + settings_text(opts['scale'],refresh_rate=opts['refresh_rate']) 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 @@ -149,6 +162,7 @@ class BatchWindow(tk.Toplevel): 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='')) + self.items[-1]['options']['refresh_rate']=game.get('refresh_rate',0) except Exception as exc:messagebox.showerror('Invalid manifest',str(exc),parent=self) self.refresh();self.save() def start(self,action): @@ -158,16 +172,17 @@ class BatchWindow(tk.Toplevel): 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() + transport='wifi' if self.wireless_install.get() else 'usb' try:storage_id=self.storage_picker.selected_id() except ValueError as exc:messagebox.showerror('Storage',str(exc),parent=self);return 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,storage_id) + return run_batch(items,action,self.parent.bridge,self.serial,credentials,add,self.save,auto_art,storage_id,transport) 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.message.set(f'Batch finished: {len(result)-failed} completed, {failed} failed. '+('Wake Frame, choose Wi-Fi or USB, 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 diff --git a/bridge.py b/bridge.py index 2d5c383..888b684 100644 --- a/bridge.py +++ b/bridge.py @@ -16,9 +16,11 @@ import time import urllib.request import urllib.parse import zipfile +import uuid import paramiko -from steam_shortcuts import upsert, app_id +from steam_shortcuts import upsert, app_id, remove as remove_shortcut from storage import PROBE, select_storage, card_launcher +from keep_awake import FrameAwake APP = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path(__file__).resolve().parent ROOT = APP @@ -60,9 +62,24 @@ def valid_package(value): raise ValueError('Invalid Android package name') return value -def settings_text(scale=100, controllers=True, foveation=True): +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)}\n' + return f'scale={float(scale)/100:.3f}\ncontroller_fix={int(controllers)}\nfoveation_fix={int(foveation)}\nrefresh_rate={refresh_value(refresh_rate)}\n' def digest(path): with open(path, 'rb') as stream: return hashlib.file_digest(stream, 'sha256').hexdigest() @@ -77,10 +94,18 @@ 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): + 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.awake_guard = None 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) @@ -88,6 +113,9 @@ class Bridge: 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: self.log(clean) previous = clean p.wait(); output = ''.join(lines) @@ -116,6 +144,55 @@ class Bridge: 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={} + 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_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)) 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:')] @@ -124,6 +201,7 @@ class Bridge: 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'): @@ -131,10 +209,12 @@ class Bridge: 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 @@ -151,7 +231,9 @@ class Bridge: 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=''): + def convert(self, backup, title, scale=100, controllers=True, foveation=True, steam_id='', refresh_rate=0): + self.progress('Conversion 1/7: inspecting APK') + refresh_rate = refresh_value(refresh_rate) backup = Path(backup) java, jar, build, clang = self.toolchain() source_apk = backup / 'base.apk' @@ -166,12 +248,17 @@ class Bridge: 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(): shutil.copy2(prebuilt, library) + 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' @@ -185,23 +272,32 @@ class Bridge: 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)) + 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, + '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) - self.log('Conversion complete. Unplug Quest, plug in Frame, then press Install.') + 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 connect(self, host, username, password, port=22): @@ -225,6 +321,7 @@ class Bridge: return client def remote(self, client, command, check=True): + if self.awake_guard is not None:self.awake_guard.check() _, 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() @@ -255,25 +352,107 @@ class Bridge: 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 install(self, folder, client, add_steam=True, artwork_id=None, storage_id='internal'): + def transfer_file(self, client, local, remote, transport): + if self.awake_guard is not None:self.awake_guard.check() + 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): + if self.awake_guard is not None:self.awake_guard.check() + 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'): + if transport == 'wifi': + self.progress('Installation: keeping Frame awake') + with FrameAwake(client, self.log) as guard: + self.awake_guard=guard + try:return self._install(folder,client,add_steam,artwork_id,storage_id,transport) + finally:self.awake_guard=None + 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_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 'frame' not in self.devices(): raise RuntimeError('Plug in Frame by USB and enable USB debugging first.') - # Verify SSH and USB point to the same physical device, not two different Frames. - 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') + 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 @@ -304,16 +483,18 @@ class Bridge: 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 USB ADB (APK + expansion data). Keep Frame connected…') - self.adb_run('frame', 'push', '-Z', folder / 'game.apk', stage + '/game.apk') + 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.adb_run('frame', 'push', '-Z', file, dest) + 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. @@ -323,12 +504,14 @@ class Bridge: 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.adb_run('frame', 'push', file, destination) - self.put_text(client, data_root + '/files/framebridge.conf', settings_text(game['scale'], game['controllers'], game['foveation'])) - self.put_text(client, base + '/settings.conf', settings_text(game['scale'], game['controllers'], game['foveation'])) + 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') @@ -339,9 +522,11 @@ class Bridge: 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): @@ -390,15 +575,16 @@ class Bridge: 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): + def update_settings(self, folder, client, scale, controllers, foveation, refresh_rate=0): folder = Path(folder) + check_refresh_adapter(folder, refresh_rate) 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)) - self.put_text(client, base + '/settings.conf', settings_text(scale, controllers, foveation)) + 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)) + 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.') diff --git a/keep_awake.py b/keep_awake.py new file mode 100644 index 0000000..3f4546a --- /dev/null +++ b/keep_awake.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Short-lived Frame sleep inhibitor with a disconnect/crash watchdog.""" +import shlex +import threading + + +class FrameAwake: + # Remote read timeout releases the lock even if an SSH connection becomes + # half-open. No power settings, services, or privileged files are changed. + command = ( + 'systemd-inhibit --what=idle:sleep --mode=block --who=Quest2Frame ' + '--why=' + shlex.quote('Wireless game installation') + ' bash -c ' + + shlex.quote('printf "Q2F_AWAKE_READY\\n"; ' + 'while IFS= read -r -t 45 pulse; do ' + '[ "$pulse" = Q2F_KEEP_AWAKE ] || break; done') + ) + + def __init__(self, client, log): + self.client=client;self.log=log;self.channel=None + self.streams=();self.stop=threading.Event();self.thread=None + self.failed=threading.Event() + + def __enter__(self): + try: + self.streams=self.client.exec_command(self.command,timeout=10) + _,out,_=self.streams;self.channel=out.channel + if out.readline().strip()!='Q2F_AWAKE_READY': + raise RuntimeError('Frame refused the sleep inhibitor. Check systemd-inhibit permissions on Frame; no wireless installation was started.') + self.check() + self.thread=threading.Thread(target=self._heartbeat,daemon=True) + self.thread.start() + self.log('Frame keep-awake active for wireless installation (idle/suspend blocked).') + return self + except Exception: + self.close() + raise + + def check(self): + if self.failed.is_set() or self.channel is None or self.channel.closed or self.channel.exit_status_ready(): + raise RuntimeError('Frame keep-awake connection was lost. Wireless installation stopped; reconnect Frame and retry.') + + def _heartbeat(self): + while not self.stop.wait(10): + try: + self.check() + self.channel.sendall(b'Q2F_KEEP_AWAKE\n') + except Exception: + self.failed.set();return + + def close(self): + self.stop.set() + if self.channel is not None: + try:self.channel.shutdown_write() + except Exception:pass + try:self.channel.close() + except Exception:pass + for stream in self.streams: + try:stream.close() + except Exception:pass + if self.thread is not None:self.thread.join(timeout=.2) + + def __exit__(self, *exc): + self.close() + self.log('Frame keep-awake released; normal power management resumes (within 45 seconds if the connection was lost).') diff --git a/manage_remote.py b/manage_remote.py new file mode 100644 index 0000000..acafe19 --- /dev/null +++ b/manage_remote.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Constrained Frame-side uninstall helper. Only managed package directories.""" +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import uuid + +def checked(path): + path=Path(path) + if path.resolve()!=path or path.is_symlink():raise ValueError('Symlinked installation paths are not supported') + return path + +def inspect_game(home, package, mounts): + if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+',package):raise ValueError('Invalid package') + anchor=checked(Path(home)/'Applications/quest-frame'/package) + metadata=checked(anchor/'deployment.json') + game=json.loads(metadata.read_text()) + if game.get('package')!=package or not isinstance(game.get('appid'),int):raise ValueError('Invalid deployment marker') + storage_uuid=game.get('storage_uuid','') + base=anchor + if storage_uuid: + matches=[m for m in mounts if m.get('uuid')==storage_uuid] + if len(matches)!=1:raise ValueError('Required SD/removable storage unavailable') + mount=checked(matches[0]['mount']) + if not os.path.ismount(mount):raise ValueError('Card is not mounted') + actual=json.loads(subprocess.check_output(['findmnt','-J','-M',str(mount),'-o','UUID'],text=True))['filesystems'][0]['uuid'] + if actual!=storage_uuid:raise ValueError('Mounted storage identity changed') + base=checked(mount/'Applications/quest-frame'/package) + checked(base/'lepton-app');checked(base/'lepton-data') + if not (base/'lepton-app/game.apk').is_file():raise ValueError('Managed game APK missing') + launcher=checked(anchor/'launch.sh') + text=launcher.read_text() + if 'STEAM_COMPAT_INSTALL_PATH' not in text or 'lepton-app' not in text:raise ValueError('Unrecognized launcher') + return dict(package=package,appid=game['appid'],anchor=str(anchor),base=str(base),storage_uuid=storage_uuid) + +def inventory(home,mounts): + root=checked(Path(home)/'Applications/quest-frame');result=[] + if not root.is_dir():return result + for path in sorted(root.iterdir()): + if not path.is_dir() or path.name.startswith('.'):continue + try:result.append(dict(inspect_game(home,path.name,mounts),error='')) + except (OSError,ValueError,KeyError,subprocess.SubprocessError) as exc: + if (path/'deployment.json').is_file():result.append(dict(package=path.name,error=str(exc))) + return result + +def delete_game(home,package,mounts,expected,keep_saves): + game=inspect_game(home,package,mounts) + if game!=expected:raise ValueError('Installation changed; refresh before uninstalling') + running=subprocess.check_output(['podman','ps','--format','{{.Names}}'],text=True).splitlines() + if 'lepton-steamlaunch-'+str(game['appid']) in running:raise ValueError('Close the game before uninstalling') + base=Path(game['base']);anchor=Path(game['anchor']);token=uuid.uuid4().hex + # Renaming isolates the validated game directory. No broad/glob removal. + staged=base.with_name('.quest2frame-uninstall-'+package+'-'+token) + staged_anchor=anchor.with_name('.quest2frame-uninstall-'+package+'-'+token) + base.rename(staged) + try: + if anchor!=base:anchor.rename(staged_anchor) + except Exception: + staged.rename(base);raise + saved=None + try: + if keep_saves: + names=[p.name for p in staged.iterdir() if p.name=='lepton-data' or p.name.startswith('previous-baked-')] + if names: + saved=checked(base.parent/('.quest2frame-saves-'+package+'-'+token));saved.mkdir() + for name in names:(staged/name).rename(saved/name) + # Reject nested mounts, even inside an otherwise legitimate installation. + directories=[staged]+([staged_anchor] if anchor!=base else []) + for directory in directories: + for root,dirs,files in os.walk(directory,followlinks=False): + if any(os.path.ismount(Path(root)/d) for d in dirs):raise ValueError('Nested mount found; files retained in '+str(directory)) + for directory in directories:shutil.rmtree(directory) + except Exception as exc: + raise RuntimeError(f'Uninstall cleanup incomplete. Recovery directories: {staged}, {staged_anchor}; saves: {saved}. {exc}') from exc + return dict(removed=game,saves=str(saved) if saved else None) + +def main(request): + home=str(Path.home()) + if request['action']=='list':result=inventory(home,request['mounts']) + elif request['action']=='check':result=inspect_game(home,request['package'],request['mounts']) + elif request['action']=='delete':result=delete_game(home,request['package'],request['mounts'],request['expected'],request['keep_saves']) + else:raise ValueError('Unknown action') + print(json.dumps(result)) diff --git a/manage_ui.py b/manage_ui.py new file mode 100644 index 0000000..ac2d177 --- /dev/null +++ b/manage_ui.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: GPL-3.0-only +import tkinter as tk +from tkinter import ttk, messagebox + +class InstalledGames(tk.Toplevel): + def __init__(self,wizard): + super().__init__(wizard);self.wizard=wizard;self.games={} + self.title('Quest2Frame — installed games');self.geometry('980x500') + box=ttk.Frame(self,padding=18);box.pack(fill='both',expand=True) + ttk.Label(box,text='Manage only games installed by Quest2Frame. Uses Frame SSH; USB is not required after initial verification.').pack(anchor='w') + ttk.Button(box,text='Refresh installed games',command=self.refresh).pack(anchor='w',pady=10) + self.tree=ttk.Treeview(box,columns=('game','location','state'),show='headings',selectmode='browse') + for name,width in [('game',370),('location',150),('state',350)]: + self.tree.heading(name,text=name.title());self.tree.column(name,width=width) + self.tree.pack(fill='both',expand=True) + self.keep=tk.BooleanVar(value=True) + ttk.Checkbutton(box,text='Preserve saves / Lepton data in a recovery folder (uses space; manual restore)',variable=self.keep).pack(anchor='w',pady=10) + ttk.Button(box,text='Uninstall selected game…',command=self.uninstall).pack(anchor='w') + self.message=tk.StringVar(value='Refresh to detect internal and SD-card installations.') + ttk.Label(box,textvariable=self.message,wraplength=920).pack(anchor='w',pady=10) + self.protocol('WM_DELETE_WINDOW',self.close) + def refresh(self): + if self.wizard.busy:return + credentials=self.wizard.credentials() + def work(): + with self.wizard.bridge.connect(**credentials) as client:return self.wizard.bridge.installed_games(client) + def done(games): + self.tree.delete(*self.tree.get_children());self.games={} + for index,game in enumerate(games): + ident=str(index);self.games[ident]=game + self.tree.insert('','end',iid=ident,values=(game['package'],'SD / removable' if game.get('storage_uuid') else 'Internal / unknown' if game.get('error') else 'Internal',game.get('error') or 'Installed')) + self.message.set(f'{len(games)} managed installations found. Missing SD cards must be mounted before uninstalling.') + self.wizard.run_job('Reading installed Quest2Frame games…',work,done) + def uninstall(self): + if self.wizard.busy:return + selection=self.tree.selection() + if not selection:return + game=self.games[selection[0]] + if game.get('error'):messagebox.showerror('Cannot uninstall',game['error'],parent=self);return + keep=self.keep.get();package=game['package'] + message=(f'Uninstall {package}?\n\nGame location: {game["base"]}\n\n' + 'Removes game files, previous app copies, and its Steam shortcut. Steam will restart; finish all active games first.\n\n'+ + ('Saves and per-game Lepton data will be moved to a recovery folder for manual restoration.' if keep else 'PERMANENTLY DELETE saves and per-game Lepton data, including previous baked snapshots.')+ + '\n\nQuest originals and PC backups are not changed.') + if not messagebox.askyesno('Confirm uninstall',message,parent=self,default='no'):return + credentials=self.wizard.credentials() + def work(): + with self.wizard.bridge.connect(**credentials) as client:return self.wizard.bridge.uninstall(client,package,keep,game) + def done(result): + ident=selection[0];self.tree.delete(ident);self.games.pop(ident,None) + self.message.set('Uninstalled. '+('Saves retained at '+result['saves'] if result['saves'] else 'No save recovery folder was created.')) + self.wizard.run_job('Uninstalling '+package+'…',work,done) + def close(self): + if self.wizard.busy:return + self.destroy() diff --git a/native/QuestAppLabels.java b/native/QuestAppLabels.java new file mode 100644 index 0000000..00118ed --- /dev/null +++ b/native/QuestAppLabels.java @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-only +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import org.json.JSONObject; + +/** Read installed app labels as the ADB shell user; no APK installation needed. */ +public final class QuestAppLabels { + public static void main(String[] args) throws Exception { + Class threadClass = Class.forName("android.app.ActivityThread"); + Object thread = threadClass.getMethod("systemMain").invoke(null); + Context context = (Context) threadClass.getMethod("getSystemContext").invoke(thread); + PackageManager pm = context.createPackageContext("com.android.shell", 0).getPackageManager(); + JSONObject labels = new JSONObject(); + for (String name : args) { + try { + ApplicationInfo info = pm.getApplicationInfo(name, 0); + labels.put(name, pm.getApplicationLabel(info).toString()); + } catch (PackageManager.NameNotFoundException ignored) { + // Packages can disappear during a scan; the caller retains its ID. + } + } + System.out.println("Q2F_LABELS=" + labels.toString()); + System.exit(0); + } +} diff --git a/native/frame_bridge.c b/native/frame_bridge.c index 705078c..e2fc56e 100644 --- a/native/frame_bridge.c +++ b/native/frame_bridge.c @@ -14,6 +14,10 @@ static PFN_xrGetInstanceProcAddr original; static pthread_once_t once = PTHREAD_ONCE_INIT; static XrInstance current; static float scale = 1.0f; +static float refresh_rate = 0.0f; +static int refresh_enabled; +/* Capability marker also checked by the Windows installer. */ +__attribute__((used)) static const char refresh_capability[] = "Q2F_REFRESH_RATE_V1"; static int foveation_fix = 1, controller_fix = 1; static void settings(const char *path) { @@ -22,6 +26,7 @@ static void settings(const char *path) { char line[256]; float value; while (fgets(line, sizeof(line), f)) { if (sscanf(line, "scale=%f", &value) == 1 && value >= .5f && value <= 2.f) scale = value; + if (sscanf(line, "refresh_rate=%f", &value) == 1 && (value == 0 || value == 72 || value == 80 || value == 90 || value == 120 || value == 144)) refresh_rate = value; if (sscanf(line, "foveation_fix=%f", &value) == 1) foveation_fix = value != 0; if (sscanf(line, "controller_fix=%f", &value) == 1) controller_fix = value != 0; } @@ -65,17 +70,65 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info if (!strcmp(info->enabledExtensionNames[i], "XR_KHR_android_create_instance")) enabled = 1; XrInstanceCreateInfo fixed = *info; const char **names = NULL; - if (has && !enabled) { - names = calloc(info->enabledExtensionCount + 1, sizeof(*names)); + int refresh_present = 0, refresh_supported = 0; + for (uint32_t i=0; ienabledExtensionCount; ++i) + if (!strcmp(info->enabledExtensionNames[i], XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME)) refresh_present = 1; + PFN_xrEnumerateInstanceExtensionProperties enumerate = (PFN_xrEnumerateInstanceExtensionProperties)resolve(XR_NULL_HANDLE, "xrEnumerateInstanceExtensionProperties"); + uint32_t count = 0; + if (enumerate && XR_SUCCEEDED(enumerate(NULL, 0, &count, NULL)) && count) { + XrExtensionProperties *props = calloc(count, sizeof(*props)); + if (props) { + uint32_t capacity = count; + for (uint32_t i=0; ienabledExtensionCount + 2, sizeof(*names)); if (!names) return XR_ERROR_OUT_OF_MEMORY; for (uint32_t i=0; ienabledExtensionCount; ++i) names[i] = info->enabledExtensionNames[i]; - names[info->enabledExtensionCount] = "XR_KHR_android_create_instance"; - fixed.enabledExtensionNames = names; fixed.enabledExtensionCount++; + if (has && !enabled) names[fixed.enabledExtensionCount++] = "XR_KHR_android_create_instance"; + if (refresh_supported && !refresh_present) names[fixed.enabledExtensionCount++] = XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME; + fixed.enabledExtensionNames = names; } XrResult result = fn(&fixed, instance); free(names); - if (XR_SUCCEEDED(result)) current = *instance; + if (XR_SUCCEEDED(result)) { current = *instance; refresh_enabled = refresh_present || refresh_supported; } return result; } +static int supports_refresh(XrSession session) { + if (!refresh_enabled || !refresh_rate) return 0; + PFN_xrEnumerateDisplayRefreshRatesFB fn = (PFN_xrEnumerateDisplayRefreshRatesFB)resolve(current, "xrEnumerateDisplayRefreshRatesFB"); + uint32_t count = 0; + if (!fn || XR_FAILED(fn(session, 0, &count, NULL)) || !count) return 0; + float *rates = calloc(count, sizeof(*rates)); + if (!rates) return 0; + uint32_t capacity = count; int supported = 0; + if (XR_SUCCEEDED(fn(session, capacity, &count, rates))) + for (uint32_t i=0; i +static int offered=1, requested_count, extension_count; +static float requested; +static XrResult begin_result=XR_SUCCESS, request_result=XR_SUCCESS; +static XrResult mock_extensions(const char *layer,uint32_t cap,uint32_t *count,XrExtensionProperties *props) { + (void)layer; *count=offered; + if (cap && offered) strcpy(props[0].extensionName, XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME); + return XR_SUCCESS; +} +static XrResult mock_create(const XrInstanceCreateInfo *info,XrInstance *instance) { + extension_count=0; + for(uint32_t i=0;ienabledExtensionCount;++i) + if(!strcmp(info->enabledExtensionNames[i],XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME)) ++extension_count; + *instance=(XrInstance)(uintptr_t)1; return XR_SUCCESS; +} +static XrResult mock_rates(XrSession session,uint32_t cap,uint32_t *count,float *rates) { + (void)session; *count=2; if(cap>=2) {rates[0]=72;rates[1]=90;} return XR_SUCCESS; +} +static XrResult mock_request(XrSession session,float rate) { + (void)session; requested=rate; ++requested_count; return request_result; +} +static XrResult mock_begin(XrSession session,const XrSessionBeginInfo *info) { + (void)session;(void)info; return begin_result; +} +static XrResult mock_proc(XrInstance instance,const char *name,PFN_xrVoidFunction *fn) { + (void)instance; *fn=NULL; +#define MOCK(n,f) if(!strcmp(name,n)) *fn=(PFN_xrVoidFunction)f + MOCK("xrEnumerateInstanceExtensionProperties",mock_extensions); + MOCK("xrCreateInstance",mock_create); + MOCK("xrEnumerateDisplayRefreshRatesFB",mock_rates); + MOCK("xrRequestDisplayRefreshRateFB",mock_request); + MOCK("xrBeginSession",mock_begin); + return *fn?XR_SUCCESS:XR_ERROR_FUNCTION_UNSUPPORTED; +} +static void noop(void) {} +int main(void) { + pthread_once(&once,noop); original=mock_proc; + XrInstanceCreateInfo info={.type=XR_TYPE_INSTANCE_CREATE_INFO}; XrInstance instance; + assert(xrCreateInstance(&info,&instance)==XR_SUCCESS && extension_count==1); + XrSession session=(XrSession)(uintptr_t)2; + refresh_rate=90; + assert(xrBeginSession(session,NULL)==XR_SUCCESS && requested==90 && requested_count==1); + assert(xrRequestDisplayRefreshRateFB(session,72)==XR_SUCCESS && requested==90); + refresh_rate=120; requested_count=0; + assert(xrBeginSession(session,NULL)==XR_SUCCESS && requested_count==0); + assert(xrRequestDisplayRefreshRateFB(session,72)==XR_SUCCESS && requested==72); + refresh_rate=0; requested_count=0; + assert(xrBeginSession(session,NULL)==XR_SUCCESS && requested_count==0); + assert(xrRequestDisplayRefreshRateFB(session,72)==XR_SUCCESS && requested==72); + refresh_rate=90; request_result=XR_ERROR_RUNTIME_FAILURE; + assert(xrBeginSession(session,NULL)==XR_SUCCESS); // preference cannot fail startup + begin_result=XR_ERROR_SESSION_RUNNING; requested_count=0; + assert(xrBeginSession(session,NULL)==XR_ERROR_SESSION_RUNNING && requested_count==0); + begin_result=XR_SUCCESS; + const char *names[]={XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME}; + info.enabledExtensionCount=1;info.enabledExtensionNames=names; + assert(xrCreateInstance(&info,&instance)==XR_SUCCESS && extension_count==1); + offered=0;info.enabledExtensionCount=0;info.enabledExtensionNames=NULL; + assert(xrCreateInstance(&info,&instance)==XR_SUCCESS && extension_count==0); + assert(xrBeginSession(session,NULL)==XR_SUCCESS && requested_count==0); + puts("Refresh-rate behavior tests passed"); return 0; +} diff --git a/native/test_stubs/android/log.h b/native/test_stubs/android/log.h new file mode 100644 index 0000000..dc39bc6 --- /dev/null +++ b/native/test_stubs/android/log.h @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once +#define ANDROID_LOG_INFO 4 +static inline int __android_log_print(int priority, const char *tag, const char *format, ...) { + (void)priority; (void)tag; (void)format; return 0; +} diff --git a/steam_shortcuts.py b/steam_shortcuts.py index be66be2..474779f 100644 --- a/steam_shortcuts.py +++ b/steam_shortcuts.py @@ -41,6 +41,17 @@ def encode(obj): def app_id(exe, title): return zlib.crc32((exe + title).encode('utf-8')) | 0x80000000 +def remove(data, exe, ident): + """Remove only the exact managed launcher, rejecting ID collisions.""" + root=decode(data) if data else {'shortcuts':{}} + shortcuts=root.get('shortcuts',{}) + for key,value in list(shortcuts.items()): + if value.get('appid')==ident or value.get('Exe')==exe: + if value.get('appid')!=ident or value.get('Exe')!=exe: + raise ValueError('Steam shortcut identity mismatch; nothing removed') + del shortcuts[key] + return encode(root) + def upsert(data, exe, title, directory, icon=''): root = decode(data) if data else {'shortcuts': {}} shortcuts = root.setdefault('shortcuts', {}) diff --git a/test_app_names.py b/test_app_names.py new file mode 100644 index 0000000..be04a6b --- /dev/null +++ b/test_app_names.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: GPL-3.0-only +import unittest +from pathlib import Path +from unittest.mock import patch +from bridge import Bridge +import test_app + + +class NameLookupTests(unittest.TestCase): + def test_names_sorted_and_unknown_package_retained(self): + b=Bridge(lambda _:None) + with patch.object(b,'packages',return_value=['com.z.game','com.a.game']), \ + patch.object(b,'label_helper',return_value=Path('classes.dex')),patch.object(b,'adb_run'), \ + patch.object(b,'shell',return_value='noise\nQ2F_LABELS={"com.z.game":"A Friendly Name"}') as shell: + self.assertEqual(b.installed_apps('quest'),[ + dict(package='com.z.game',title='A Friendly Name'),dict(package='com.a.game',title='com.a.game')]) + self.assertEqual(shell.call_args.args[1:3],('rm','-f')) + + def test_lookup_failure_falls_back_without_losing_apps(self): + b=Bridge(lambda _:None) + with patch.object(b,'packages',return_value=['com.test.game']), \ + patch.object(b,'label_helper',side_effect=RuntimeError('SDK missing')),patch.object(b,'shell'): + self.assertEqual(b.installed_apps('quest'),[dict(package='com.test.game',title='com.test.game')]) + + +class NameUITests(unittest.TestCase): + setUp=test_app.ArtworkUITests.setUp + tearDown=test_app.ArtworkUITests.tearDown + + def test_filter_and_selection_keep_real_package(self): + self.app.auto_art.set(False) + self.app.package_list=['com.one.game','com.two.game'] + self.app.package_names={'com.one.game':'Same Name','com.two.game':'Same Name'} + self.app.vars['filter'].set('two') + self.assertEqual(self.app.listbox.size(),1) + self.app.listbox.selection_set(0);self.app.select_game() + self.assertEqual(self.app.package,'com.two.game') + self.assertEqual(self.app.vars['title'].get(),'Same Name') + self.app.vars['filter'].set('same name') + self.assertEqual(self.app.listbox.size(),2) + + def test_multi_select_passes_package_ids(self): + self.app.package_list=['com.one.game','com.two.game'] + self.app.package_names={'com.one.game':'One','com.two.game':'Two'} + self.app.filter_packages();self.app.listbox.selection_set(0,'end') + with patch.object(self.app,'show_batch') as show: + self.app.select_game() + show.assert_called_once_with(['com.one.game','com.two.game']) diff --git a/test_progress.py b/test_progress.py new file mode 100644 index 0000000..bd15c51 --- /dev/null +++ b/test_progress.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: GPL-3.0-only +import io +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch +from bridge import Bridge +import test_app + + +class ProgressTests(unittest.TestCase): + def test_wifi_byte_progress_and_completion(self): + events=[];b=Bridge(lambda _:None,lambda *event:events.append(event)) + client=MagicMock() + def put(local,remote,callback): + callback(512,1024);callback(1024,1024) + client.open_sftp.return_value.__enter__.return_value.put.side_effect=put + b.transfer_file(client,Path('game.apk'),'/remote/game.apk','wifi') + self.assertTrue(any(percent==50 for _,percent in events)) + self.assertEqual(events[-1],('Installing: game.apk',100)) + + def test_failed_transfer_never_reports_complete(self): + events=[];b=Bridge(lambda _:None,lambda *event:events.append(event)) + client=MagicMock() + client.open_sftp.return_value.__enter__.return_value.put.side_effect=OSError('offline') + with self.assertRaises(OSError):b.transfer_file(client,Path('game.apk'),'/remote/game.apk','wifi') + self.assertFalse(any(percent==100 for _,percent in events)) + + def test_adb_reported_percent(self): + events=[];b=Bridge(lambda _:None,lambda *event:events.append(event)) + b.progress('Backup: APK') + process=MagicMock(stdout=io.StringIO('[ 25%] file.apk\n[100%] file.apk\n'),returncode=0) + with patch('bridge.subprocess.Popen',return_value=process):b.adb_run('quest','pull','file.apk','local.apk') + self.assertEqual(events[-2:],[('Backup: APK',25),('Backup: APK',100)]) + + def test_batch_context(self): + events=[];b=Bridge(lambda _:None,lambda *event:events.append(event)) + b.progress_prefix='Game 2/3: ' + b.progress('Signing') + self.assertEqual(events,[('Game 2/3: Signing',None)]) + + +class ProgressUITests(unittest.TestCase): + setUp=test_app.ArtworkUITests.setUp + tearDown=test_app.ArtworkUITests.tearDown + + def test_modes_and_percent(self): + self.app.show_progress('Signing',None) + self.assertEqual(str(self.app.progress['mode']),'indeterminate') + self.app.show_progress('Uploading',42) + self.assertEqual(str(self.app.progress['mode']),'determinate') + self.assertEqual(self.app.progress_value.get(),42) + self.assertIn('42% of this stage',self.app.progress_text.get()) + + def test_worker_events_and_failure(self): + self.app.events.put(('progress',('Uploading',60))) + self.app.drain() + self.assertEqual(self.app.progress_value.get(),60) + self.app.events.put(('error','Disconnected')) + with patch('app.messagebox.showerror'):self.app.drain() + self.assertEqual(self.app.progress_value.get(),0) + self.assertFalse(self.app.busy) + self.assertIn('failed',self.app.progress_text.get()) diff --git a/test_refresh.py b/test_refresh.py new file mode 100644 index 0000000..cbbf30e --- /dev/null +++ b/test_refresh.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: GPL-3.0-only +import json +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch +from bridge import Bridge, refresh_value, settings_text, check_refresh_adapter +import test_app + + +class RefreshTests(unittest.TestCase): + def test_rates(self): + for value in ('Default',0,72,80,90,120,144): + self.assertIn('refresh_rate='+str(refresh_value(value)),settings_text(refresh_rate=value)) + for value in (-1,60,91,float('nan'),float('inf'),'90; reboot'): + with self.assertRaises(ValueError): refresh_value(value) + + def test_old_build_default_and_explicit_rate(self): + with tempfile.TemporaryDirectory() as tmp: + with zipfile.ZipFile(Path(tmp)/'game.apk','w') as apk: + apk.writestr('lib/arm64-v8a/libopenxr_loader_generic.so',b'old') + check_refresh_adapter(tmp,0) + with self.assertRaisesRegex(ValueError,'reconversion'):check_refresh_adapter(tmp,90) + b=Bridge() + with patch.object(b,'remote') as remote,patch.object(b,'put_text') as put: + with self.assertRaises(ValueError):b.update_settings(tmp,None,100,True,True,90) + remote.assert_not_called();put.assert_not_called() + + def test_update_settings(self): + with tempfile.TemporaryDirectory() as tmp: + folder=Path(tmp) + with zipfile.ZipFile(folder/'game.apk','w') as apk: + apk.writestr('lib/arm64-v8a/libopenxr_loader_generic.so',b'Q2F_REFRESH_RATE_V1') + (folder/'game.json').write_text('{}') + (folder/'deployment.json').write_text(json.dumps({'package':'com.example.game'})) + b=Bridge(lambda _:None) + with patch.object(b,'deployment_base',return_value='/safe/game'),patch.object(b,'put_text') as put: + b.update_settings(folder,None,100,True,True,90) + self.assertEqual(put.call_count,2) + for call in put.call_args_list:self.assertIn('refresh_rate=90\n',call.args[2]) + self.assertEqual(json.loads((folder/'game.json').read_text())['refresh_rate'],90) + + +class RefreshUITests(unittest.TestCase): + setUp=test_app.ArtworkUITests.setUp + tearDown=test_app.ArtworkUITests.tearDown + + def test_ui_selection(self): + self.assertEqual(self.app.options()['refresh_rate'],0) + self.app.vars['refresh_rate'].set('90') + self.assertEqual(self.app.options()['refresh_rate'],90) diff --git a/test_uninstall.py b/test_uninstall.py new file mode 100644 index 0000000..fcb45be --- /dev/null +++ b/test_uninstall.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: GPL-3.0-only +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +from manage_remote import inspect_game, inventory, delete_game +from steam_shortcuts import upsert, remove, decode, encode + +class UninstallTests(unittest.TestCase): + def fixture(self,root,package='com.test.game'): + base=Path(root)/'Applications/quest-frame'/package + (base/'lepton-app').mkdir(parents=True) + (base/'lepton-app/game.apk').write_bytes(b'test') + (base/'lepton-data').mkdir();(base/'lepton-data/save').write_text('progress') + (base/'deployment.json').write_text(json.dumps(dict(package=package,appid=123))) + (base/'launch.sh').write_text('export STEAM_COMPAT_INSTALL_PATH="$app_dir/lepton-app"') + return base + def test_only_managed_directories_listed(self): + with tempfile.TemporaryDirectory() as tmp: + self.fixture(tmp) + (Path(tmp)/'Applications/quest-frame/other').mkdir() + self.assertEqual([g['package'] for g in inventory(tmp,[])],['com.test.game']) + def test_preserves_saves_and_other_games(self): + with tempfile.TemporaryDirectory() as tmp: + base=self.fixture(tmp);other=self.fixture(tmp,'com.test.other') + expected=inspect_game(tmp,'com.test.game',[]) + with patch('manage_remote.subprocess.check_output',return_value=''): + result=delete_game(tmp,'com.test.game',[],expected,True) + self.assertFalse(base.exists());self.assertTrue(other.exists()) + self.assertEqual((Path(result['saves'])/'lepton-data/save').read_text(),'progress') + self.assertFalse((Path(result['saves'])/'lepton-app').exists()) + def test_delete_saves_option(self): + with tempfile.TemporaryDirectory() as tmp: + base=self.fixture(tmp);expected=inspect_game(tmp,'com.test.game',[]) + with patch('manage_remote.subprocess.check_output',return_value=''): + result=delete_game(tmp,'com.test.game',[],expected,False) + self.assertIsNone(result['saves']);self.assertFalse(base.exists()) + def test_reject_traversal_missing_marker_and_changed_identity(self): + with tempfile.TemporaryDirectory() as tmp: + base=self.fixture(tmp) + with self.assertRaises(ValueError):inspect_game(tmp,'../outside',[]) + expected=inspect_game(tmp,'com.test.game',[]);expected['appid']=999 + with self.assertRaises(ValueError):delete_game(tmp,'com.test.game',[],expected,False) + self.assertTrue(base.exists()) + def test_running_game_refused(self): + with tempfile.TemporaryDirectory() as tmp: + base=self.fixture(tmp);expected=inspect_game(tmp,'com.test.game',[]) + with patch('manage_remote.subprocess.check_output',return_value='lepton-steamlaunch-123\n'): + with self.assertRaises(ValueError):delete_game(tmp,'com.test.game',[],expected,False) + self.assertTrue(base.exists()) + def test_missing_card_refused(self): + with tempfile.TemporaryDirectory() as tmp: + base=self.fixture(tmp) + (base/'deployment.json').write_text(json.dumps(dict(package='com.test.game',appid=123,storage_uuid='missing'))) + with self.assertRaisesRegex(ValueError,'unavailable'):inspect_game(tmp,'com.test.game',[]) + self.assertTrue(base.exists()) + def test_sd_uninstall_preserves_saves_on_card(self): + with tempfile.TemporaryDirectory() as tmp: + home=Path(tmp)/'home';card=Path(tmp)/'Renamed Card';home.mkdir();card.mkdir() + anchor=self.fixture(home);base=self.fixture(card) + (anchor/'deployment.json').write_text(json.dumps(dict(package='com.test.game',appid=123,storage_uuid='card-uuid'))) + mounts=[dict(uuid='card-uuid',mount=str(card))] + def output(args,**kwargs): + return json.dumps({'filesystems':[{'uuid':'card-uuid'}]}) if args[0]=='findmnt' else '' + with patch('manage_remote.os.path.ismount',side_effect=lambda path:Path(path)==card),patch('manage_remote.subprocess.check_output',side_effect=output): + expected=inspect_game(home,'com.test.game',mounts) + result=delete_game(home,'com.test.game',mounts,expected,True) + self.assertFalse(anchor.exists());self.assertFalse(base.exists()) + self.assertTrue(Path(result['saves']).is_relative_to(card)) + def test_vdf_removal_preserves_other_entries_and_unknown_fields(self): + data,ident=upsert(b'','"/a/launch.sh"','A','/a') + data,_=upsert(data,'"/b/launch.sh"','B','/b') + tree=decode(data);tree['custom']='retained';data=encode(tree) + result=decode(remove(data,'"/a/launch.sh"',ident)) + self.assertEqual(result['custom'],'retained') + self.assertEqual([v['appname'] for v in result['shortcuts'].values()],['B']) + with self.assertRaises(ValueError):remove(data,'"/wrong/launch.sh"',ident) diff --git a/test_wireless.py b/test_wireless.py new file mode 100644 index 0000000..90b09a2 --- /dev/null +++ b/test_wireless.py @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: GPL-3.0-only +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch +from bridge import Bridge, digest +from batch import run_batch + + +class WirelessTests(unittest.TestCase): + def exercise(self, corrupt=False, transport='wifi'): + with tempfile.TemporaryDirectory() as tmp: + folder=Path(tmp); backup=folder/'backup' + (backup/'obb').mkdir(parents=True); (backup/'data/files').mkdir(parents=True) + (folder/'game.apk').write_bytes(b'apk') + (backup/'obb/main.obb').write_bytes(b'obb') + (backup/'data/files/save.sav').write_bytes(b'old quest progress') + (backup/'data/files/new.dat').write_bytes(b'new data') + game=dict(package='com.test.game',backup=str(backup),title='Test',scale=125, + controllers=True,foveation=True,steam_id='',sha256=digest(folder/'game.apk')) + (folder/'game.json').write_text(json.dumps(game)) + b=Bridge(lambda _:None); client=MagicMock(); uploaded={}; commands=[] + client.open_sftp.return_value.__enter__.return_value.put.side_effect=lambda local,remote,**kwargs: uploaded.update({remote:Path(local)}) + def remote(c,cmd,check=True): + commands.append(cmd) + if cmd=='printf %s "$HOME"':return '/home/test' + if cmd.startswith('if test -e ') and 'save.sav' in cmd:return 'EXISTS' + if cmd.startswith('sha256sum '): + path=cmd[len('sha256sum '):].strip("'") + return ('bad' if corrupt else digest(uploaded[path]))+' file' + return '' + with patch('bridge.FrameAwake'), \ + patch.object(b,'devices',side_effect=AssertionError('Wi-Fi must not query ADB')), \ + patch.object(b,'adb_run',side_effect=AssertionError('Wi-Fi must not use ADB')), \ + patch.object(b,'remote',side_effect=remote),patch.object(b,'put_text'), \ + patch.object(b,'storage_locations',return_value=[dict(id='internal',uuid='',mount='/home/test',free=10**12)]): + if corrupt: + with self.assertRaisesRegex(RuntimeError,'verification failed'): + b.install(folder,client,False,transport=transport) + self.assertFalse(any('previous-app-' in c for c in commands)) + self.assertFalse((folder/'deployment.json').exists()) + else: + b.install(folder,client,False,transport=transport) + self.assertTrue((folder/'deployment.json').exists()) + return uploaded + + def test_wifi_installs_without_adb_and_preserves_existing_saves(self): + uploads=self.exercise() + names=[p.name for p in uploads.values()] + self.assertCountEqual(names,['game.apk','main.obb','new.dat']) + + def test_bad_transfer_does_not_replace_installed_app(self):self.exercise(corrupt=True) + + def test_invalid_transport_fails_before_accessing_files(self): + with self.assertRaises(ValueError):Bridge().install('missing',None,transport='invalid') + + def test_usb_still_uses_adb(self): + b=Bridge() + with patch.object(b,'adb_run') as adb: + b.transfer_file(None,Path('game.apk'),'/remote/game.apk','usb') + adb.assert_called_once_with('frame','push','-Z',Path('game.apk'),'/remote/game.apk') + + def test_batch_passes_wireless_selection(self): + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp)/'game.json').write_text(json.dumps(dict(package='com.test.game',title='Test'))) + entry=dict(package='com.test.game',build=tmp,options=dict(title='',steam_id='')) + bridge=MagicMock() + run_batch([entry],'install',bridge,None,{},False,lambda:None,transport='wifi') + self.assertEqual(entry['status'],'Installed') + self.assertEqual(bridge.install.call_args.args[-1],'wifi')