mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 03:01:59 +01:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31edf6b737 | ||
|
|
005ad9b71d | ||
|
|
aaf2668882 | ||
|
|
cfb0310e9b | ||
|
|
493ab35635 | ||
|
|
6ee7bacc47 | ||
|
|
027bbfa701 | ||
|
|
34cd35056b |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.35",
|
||||
"version": "0.4.36",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.35",
|
||||
"version": "0.4.36",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.35",
|
||||
"version": "0.4.36",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.35**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
Current for Frame Control **v0.4.36**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
@@ -23,7 +23,7 @@ Current for Frame Control **v0.4.35**, 25 October 2026. “Real Frame + physical
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified. The new large read-only status screen has browser layout and packaging checks only | 25 Oct, v0.4.29 device check and v0.4.31 status screen: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [PR #220](https://github.com/saphid/frame-control/pull/220), [panel guide](headset-app.md) |
|
||||
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; seven official-store plugin backends loaded, and ProtonDB Badges completed managed store install/update/remove | No | v0.4.33 shared transaction lock and stopped-loader plugin removal have Linux and CI tests, but no live recovery test on the Frame. v0.4.35 treats `_root` store tags as root-required and hides duplicate IDs; this has test coverage, not a live store check. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.35 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [PR #246](https://github.com/saphid/frame-control/pull/246), [compatibility table](decky-frame.md) |
|
||||
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Official Steam Status rendered in local Chromium; a session-only cache avoided its earlier storage errors. A later local browser check restored a bounded per-plugin cache after panel reopen; live Decky and offline behavior were not tested. Fake Frame tests cover setting read/write through update and reinstall and install/remove two pinned official API v2 archives plus the modern API v1 Storage Cleaner archive. Page tests cover the official JSX runtime, disabled controls, text fields, sliders, grouped dropdowns and an author sample bundle with a settings tab and read-only backend quick action. The sample is not published in the store. The 11-archive manifest audit found only ProtonDB Badges API v2 in that set. Storage Cleaner 1.4.0 showed an empty-data panel and four disabled cleanup buttons in local Chromium with a stub backend; no cleanup action was tested. A hash match, local panel and fake Frame install do not prove VR use. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.35: [#223](https://github.com/saphid/frame-control/pull/223), [#224](https://github.com/saphid/frame-control/pull/224), [#225](https://github.com/saphid/frame-control/pull/225), [#226](https://github.com/saphid/frame-control/pull/226), [#227](https://github.com/saphid/frame-control/pull/227), [#229](https://github.com/saphid/frame-control/pull/229), [#231](https://github.com/saphid/frame-control/pull/231), [#232](https://github.com/saphid/frame-control/pull/232), [#233](https://github.com/saphid/frame-control/pull/233), [#236](https://github.com/saphid/frame-control/pull/236), [#237](https://github.com/saphid/frame-control/pull/237), [#238](https://github.com/saphid/frame-control/pull/238), [#239](https://github.com/saphid/frame-control/pull/239), [#240](https://github.com/saphid/frame-control/pull/240), [#241](https://github.com/saphid/frame-control/pull/241), [#243](https://github.com/saphid/frame-control/pull/243), [#244](https://github.com/saphid/frame-control/pull/244), [#245](https://github.com/saphid/frame-control/pull/245), [#247](https://github.com/saphid/frame-control/pull/247), [#248](https://github.com/saphid/frame-control/pull/248), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.md) |
|
||||
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; seven official-store plugin backends loaded, and ProtonDB Badges completed managed store install/update/remove | No | v0.4.33 shared transaction lock and stopped-loader plugin removal have Linux and CI tests, but no live recovery test on the Frame. v0.4.35 treats `_root` store tags as root-required and hides duplicate IDs; this has test coverage, not a live store check. v0.4.36 stops a store install or update when the catalog hash changed after review; this was tested with a fake Frame, not with a live store change on the headset. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.36 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [PR #246](https://github.com/saphid/frame-control/pull/246), [PR #253](https://github.com/saphid/frame-control/pull/253), [compatibility table](decky-frame.md) |
|
||||
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Official Steam Status rendered in local Chromium; a session-only cache avoided its earlier storage errors. A later local browser check restored a bounded per-plugin cache after panel reopen; live Decky and offline behavior were not tested. Fake Frame tests cover setting read/write through update and reinstall and install/remove two pinned official API v2 archives plus the modern API v1 Storage Cleaner archive. Page tests cover the official JSX runtime, disabled controls, text fields, sliders, grouped dropdowns and an author sample bundle with a settings tab and read-only backend quick action. The sample is not published in the store. The 11-archive manifest audit found only ProtonDB Badges API v2 in that set. Storage Cleaner 1.4.0 showed an empty-data panel and four disabled cleanup buttons in local Chromium with a stub backend; no cleanup action was tested. HLTB for Deck 2.0.12 built a shallow settings view and Bluetooth 3.1.1 built its initial panel in local frontend tests without effects or backend calls. Their badges, search, pairing and Bluetooth changes remain unverified. A hash match, local panel and fake Frame install do not prove VR use. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.36: [#223](https://github.com/saphid/frame-control/pull/223), [#224](https://github.com/saphid/frame-control/pull/224), [#225](https://github.com/saphid/frame-control/pull/225), [#226](https://github.com/saphid/frame-control/pull/226), [#227](https://github.com/saphid/frame-control/pull/227), [#229](https://github.com/saphid/frame-control/pull/229), [#231](https://github.com/saphid/frame-control/pull/231), [#232](https://github.com/saphid/frame-control/pull/232), [#233](https://github.com/saphid/frame-control/pull/233), [#236](https://github.com/saphid/frame-control/pull/236), [#237](https://github.com/saphid/frame-control/pull/237), [#238](https://github.com/saphid/frame-control/pull/238), [#239](https://github.com/saphid/frame-control/pull/239), [#240](https://github.com/saphid/frame-control/pull/240), [#241](https://github.com/saphid/frame-control/pull/241), [#243](https://github.com/saphid/frame-control/pull/243), [#244](https://github.com/saphid/frame-control/pull/244), [#245](https://github.com/saphid/frame-control/pull/245), [#247](https://github.com/saphid/frame-control/pull/247), [#248](https://github.com/saphid/frame-control/pull/248), [#251](https://github.com/saphid/frame-control/pull/251), [#252](https://github.com/saphid/frame-control/pull/252), [#253](https://github.com/saphid/frame-control/pull/253), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.md) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
@@ -103,7 +103,10 @@ 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. If Frame Control restarts, it cannot recover the old
|
||||
operation after an error. The headset panel and desktop app send the hash of
|
||||
the version you saw; if the official store changes that version before the job
|
||||
starts, Frame Control stops before downloading or changing the headset. Refresh
|
||||
the store and review the new version before retrying. If Frame Control restarts, it cannot recover the old
|
||||
job result. The panel clears that job ID and asks you to refresh the installed
|
||||
list before making another change.
|
||||
|
||||
|
||||
+4
-2
@@ -22,8 +22,10 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
the affected upload; it refuses ambiguous matches.
|
||||
The Linux build runs `scripts/check-appimage-launch.py` before upload. It
|
||||
starts the built x86_64 AppImage on an isolated Xvfb display, checks that
|
||||
its bundled server answers, and checks that the server stops on exit. This
|
||||
CI test does not test a physical display, FUSE mount or a headset.
|
||||
its bundled server answers, and checks that the server stops on exit. The
|
||||
check disables telemetry and update requests, and keeps extraction in its
|
||||
temporary profile. This CI test does not test a physical display, FUSE mount
|
||||
or a headset.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
|
||||
@@ -45,13 +45,17 @@ def check(appimage):
|
||||
digest = hashlib.sha256(appimage.read_bytes()).hexdigest()
|
||||
with tempfile.TemporaryDirectory(prefix="frame-appimage-smoke-") as directory:
|
||||
profile = Path(directory)
|
||||
(profile / "tmp").mkdir()
|
||||
env = dict(os.environ)
|
||||
env.update(
|
||||
APPIMAGE_EXTRACT_AND_RUN="1", ELECTRON_DISABLE_SANDBOX="1",
|
||||
TMPDIR=str(profile / "tmp"),
|
||||
XDG_CONFIG_HOME=str(profile / "config"),
|
||||
XDG_CACHE_HOME=str(profile / "cache"),
|
||||
XDG_DATA_HOME=str(profile / "data"),
|
||||
FRAME_ALIAS="frame-control-appimage-smoke-no-headset",
|
||||
FRAME_CONTROL_TELEMETRY="0", FRAME_CONTROL_NO_UPDATE_CHECK="1",
|
||||
DO_NOT_TRACK="1",
|
||||
)
|
||||
output = profile / "app.log"
|
||||
port = None
|
||||
|
||||
@@ -142,6 +142,11 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
stale = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'update','id':999,'sha256':HASHES[0],
|
||||
'acknowledged':True}))
|
||||
self.assertIn('version changed', stale.get('error', ''))
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
|
||||
|
||||
def test_pinned_official_api_v1_and_v2_plugins_install_and_remove(self):
|
||||
self.backend()
|
||||
|
||||
@@ -48,14 +48,14 @@ 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'};
|
||||
ctx.mockApi=async (path,body) => {
|
||||
if(path==='/api/decky/plugin'){assert.equal(body.sha256,'a'.repeat(64));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);
|
||||
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
|
||||
assert.equal(el('deckyJobStatus').textContent,'Installed Plugin 2');
|
||||
assert.equal(el('deckyJobProgress').value,100);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
@@ -70,14 +70,14 @@ 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'};
|
||||
ctx.mockApi=async (path,body) => {
|
||||
if(path==='/api/decky/plugin'){assert.equal(body.sha256,'b'.repeat(64));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);
|
||||
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2",sha256:"b".repeat(64)})',ctx);
|
||||
assert.match(el('deckyJobStatus').textContent,/Error: archive digest mismatch/);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
|
||||
|
||||
@@ -183,6 +183,18 @@ class StoreTransaction(unittest.TestCase):
|
||||
|
||||
|
||||
class StoreHost(unittest.TestCase):
|
||||
def test_reviewed_archive_changed_before_install_does_not_download(self):
|
||||
state = {'managed': True, 'running': True, 'plugins': []}
|
||||
catalog = [{'id': 42, 'name': 'Example', 'root': False,
|
||||
'sha256': 'b' * 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), \
|
||||
patch.object(frame_decky_store.urllib.request, 'urlopen',
|
||||
side_effect=AssertionError('No download expected')):
|
||||
with self.assertRaisesRegex(ValueError, 'version changed'):
|
||||
frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'),
|
||||
'install', 42, reviewed_hash='a' * 64)
|
||||
|
||||
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'}]}
|
||||
|
||||
@@ -193,9 +193,12 @@ finally:
|
||||
'''
|
||||
|
||||
|
||||
def change(ssh, action, plugin_id, report=lambda stage, percent=None: None):
|
||||
def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, reviewed_hash=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')
|
||||
if reviewed_hash is not None and (action == 'remove' or not isinstance(reviewed_hash, str)
|
||||
or not SHA.fullmatch(reviewed_hash)):
|
||||
raise ValueError('Invalid reviewed store version')
|
||||
report('Checking installed plugins', 5)
|
||||
state = frame_decky.status(ssh)
|
||||
if not state['managed']:
|
||||
@@ -215,6 +218,8 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None):
|
||||
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')
|
||||
if reviewed_hash is not None and plugin['sha256'] != reviewed_hash:
|
||||
raise ValueError('The official store version changed; refresh the store and review it again')
|
||||
if plugin['root']:
|
||||
raise ValueError('Root plugins are not supported by the Frame user service')
|
||||
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']]
|
||||
|
||||
+3
-2
@@ -169,7 +169,8 @@ async function followDeckyJob(job,action,name){
|
||||
async function deckyChange(action,plugin){if(deckyBusy||!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
|
||||
deckyBusy=true;showDeckyStore();
|
||||
deckyJobView(`${action} ${plugin.name}`,'Starting…',0);
|
||||
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,acknowledged:true});
|
||||
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,
|
||||
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true});
|
||||
if(!started.job)throw Error('The server did not return a job ID');
|
||||
sessionStorage.setItem('frameyPluginJob',JSON.stringify({job:started.job,action,name:plugin.name}));
|
||||
await followDeckyJob(started.job,action,plugin.name);
|
||||
@@ -191,7 +192,7 @@ function openFrameyPanel(id,name){
|
||||
byId('frameyFrame').src=`/framey/panel/${id}#${frameySession}`;
|
||||
byId('frameyPanel').hidden=false;byId('frameyPanel').scrollIntoView();
|
||||
setTimeout(()=>{if(activeFrameyId===id&&byId('frameyPanelStatus').textContent==='Loading plugin panel…')
|
||||
byId('frameyPanelStatus').textContent='No compatible API v2 panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
|
||||
byId('frameyPanelStatus').textContent='No compatible panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
|
||||
}
|
||||
byId('frameyClose').onclick=()=>{activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
|
||||
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
|
||||
|
||||
+2
-1
@@ -2244,7 +2244,8 @@ function showDeckyStore() {
|
||||
}
|
||||
async function changeDeckyPlugin(action, plugin) {
|
||||
if (!$('deckyAck').checked || !confirm(`${action} ${plugin.name}? This official store plugin runs code as your Frame user.`)) return;
|
||||
try { await runJob(`${action} ${plugin.name}`, 'decky-plugin', () => api('/api/decky/plugin', {action,id:plugin.id,acknowledged:true}));
|
||||
try { await runJob(`${action} ${plugin.name}`, 'decky-plugin', () => api('/api/decky/plugin',
|
||||
{action,id:plugin.id,sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true}));
|
||||
await loadDecky(); showDeckyStore(); } catch(error) { $('deckyStoreState').textContent=error.message; }
|
||||
}
|
||||
function deckyButtons() {
|
||||
|
||||
+3
-1
@@ -571,8 +571,10 @@ def decky_plugin(body):
|
||||
raise Failure('Choose an official store plugin and action', 400)
|
||||
if body.get('acknowledged') is not True:
|
||||
raise Failure('Confirm that store plugins run code as your Frame user', 400)
|
||||
reviewed_hash = body.get('sha256')
|
||||
return start_job('Decky plugin ' + action,
|
||||
lambda report: frame_decky_store.change(ssh, action, plugin_id, report), progress=True)
|
||||
lambda report: frame_decky_store.change(ssh, action, plugin_id, report,
|
||||
reviewed_hash=reviewed_hash), progress=True)
|
||||
|
||||
|
||||
def framey_method_action(body):
|
||||
|
||||
Reference in new issue
Block a user