Compare commits

...
6 changed files with 63 additions and 8 deletions

No files matched your search

+3 -1
View File
@@ -65,7 +65,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
+10
View File
@@ -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'};
+36
View File
@@ -112,6 +112,18 @@ 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.assertEqual((Path(self.tmp.name) / 'service-calls').read_text().splitlines(),
['--user stop frame-control-decky.service'])
def test_shared_lock_rejects_plugin_and_loader_changes(self):
import fcntl
@@ -136,6 +148,30 @@ class StoreTransaction(unittest.TestCase):
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'}]}
+8 -3
View File
@@ -151,7 +151,8 @@ try:
unpacked=stage/folder
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
(unpacked/marker).write_text(json.dumps({'storeId':int(plugin_id),'sha256':digest,'version':version})+'\n')
if running: service('stop','frame-control-decky.service')
# Stop even a failed unit before removal to cancel any pending auto-restart.
if running or action=='remove': service('stop','frame-control-decky.service')
try:
if action!='install': target.rename(backup)
if action!='remove': (stage/folder).rename(target)
@@ -176,9 +177,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')
+3 -1
View File
@@ -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);
+3 -3
View File
@@ -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;