From b03cbb6fd7c577b565af684a4c88da8324e14a79 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sun, 25 Oct 2026 09:22:17 +0000 Subject: [PATCH] Allow safe Decky plugin removal when loader is stopped --- docs/framey-loader.md | 4 +++- tests/page/framey_store.mjs | 10 ++++++++++ tests/test_frame_decky_store.py | 35 +++++++++++++++++++++++++++++++++ ui/frame_decky_store.py | 8 ++++++-- ui/headset.html | 4 +++- ui/index.html | 6 +++--- 6 files changed, 60 insertions(+), 7 deletions(-) diff --git a/docs/framey-loader.md b/docs/framey-loader.md index c3b9c9e..ef60883 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -59,7 +59,9 @@ Search matches names, authors, and descriptions. Filters show non-root plugins or installed plugins, and Previous/Next buttons browse 20 results per page. 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. +before its Install or Update buttons are enabled. If a plugin prevents Decky +from starting, Remove remains available for Frame Control-owned plugins while +the managed service is stopped; it does not restart the service. 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 diff --git a/tests/page/framey_store.mjs b/tests/page/framey_store.mjs index 5db9aa9..0e5382e 100644 --- a/tests/page/framey_store.mjs +++ b/tests/page/framey_store.mjs @@ -60,6 +60,16 @@ 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); +// A stopped Decky service must still offer recovery by removing its owned plugin. +ctx.mockApi=async path => path==='/api/decky' ? + {managed:true,running:false,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]} : + Promise.reject(Error(`unexpected ${path}`)); +vm.runInContext('api=mockApi',ctx); +await vm.runInContext('refreshDecky()',ctx); +assert.equal(el('deckyInstalled').children[0].children[2].disabled,false); +el('deckySearch').value='';el('deckyStoreFilter').value='all'; +vm.runInContext('showDeckyStore()',ctx); +assert.equal(el('deckyStoreList').children[1].children[1].disabled,true); 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'}; diff --git a/tests/test_frame_decky_store.py b/tests/test_frame_decky_store.py index 553ab06..c110022 100644 --- a/tests/test_frame_decky_store.py +++ b/tests/test_frame_decky_store.py @@ -111,8 +111,43 @@ class StoreTransaction(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertTrue(external.exists()) + def test_remove_while_service_is_stopped_does_not_restart_it(self): + data, _ = archive() + self.assertEqual(self.apply('install', data).returncode, 0) + fake = Path(self.tmp.name) / 'systemctl' + fake.write_text('#!/bin/sh\nif [ "$2" = is-active ]; then exit 1; fi\n' + 'echo "$*" >> "$HOME/service-calls"\nexit 0\n') + result = self.apply('remove') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.folder.exists()) + self.assertFalse((Path(self.tmp.name) / 'service-calls').exists()) + class StoreHost(unittest.TestCase): + def test_stopped_managed_service_allows_removing_owned_plugin(self): + state = {'managed': True, 'running': False, 'plugins': + [{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]} + calls = [] + with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \ + patch.object(frame_decky_store, 'catalog') as network: + result = frame_decky_store.change(lambda command, **kwargs: calls.append(command) or '{}', + 'remove', 42) + self.assertEqual(result, {}) + self.assertEqual(len(calls), 1) + network.assert_not_called() + + def test_renamed_store_plugin_cannot_be_installed_twice(self): + state = {'managed': True, 'running': True, 'plugins': + [{'storeId': 42, 'folder': 'OldFolder', 'name': 'Old Name', 'version': '1.0'}]} + catalog = [{'id': 42, 'name': 'New Name', 'root': False, 'sha256': 'a' * 64, 'version': '2.0'}] + with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \ + patch.object(frame_decky_store, 'catalog', return_value=catalog) as network, \ + patch.object(frame_decky_store.urllib.request, 'urlopen', side_effect=AssertionError('No download expected')): + with self.assertRaisesRegex(ValueError, 'already installed'): + frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'), + 'install', 42) + network.assert_not_called() + def test_owned_plugin_can_be_removed_without_store_network(self): state = {'managed': True, 'running': True, 'plugins': [{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]} diff --git a/ui/frame_decky_store.py b/ui/frame_decky_store.py index 2886065..c8f6c76 100644 --- a/ui/frame_decky_store.py +++ b/ui/frame_decky_store.py @@ -167,9 +167,13 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None): 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') + if not state['managed']: + raise ValueError('Install Frame Control managed Decky before changing plugins') + if action != 'remove' and not state['running']: + raise ValueError('Start Frame Control managed Decky before installing or updating plugins') installed = [p for p in state['plugins'] if p.get('storeId') == plugin_id] + if action == 'install' and installed: + raise ValueError('This store plugin is already installed; use Update or Remove') if action == 'remove': if len(installed) != 1: raise ValueError('Only an installed Frame Control store plugin can be removed') diff --git a/ui/headset.html b/ui/headset.html index 880fc8d..d73c9ee 100644 --- a/ui/headset.html +++ b/ui/headset.html @@ -105,7 +105,9 @@ async function refreshGames(){const list=byId('games');try{const data=await api( }catch(error){list.textContent=error.message;}} let deckyState=null, deckyStore=[], deckyStoreIndex=0, activeFrameyId=null, deckyBusy=false; async function refreshDecky(){try{deckyState=await api('/api/decky'); - byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':'Decky is not running. Install it from Frame Control on your computer.'; + byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.': + deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.': + 'Decky is not running. Install it from Frame Control on your computer.'; const list=byId('deckyInstalled');list.replaceChildren(); for(const plugin of deckyState.plugins||[]){const row=document.createElement('div');row.className='block'; const name=document.createElement('strong');name.textContent=`${plugin.name} ${plugin.version||''}`;row.append(name); diff --git a/ui/index.html b/ui/index.html index da18d1e..1f8975d 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2202,7 +2202,7 @@ async function loadDecky() { const row=document.createElement('div');row.className='hint'; const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version||''}`; const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove plugin'; - remove.disabled=!d.running||!$('deckyAck').checked; + remove.disabled=!d.managed||!$('deckyAck').checked; remove.onclick=()=>changeDeckyPlugin('remove',{id:plugin.storeId,name:plugin.name}); row.append(label,remove);managedList.append(row);} deckyButtons(); @@ -2236,7 +2236,7 @@ function showDeckyStore() { button.onclick=()=>changeDeckyPlugin(entry?'update':'install',plugin); row.append(button); if(entry){const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove'; - remove.disabled=!deckyInfo?.managed || !deckyInfo?.running || !$('deckyAck').checked; + remove.disabled=!deckyInfo?.managed || !$('deckyAck').checked; remove.onclick=()=>changeDeckyPlugin('remove',plugin);row.append(remove);} } list.append(row); @@ -2255,7 +2255,7 @@ function deckyButtons() { $('deckyRemove').disabled = !ack || !deckyInfo?.managed; } $('deckyAck').onchange = () => { deckyButtons(); showDeckyStore(); - for(const button of $('deckyManagedList').querySelectorAll('button'))button.disabled=!$('deckyAck').checked||!deckyInfo?.running; }; + for(const button of $('deckyManagedList').querySelectorAll('button'))button.disabled=!$('deckyAck').checked||!deckyInfo?.managed; }; $('deckyRefresh').onclick = loadDecky; $('deckyStoreRefresh').onclick = loadDeckyStore; $('deckyStoreSearch').oninput = showDeckyStore;