mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 16:01:57 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dddfc6bea8 |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.44",
|
||||
"version": "0.4.43",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.44",
|
||||
"version": "0.4.43",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.44",
|
||||
"version": "0.4.43",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
# v0.4.44 candidate gate, 26 October 2026
|
||||
|
||||
This candidate starts from main commit
|
||||
`c50f96510fdb42a2aee227436a04c0cfe8817919`, which contains PR #312's
|
||||
Framey scroll buttons and PR #313's read-only installed-app metadata projection.
|
||||
It also carries the documentation-only v0.4.43 release evidence from PR #314.
|
||||
The app and lockfile are bumped to 0.4.44. No compatibility label changes:
|
||||
the exact-version official Decky catalog remains **4 works / 49 partial /
|
||||
52 broken / 11 N/A**. Junk-Store's About and Dependencies pages now render in
|
||||
the local Chromium test with its actual ARM64 backend; the plugin remains partial.
|
||||
|
||||
## Real Frame checks on the combined code
|
||||
|
||||
At 08:37 UTC, `scripts/frame-smoke.sh` ran on that main commit under its own
|
||||
`/tmp/frame-test.lock` owner. Locked preflight found SteamVR activity 3
|
||||
(standby), proximity 6.25 and battery 99%. On Frame OS 0.5.6 build
|
||||
`20261022.6153909`, ARM64 and Proton executables launched; the optional x86_64
|
||||
launch was skipped because Steam Linux Runtime 4 is absent. The result was
|
||||
**12 passed / 1 skipped / 0 failed**. It removed its test titles, exited 0 and
|
||||
released its owned lock. The private result is
|
||||
`tests/smoke/results/2026-10-26T083719Z-20261022.6153909.json` on the test
|
||||
host.
|
||||
|
||||
At 08:45 UTC, `scripts/framey-vr-check.py --exercise` ran the same code under
|
||||
a separate unique shared-lock owner, beginning in standby (activity 3,
|
||||
proximity 6.3125, battery 99%). Synthetic native OpenVR clicks on the new
|
||||
**Scroll plugin down/up** buttons moved a temporary sample region 0 → 120 → 0.
|
||||
The sample setting save/reopen, quick action, slider, text and compositor-change
|
||||
checks passed. Native synthetic wheel attempts still produced no event or
|
||||
movement. Cleanup removed test services and its power watchdog, restored the
|
||||
original `false` / `5` power values, read battery 99%, and released its lock
|
||||
with no errors. The private report is
|
||||
`/tmp/framey-scroll-postmerge-direct-20261026/result.json` on the test host.
|
||||
This is an unworn sample panel check. It does not prove physical controller
|
||||
scrolling, optical readability or an official-plugin panel in VR.
|
||||
|
||||
## Publication boundary
|
||||
|
||||
The version-bumped release branch also ran `scripts/frame-smoke.sh` after its
|
||||
normal merge of PR #314's documentation. At 08:59 UTC, its locked preflight
|
||||
found activity 3, proximity 6.3125 and battery 97%. The result was again
|
||||
**12 passed / 1 skipped / 0 failed** on the same Frame OS build; ARM64 and
|
||||
Proton launches passed, while the optional x86 launch was skipped for the
|
||||
missing Steam Linux Runtime 4. Owned test title/process cleanup passed, the
|
||||
command exited 0, and its lock was released. The private result is
|
||||
`tests/smoke/results/2026-10-26T085951Z-20261022.6153909.json` on the test
|
||||
host. This evidence-only paragraph is the sole change after that smoke.
|
||||
|
||||
PR #313's own actual ARM64 backend/library test is documented in
|
||||
[installed-context evidence](framey-installed-context-2026-10-26.md). Its
|
||||
dependency installation, login, game operations, RSS and optional websocket
|
||||
remain unverified. The candidate still needs exact-head CI, tagged builds,
|
||||
ten installer digests with `SHA256SUMS`, downloaded package checks, publisher
|
||||
dry run and public updater readback before publication. A worn check requires
|
||||
activity, proximity and reliable wearer confirmation; none was available, so
|
||||
the hourly schedule stays enabled.
|
||||
@@ -217,6 +217,10 @@ frontend cannot call the changing backend. Open panel is unavailable while a
|
||||
store job is pending. A later installed-status refresh also closes an open panel
|
||||
if Decky stopped, its store receipt changed, or the status read failed. Reopen
|
||||
the plugin after a successful refresh to use its current frontend.
|
||||
Chunked frontends load modules through URLs bound to the installed store
|
||||
receipt. If a desktop store update replaces the plugin during panel startup,
|
||||
old module requests fail and the panel must be reopened; Framey does not mix
|
||||
chunks from two installed versions.
|
||||
The headset also includes the panel's store SHA-256 receipt with each backend
|
||||
call, Steam read, and event poll. It rejects a request if the installed receipt
|
||||
changed before the next status refresh; reopen the panel to use the new version.
|
||||
|
||||
@@ -33,7 +33,8 @@ class OfficialBundles(unittest.TestCase):
|
||||
self.assertIn(b'deckyFrontendLib.definePlugin', frontend)
|
||||
bundle.extractall(root)
|
||||
folder = Path(root) / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({
|
||||
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
|
||||
with patch.object(framey_panel, 'PLUGINS', Path(root)):
|
||||
page = framey_panel.panel(plugin['id']).decode()
|
||||
encoded = re.search(r'<script id="framey-bundle" type="application/json">([^<]+)</script>', page)
|
||||
@@ -71,16 +72,17 @@ class OfficialBundles(unittest.TestCase):
|
||||
self.assertEqual(manifest.get('api_version', 'legacy'), plugin['api_version'])
|
||||
z.extractall(plugins)
|
||||
folder = plugins / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({
|
||||
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
|
||||
with patch.object(framey_panel, 'PLUGINS', plugins):
|
||||
page = framey_panel.panel(plugin['id']).decode()
|
||||
encoded = re.search(r'<script id="framey-bundle" type="application/json"[^>]*>([^<]+)</script>', page)
|
||||
self.assertIsNotNone(encoded)
|
||||
self.assertEqual(base64.b64decode(encoded.group(1)), (folder / 'dist/index.js').read_bytes())
|
||||
if plugin['id'] == 135:
|
||||
self.assertIn('data-module="/framey/module/135/index.js"', page)
|
||||
self.assertIn(f'data-module="/framey/module/135/{plugin["sha256"]}/index.js"', page)
|
||||
with patch.object(framey_panel, 'PLUGINS', plugins):
|
||||
self.assertEqual(framey_panel.local_module(135, 'chunks/pluginApi-B8sV7970.js'),
|
||||
self.assertEqual(framey_panel.local_module(135, plugin['sha256'], 'chunks/pluginApi-B8sV7970.js'),
|
||||
(folder/'dist/chunks/pluginApi-B8sV7970.js').read_bytes())
|
||||
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.plugins = Path(tmp.name)/'plugins'
|
||||
self.folder = self.plugins/'protondb-decky'
|
||||
(self.folder/'dist').mkdir(parents=True)
|
||||
(self.folder/'.frame-control-store.json').write_text(json.dumps({'storeId':14,'version':'1.3.4'}))
|
||||
(self.folder/'.frame-control-store.json').write_text(json.dumps({'storeId':14,'version':'1.3.4','sha256':RECEIPT}))
|
||||
(self.folder/'plugin.json').write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':[]}))
|
||||
(self.folder/'dist/index.js').write_text("export default () => ({content: SP_REACT.createElement('p', null, 'OK')});")
|
||||
home = patch.object(framey_panel, 'PLUGINS', self.plugins)
|
||||
@@ -260,18 +260,36 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
chunks.mkdir()
|
||||
(chunks/'view.js').write_text('export const title = "Settings";')
|
||||
page = framey_panel.panel(14).decode()
|
||||
self.assertIn('data-module="/framey/module/14/index.js"', page)
|
||||
self.assertEqual(framey_panel.local_module(14, 'chunks/view.js'),
|
||||
self.assertIn(f'data-module="/framey/module/14/{RECEIPT}/index.js"', page)
|
||||
self.assertEqual(framey_panel.local_module(14, RECEIPT, 'chunks/view.js'),
|
||||
b'export const title = "Settings";')
|
||||
for name in ('../plugin.json', 'chunks/../../plugin.json',
|
||||
'chunks/view.js.map', 'other.js', 'chunks/%2e%2e/view.js'):
|
||||
with self.subTest(name=name), self.assertRaises(FileNotFoundError):
|
||||
framey_panel.local_module(14, name)
|
||||
framey_panel.local_module(14, RECEIPT, name)
|
||||
(chunks/'linked.js').symlink_to(chunks/'view.js')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.local_module(14, 'chunks/linked.js')
|
||||
framey_panel.local_module(14, RECEIPT, 'chunks/linked.js')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.local_module(15, 'chunks/view.js')
|
||||
framey_panel.local_module(15, RECEIPT, 'chunks/view.js')
|
||||
|
||||
def test_chunked_panel_rejects_modules_from_a_replaced_store_receipt(self):
|
||||
(self.folder/'dist/index.js').write_text("import './chunks/view.js'; export default () => ({});")
|
||||
chunks = self.folder/'dist/chunks'
|
||||
chunks.mkdir()
|
||||
(chunks/'view.js').write_text('export const version = 1;')
|
||||
old_url = f'/framey/module/14/{RECEIPT}/chunks/view.js'
|
||||
self.assertIn(f'/framey/module/14/{RECEIPT}/index.js', framey_panel.panel(14).decode())
|
||||
self.assertEqual(framey_panel.local_module(14, RECEIPT, 'chunks/view.js'), b'export const version = 1;')
|
||||
(self.folder/'.frame-control-store.json').write_text(json.dumps({
|
||||
'storeId':14,'version':'2.0','sha256':'b'*64}))
|
||||
(chunks/'view.js').write_text('export const version = 2;')
|
||||
with self.assertRaisesRegex(FileNotFoundError, 'changed'):
|
||||
framey_panel.local_module(14, RECEIPT, 'chunks/view.js')
|
||||
self.assertEqual(framey_panel.local_module(14, 'b'*64, 'chunks/view.js'), b'export const version = 2;')
|
||||
self.assertNotIn(old_url, framey_panel.panel(14).decode())
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.local_module(14, 'invalid', 'chunks/view.js')
|
||||
|
||||
def test_legacy_keyword_rpc_is_scoped_and_validated(self):
|
||||
import shlex
|
||||
@@ -365,12 +383,16 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
|
||||
(self.folder/'dist/chunks').mkdir()
|
||||
(self.folder/'dist/chunks/view.js').write_text('export const value = 1;')
|
||||
status, headers, body = get('/framey/module/14/chunks/view.js')
|
||||
status, headers, body = get(f'/framey/module/14/{RECEIPT}/chunks/view.js')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(headers['Access-Control-Allow-Origin'], 'null')
|
||||
self.assertEqual(body, b'export const value = 1;')
|
||||
self.assertEqual(get('/framey/module/14/chunks/../plugin.json')[0], 404)
|
||||
self.assertEqual(get('/framey/module/15/chunks/view.js')[0], 404)
|
||||
self.assertEqual(get(f'/framey/module/14/{RECEIPT}/chunks/../plugin.json')[0], 404)
|
||||
self.assertEqual(get(f'/framey/module/15/{RECEIPT}/chunks/view.js')[0], 404)
|
||||
(self.folder/'.frame-control-store.json').write_text(json.dumps({
|
||||
'storeId':14,'version':'2.0','sha256':'b'*64}))
|
||||
self.assertEqual(get(f'/framey/module/14/{RECEIPT}/chunks/view.js')[0], 404)
|
||||
self.assertEqual(get(f'/framey/module/14/{"b"*64}/chunks/view.js')[0], 200)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+35
-15
@@ -20,7 +20,17 @@ class PanelError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _owned_dist(plugin_id):
|
||||
def _receipt_digest(path):
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > 16_384:
|
||||
raise FileNotFoundError('Framey plugin store receipt unavailable')
|
||||
meta = json.loads(path.read_text())
|
||||
digest = meta.get('sha256') if isinstance(meta, dict) else None
|
||||
if not isinstance(digest, str) or not RECEIPT.fullmatch(digest):
|
||||
raise FileNotFoundError('Framey plugin store receipt unavailable')
|
||||
return meta, digest
|
||||
|
||||
|
||||
def _owned_dist(plugin_id, expected_receipt=None):
|
||||
"""Locate a compatible store-owned frontend without following linked paths."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0 or not PLUGINS.is_dir() or PLUGINS.is_symlink():
|
||||
raise FileNotFoundError('Framey plugin unavailable')
|
||||
@@ -31,16 +41,17 @@ def _owned_dist(plugin_id):
|
||||
manifest = folder/'plugin.json'
|
||||
dist = folder/'dist'
|
||||
entry = dist/'index.js'
|
||||
if any(p.is_symlink() for p in (receipt, manifest, dist, entry)):
|
||||
if any(p.is_symlink() for p in (manifest, dist, entry)):
|
||||
continue
|
||||
try:
|
||||
if not receipt.is_file() or receipt.stat().st_size > 16_384:
|
||||
continue
|
||||
meta = json.loads(receipt.read_text())
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
if meta.get('storeId') != plugin_id:
|
||||
continue
|
||||
meta, digest = _receipt_digest(receipt)
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
continue
|
||||
if meta.get('storeId') != plugin_id:
|
||||
continue
|
||||
try:
|
||||
if expected_receipt is not None and digest != expected_receipt:
|
||||
raise FileNotFoundError('Framey plugin changed; reopen its panel')
|
||||
if not manifest.is_file() or manifest.stat().st_size > 16_384:
|
||||
raise FileNotFoundError('Framey plugin manifest unavailable')
|
||||
data = json.loads(manifest.read_text())
|
||||
@@ -76,7 +87,11 @@ def _owned_dist(plugin_id):
|
||||
supported = True
|
||||
if not supported:
|
||||
raise FileNotFoundError('This API v1 frontend does not use the supported Decky API bridge')
|
||||
return dist, bundle
|
||||
if _receipt_digest(receipt) != (meta, digest):
|
||||
raise FileNotFoundError('Framey plugin changed; reopen its panel')
|
||||
return dist, bundle, digest
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except (OSError, ValueError):
|
||||
raise FileNotFoundError('Framey plugin files are invalid')
|
||||
raise FileNotFoundError('Plugin is not managed by the Frame Control Decky store')
|
||||
@@ -87,11 +102,13 @@ def local_bundle(plugin_id):
|
||||
return _owned_dist(plugin_id)[1]
|
||||
|
||||
|
||||
def local_module(plugin_id, name):
|
||||
def local_module(plugin_id, receipt, name):
|
||||
"""Serve only JavaScript within a store-owned dist directory for module imports."""
|
||||
if not isinstance(name, str) or not MODULE_PATH.fullmatch(name):
|
||||
raise FileNotFoundError('Framey module path unavailable')
|
||||
dist, bundle = _owned_dist(plugin_id)
|
||||
if not isinstance(receipt, str) or not RECEIPT.fullmatch(receipt):
|
||||
raise FileNotFoundError('Framey module receipt unavailable')
|
||||
dist, bundle, _ = _owned_dist(plugin_id, receipt)
|
||||
if name == 'index.js':
|
||||
return bundle
|
||||
target = dist
|
||||
@@ -101,14 +118,17 @@ def local_module(plugin_id, name):
|
||||
raise FileNotFoundError('Framey module path unavailable')
|
||||
if not target.is_file() or target.stat().st_size > 8*1024*1024:
|
||||
raise FileNotFoundError('Framey module unavailable')
|
||||
return target.read_bytes()
|
||||
data = target.read_bytes()
|
||||
if _receipt_digest(dist.parent/'.frame-control-store.json')[1] != receipt:
|
||||
raise FileNotFoundError('Framey plugin changed; reopen its panel')
|
||||
return data
|
||||
|
||||
|
||||
def panel(plugin_id):
|
||||
"""The plugin receives no Frame UI key and is sandboxed to an opaque origin."""
|
||||
bundle = local_bundle(plugin_id)
|
||||
_, bundle, receipt = _owned_dist(plugin_id)
|
||||
encoded = base64.b64encode(bundle).decode('ascii')
|
||||
module = f' data-module="/framey/module/{plugin_id}/index.js"' if b'./chunks/' in bundle else ''
|
||||
module = f' data-module="/framey/module/{plugin_id}/{receipt}/index.js"' if b'./chunks/' in bundle else ''
|
||||
return ('''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Framey plugin</title><style>body{font:21px/1.4 system-ui;background:#0d1925;color:#f3f7fa;margin:0;padding:20px}
|
||||
button,select,input{font:inherit;min-height:52px;background:#23618c;color:#fff;border:1px solid #72baf0;border-radius:9px;padding:8px}
|
||||
|
||||
+2
-2
@@ -3705,8 +3705,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({'error':'Framey panel unavailable'}, 404)
|
||||
elif path.startswith('/framey/module/'):
|
||||
try:
|
||||
plugin_id, name = path.removeprefix('/framey/module/').split('/', 1)
|
||||
module = framey_panel.local_module(int(plugin_id), name)
|
||||
plugin_id, receipt, name = path.removeprefix('/framey/module/').split('/', 2)
|
||||
module = framey_panel.local_module(int(plugin_id), receipt, name)
|
||||
# The plugin iframe has an opaque origin; only its module
|
||||
# imports need cross-origin access to these public JS files.
|
||||
self.send_bytes(module, 'text/javascript; charset=utf-8', framey=True,
|
||||
|
||||
Reference in new issue
Block a user