diff --git a/docs/framey-loader.md b/docs/framey-loader.md index 532d0c7..6f4a234 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -61,6 +61,13 @@ The root label is a compatibility warning; other APIs are checked only when a plugin is opened. The store still needs the managed Decky user service running before its Install or Update buttons are enabled. +Install, Update, and Remove show the current job stage in the headset panel. +The store verifies each downloaded archive and the headset applies it with a +rollback if Decky cannot start. The panel remembers the current job ID for this +browser session, so **Check result** can resume polling after a navigation or +temporary connection error. Refresh the installed list before starting another +operation after an error. + ## For plugin authors Publish a normal [Decky store](https://plugins.deckbrew.xyz/) API v2 release diff --git a/tests/page/framey_store.mjs b/tests/page/framey_store.mjs index 1ce340e..5db9aa9 100644 --- a/tests/page/framey_store.mjs +++ b/tests/page/framey_store.mjs @@ -9,13 +9,16 @@ class Element { append(...nodes) {this.children.push(...nodes);} replaceChildren(...nodes) {this.children=nodes; this.textContent='';} scrollIntoView() {} + removeAttribute(name) {delete this[name];} } const elements = new Map(); elements.set('deckyStoreFilter',Object.assign(new Element('deckyStoreFilter'),{value:'all'})); const document = {getElementById(id) {if(!elements.has(id)) elements.set(id,new Element(id)); return elements.get(id);}, createElement() {return new Element();}, hidden:false}; +const session = new Map(); const ctx = vm.createContext({document, location:{hash:'',pathname:'/'}, - sessionStorage:{getItem:()=>'',setItem(){}}, history:{replaceState(){}}, + sessionStorage:{getItem:key=>session.get(key)||'',setItem:(key,value)=>session.set(key,value),removeItem:key=>session.delete(key)}, + history:{replaceState(){}}, confirm:()=>true, window:{addEventListener(){}}, setInterval(){}, setTimeout(){}, fetch(){throw Error('No network expected');}, console}); vm.runInContext(script,ctx); @@ -43,3 +46,27 @@ el('deckyStoreFilter').value='compatible'; el('deckyStoreFilter').onchange(); assert.equal(el('deckyStoreSummary').textContent,'42 plugins found'); el('deckySearch').value='no matching plugin'; el('deckySearch').oninput(); assert.equal(el('deckyStoreList').textContent,'No plugins match this search.'); + +// The page polls the actual job endpoint and keeps a separate visible result. +ctx.mockApi=async path => { + if(path==='/api/decky/plugin')return {job:'job-1'}; + if(path.startsWith('/api/job'))return {done:true,message:'Installed Plugin 2'}; + if(path==='/api/decky')return {running:true,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]}; + throw Error(`unexpected ${path}`); +}; +vm.runInContext('api=mockApi',ctx); +await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2"})',ctx); +assert.equal(el('deckyJobStatus').textContent,'Installed Plugin 2'); +assert.equal(el('deckyJobProgress').value,100); +assert.equal(el('deckyJobCheck').hidden,true); +assert.equal(session.has('frameyPluginJob'),false); +ctx.mockApi=async path => { + if(path==='/api/decky/plugin')return {job:'job-2'}; + if(path.startsWith('/api/job'))return {done:true,error:'archive digest mismatch'}; + if(path==='/api/decky')return {running:true,plugins:[]}; + throw Error(`unexpected ${path}`); +}; +vm.runInContext('api=mockApi',ctx); +await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2"})',ctx); +assert.match(el('deckyJobStatus').textContent,/Error: archive digest mismatch/); +assert.equal(session.has('frameyPluginJob'),false); diff --git a/tests/test_frame_decky_store.py b/tests/test_frame_decky_store.py index 24d4a57..553ab06 100644 --- a/tests/test_frame_decky_store.py +++ b/tests/test_frame_decky_store.py @@ -118,10 +118,12 @@ class StoreHost(unittest.TestCase): [{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]} def ssh(command, **kwargs): return '{}' + progress = [] with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \ patch.object(frame_decky_store, 'catalog') as network: - frame_decky_store.change(ssh, 'remove', 42) + frame_decky_store.change(ssh, 'remove', 42, lambda stage, percent: progress.append((stage, percent))) network.assert_not_called() + self.assertEqual(progress, [('Checking installed plugins', 5), ('Removing plugin on Frame', 80)]) if __name__ == '__main__': diff --git a/ui/frame_decky_store.py b/ui/frame_decky_store.py index 031425c..2886065 100644 --- a/ui/frame_decky_store.py +++ b/ui/frame_decky_store.py @@ -162,9 +162,10 @@ finally: ''' -def change(ssh, action, plugin_id): +def change(ssh, action, plugin_id, report=lambda stage, percent=None: None): if action not in ('install', 'update', 'remove') or type(plugin_id) is not int or plugin_id <= 0: raise ValueError('Choose a valid store plugin action') + report('Checking installed plugins', 5) state = frame_decky.status(ssh) if not state['managed'] or not state['running']: raise ValueError('Start Frame Control managed Decky before changing plugins') @@ -175,6 +176,7 @@ def change(ssh, action, plugin_id): folder = installed[0]['folder'] plugin = {'sha256': '0' * 64, 'version': 'removed'} else: + report('Reading official store', 15) plugin = next((item for item in catalog() if item['id'] == plugin_id), None) if not plugin: raise ValueError('Plugin is not in the official Decky store') @@ -187,9 +189,11 @@ def change(ssh, action, plugin_id): raise ValueError('Only an installed Frame Control store plugin can be updated') data = None if action != 'remove': + report('Downloading plugin archive', 30) url = _urls()[1] + plugin['sha256'] + '.zip' with urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': 'Frame-Control'}), timeout=35) as response: data = response.read(MAX_ZIP + 1) + report('Verifying plugin archive', 50) folder = archive_info(data, plugin['sha256'], plugin['name']) if action == 'update' and installed[0]['folder'] != folder: raise ValueError('The store changed its plugin folder; remove the old version before installing') @@ -199,7 +203,9 @@ def change(ssh, action, plugin_id): remote = '/tmp/frame-control-decky-plugin-' + token try: if data is not None: + report('Sending archive to Frame', 65) ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120) + report({'install':'Installing', 'update':'Updating', 'remove':'Removing'}[action] + ' plugin on Frame', 80) return json.loads(ssh(f'python3 - {action} {token} {plugin_id} {plugin["sha256"]} {folder} {plugin["version"]}', stdin=APPLY, timeout=90)) finally: diff --git a/ui/headset.html b/ui/headset.html index dd26091..880fc8d 100644 --- a/ui/headset.html +++ b/ui/headset.html @@ -46,6 +46,9 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
Manage official store plugins here. Decky's Quick Access Menu is absent from this VR shell. Framey can show compatible plugin panels and settings below; Steam library and store page patches are unavailable.
+