From 9b0fedf602a188c47684c73fc0a8e7955b6b64b2 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:13:42 +1000 Subject: [PATCH] Store: OBB game data becomes a follow-up 'Add game data' step install_obb needs the app's running instance, which doesn't exist straight after install, so the store no longer calls it there. The install result says the app needs its game data; after opening the app once, 'Add game data' copies the downloaded OBB files (a background job). Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_apk_search.py | 26 +++++++++++++++++++++++--- ui/apk_sources/search.py | 19 ++++++++++++++++++- ui/index.html | 13 ++++++++++--- ui/server.py | 3 +++ 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py index b7edfe1..ab9c2e7 100644 --- a/tests/test_apk_search.py +++ b/tests/test_apk_search.py @@ -31,8 +31,8 @@ class SettingsTest(unittest.TestCase): p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json') p.start() self.addCleanup(p.stop) - search._running.clear() - search._status.clear() + for state in (search._running, search._pending, search._status, search._game_data): + state.clear() class SearchTests(SettingsTest): @@ -141,8 +141,16 @@ class SearchTests(SettingsTest): patch.object(server.frame_android, 'install_obb', create=True) as obb: # An actual function exposes the future signature for inspection. with patch.object(server.frame_android, 'install', install): - search.install('one', 'brush', 1) + result = search.install('one', 'brush', 1) + # The app's instance isn't running right after install, so game data is a follow-up step. + obb.assert_not_called() + self.assertTrue(result['game_data']) + self.assertIn('Add game data', result['message']) + obb.return_value = {'package': 'org.brush', 'obb': []} + self.assertEqual(search.add_game_data('org.brush')['message'], 'Game data added') obb.assert_called_once_with('org.brush', ['main.obb']) + with self.assertRaisesRegex(SourceError, 'install it again'): + search.add_game_data('org.brush') mod.download.assert_called_once_with(mod.sources()[0] | {'status': 'not searched'}, 'brush', version_code=1) def test_discovery_and_demo_are_opt_in(self): @@ -265,6 +273,18 @@ class EndpointTests(SettingsTest): self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200) mod.remove_repo.assert_called_once_with(source_id='fdroid') + def test_http_add_game_data_job(self): + search._game_data['org.brush'] = ['/cache/main.1.org.brush.obb'] + self.addCleanup(search._game_data.clear) + with patch.object(server.frame_android, 'install_obb', create=True, + side_effect=server.frame_android.FrameError('start this app instance before installing OBB data')): + job = self.wait(self.request('POST', '/api/sources', {'action': 'game-data', 'package': 'org.brush'})[1]['job']) + self.assertEqual(job['error'], 'start this app instance before installing OBB data') + with patch.object(server.frame_android, 'install_obb', create=True, return_value={'package': 'org.brush'}) as obb: + job = self.wait(self.request('POST', '/api/sources', {'action': 'game-data', 'package': 'org.brush'})[1]['job']) + self.assertEqual(job['message'], 'Game data added') + obb.assert_called_once_with('org.brush', ['/cache/main.1.org.brush.obb']) + def wait(self, job_id): for _ in range(200): job = self.request('GET', '/api/job?id=' + job_id)[1] diff --git a/ui/apk_sources/search.py b/ui/apk_sources/search.py index ddc7289..619be8f 100644 --- a/ui/apk_sources/search.py +++ b/ui/apk_sources/search.py @@ -20,6 +20,7 @@ from apk_sources import SourceError, SourceLimited _lock = threading.RLock() _running = {} _pending = {} # source id -> newest query waiting for the running one +_game_data = {} # package -> downloaded OBB paths waiting for "Add game data" _status = {} TIMEOUT = 12 @@ -315,5 +316,21 @@ def install(source_id, entry_id, version_code=None, progress=None): progress('Installing', None) result = frame_android.install(downloaded['apk'], **kwargs) if obb: - frame_android.install_obb(result['package'], obb) + # OBB files go into the app's own instance, which only exists while the app runs. + with _lock: + _game_data[result['package']] = list(obb) + result = dict(result, game_data=True, message='Installed ' + (entry.get('name') or result['package']) + + '. It also needs its game data: open it once on the Frame, then choose Add game data.') return result + + +def add_game_data(package): + import frame_android + with _lock: + paths = _game_data.get(package) + if not paths: + raise SourceError('No downloaded game data is waiting for this app; install it again from the store') + result = frame_android.install_obb(package, paths) + with _lock: + _game_data.pop(package, None) + return dict(result, message='Game data added') diff --git a/ui/index.html b/ui/index.html index 72497b2..2aa0bad 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2032,11 +2032,11 @@ function renderDetail() { function updateInstall() { const o=currentOffer();if(!o || !$("detailInstall"))return; const job=sourceState.jobs.get(`${o.source}:${o.id}`), blocked=o.verdict?.tone==='blocked', page=sourcePage(o.page), b=$("detailInstall"); - b.disabled=!!(job && !job.done) || (blocked && !job?.result) || (!o.downloadable && !page); - b.textContent=job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on Frame') : 'Get from the creator ↗'; + b.disabled=!!(job && !job.done) || !!job?.addingData || (blocked && !job?.result) || (!o.downloadable && !page); + b.textContent=job?.result?.game_data ? (job.addingData ? 'Adding game data…' : 'Add game data') : job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on Frame') : 'Get from the creator ↗'; $("detailProgress").hidden=!job || job.done; if(job?.percent!=null)$("detailProgress").value=job.percent;else $("detailProgress").removeAttribute("value"); - $("detailInstallNote").textContent=job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Keep exploring. We’ll take care of the installation.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.'); + $("detailInstallNote").textContent=job?.result?.game_data ? (job.dataError || 'Installed. It also needs its game data: open it once on the Frame, then choose Add game data.') : job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Keep exploring. We’ll take care of the installation.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.'); $("detailOffers").querySelectorAll('input').forEach(input=>input.disabled=!!job&&!job.done); } async function openStoreApp(index) { @@ -2056,6 +2056,13 @@ async function loadOfferDetails() { } async function installStoreApp() { const o=currentOffer(), key=`${o.source}:${o.id}`, existing=sourceState.jobs.get(key); + if(existing?.result?.game_data) { + let error="";existing.addingData=true;existing.dataError="";updateInstall(); + const added=await runJob(`Add game data for ${o.name}`,`${key}:data`,()=>api('/api/sources',{action:'game-data',package:existing.result.package}),j=>{error=j.error||"";}); + Object.assign(existing,{addingData:false,dataError:added?"":error||"We couldn't add the game data. Try again."}); + if(added)existing.result={...existing.result,game_data:false}; + updateInstall();return; + } if(existing?.result) { await act(`Open ${o.name}`,()=>api('/api/android',{action:'launch',package:existing.result.package}),$("detailInstall"));return; } if(!o.downloadable) { const url=sourcePage(o.page);if(url)window.open(url,'_blank','noopener,noreferrer');return; } if(existing&&!existing.done)return; diff --git a/ui/server.py b/ui/server.py index e374460..f11655d 100755 --- a/ui/server.py +++ b/ui/server.py @@ -1294,6 +1294,9 @@ def source_manage(body): return {'message': message, 'source': {k: source.get(k) for k in ('id', 'name', 'fingerprint', 'trust_on_first_use')}} return start_job('Add repository', add) + if action == 'game-data': + package = source_text(body, 'package') + return start_job('Add game data', lambda: apk_search.add_game_data(package)) if action == 'remove': apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source')) return {'message': 'Repository removed'}