Compare commits

...
15 Commits
Author SHA1 Message Date
SAPHID 31edf6b737 Prepare Frame Control v0.4.36 2026-10-25 22:49:34 +00:00
Alex Southwell 005ad9b71d Merge pull request #253 from saphid/fix/framey-store-version-race
Reject store versions changed after review
2026-10-25 22:48:38 +00:00
Alex Southwell aaf2668882 Merge pull request #252 from saphid/test/framey-bluetooth-store
Provide Steam token fallbacks for Bluetooth panel
2026-10-25 22:39:14 +00:00
Alex Southwell cfb0310e9b Merge pull request #254 from saphid/fix/appimage-smoke-isolation
Isolate AppImage release smoke state
2026-10-25 22:32:18 +00:00
SAPHID 493ab35635 Keep stale version check within existing store flow 2026-10-25 22:29:45 +00:00
SAPHID 313d1ec9c8 Merge shipped HLTB support into Bluetooth compatibility 2026-10-25 22:25:48 +00:00
SAPHID 6ee7bacc47 Merge remote-tracking branch 'origin/main' into fix/appimage-smoke-isolation 2026-10-25 22:23:50 +00:00
SAPHID 027bbfa701 Isolate AppImage smoke network and extraction state 2026-10-25 22:23:49 +00:00
SAPHID 0f8d69a263 Use readable Bluetooth label fallbacks 2026-10-25 22:22:44 +00:00
Alex Southwell 2a9d4f886e Merge pull request #251 from saphid/feat/framey-hltb-compat
Extend Framey shim for HLTB settings panel
2026-10-25 22:22:27 +00:00
SAPHID 34cd35056b Reject store versions changed after review 2026-10-25 22:20:44 +00:00
Alex Southwell 734cd4f36c Merge pull request #250 from saphid/test/appimage-launch-ci
Smoke test the Linux AppImage in release CI
2026-10-25 22:20:18 +00:00
SAPHID 44ca81f527 Provide Steam token fallbacks for Bluetooth panel 2026-10-25 22:17:12 +00:00
SAPHID 3e5349ec39 Extend Framey shim for HLTB settings panel 2026-10-25 22:14:11 +00:00
SAPHID 3c184f5741 Smoke test packaged Linux AppImage in release CI 2026-10-25 22:10:57 +00:00
27 changed files with 352 additions and 34 deletions

No files matched your search

+4 -1
View File
@@ -10,7 +10,7 @@ on:
push:
tags: ["v*"]
pull_request:
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", "scripts/check-appimage-launch.py", ".github/workflows/release.yml"]
workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
@@ -110,6 +110,9 @@ jobs:
run: npm ci && npm run ${{ matrix.script }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Launch the built Linux AppImage
if: matrix.os == 'ubuntu-latest'
run: python3 scripts/check-appimage-launch.py app/dist/Frame-Control-linux-x86_64.AppImage
# The installer, not just the unpacked build: its 7-Zip payload once dropped
# the OpenXR layer's arm64 library without any error. Install it silently
# and compare every installed file with the unpacked build.
+2 -2
View File
@@ -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
View File
@@ -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",
+3 -3
View File
@@ -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.
+8 -7
View File
@@ -44,22 +44,23 @@ For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-aut
### Which of these plugins can open a Framey panel?
The separate Framey panel accepts a Frame Control store install only when its
`plugin.json` declares `api_version: 2` and it has `dist/index.js`. A backend
load does not bypass this frontend check. We downloaded the 11 versions above
The separate Framey panel accepts a Frame Control store install when its
`plugin.json` declares API v1 or v2 and it has a modern Decky frontend bundle
in `dist/index.js`. A backend load does not bypass this frontend check. We downloaded the 11 versions above
again on 25 October 2026 and matched every ZIP's SHA-256 to the official store
catalogue. All 11 had a frontend bundle, but their manifests differed:
| Manifest in the checked archive | Plugins | Framey panel result |
| --- | --- | --- |
| API v2 | ProtonDB Badges 1.3.4 | Eligible; settings rendered in local Chromium. No worn VR check. |
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Framey refuses these frontends. Wine Cellar and MagicPods also fail the ARM64 archive check. |
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Storage Cleaner rendered its empty-data panel in local Chromium. HLTB started and built a settings view in a local host test; its Steam game page patches have no target. Other panels remain untested. Wine Cellar and MagicPods fail the ARM64 archive check. None has a worn VR result. |
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | Framey refuses these frontends. Audio Loader and PowerTools also request root. |
The [archive audit record](evidence/framey-official-archive-audit-2026-10-25.json)
has each store ID, exact version, SHA-256, manifest field, and bundle check.
This is a static PC inspection. It does not show that any plugin is visible or
usable in the headset. The earlier real Frame backend checks in the table above
The archive audit itself is static PC inspection. Later local host and fake
Frame tests cover the Storage Cleaner and HLTB cases noted above; none shows
that a plugin is visible or usable in the headset. The earlier real Frame backend checks in the table above
remain the only device evidence for these versions. A new store version needs a
new check because its manifest and binaries can change.
@@ -83,7 +84,7 @@ API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
with settings, local routes, and panel toasts where supported.
Steam route patches and system notifications remain unavailable. The panel
still needs a worn-headset visual and controller check. See the
[current compatibility table](frame-plugin-author.md#framey-api-v2-compatibility).
[current compatibility table](frame-plugin-author.md#framey-decky-api-compatibility).
## What was checked on the real headset
@@ -0,0 +1,16 @@
# Bluetooth 3.1.1 compatibility fixture, 25 October 2026
The unmodified official Decky store archive for Bluetooth 3.1.1 (store ID 18,
SHA-256 `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5`)
passes Frame Control's archive rules. The fake Frame e2e harness installed it
through the store API, verified the managed receipt and frontend bytes, then
removed it. An earlier real Frame check recorded the backend's `Bluetooth
plugin loaded` line while SteamVR was in standby.
In a local Node host test, its official frontend starts and builds an initial
panel tree. Framey supplies empty Steam token maps and readable English
fallbacks for the three Bluetooth labels that lack plugin-owned fallbacks.
The test leaves React effects and click
handlers inert and sends no backend calls. It does not inspect paired devices,
change Bluetooth state, exercise a controller, or prove the panel is visible in
VR. No worn-headset check has passed for this plugin.
+7
View File
@@ -68,6 +68,7 @@ none of these frontend controls has passed a worn-headset check yet.
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack` | Local routes inside this plugin panel only | Host unit test |
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
| `fetchNoCors`, Decky event listeners, file picker, root calls | Unavailable | Not supported by Framey |
@@ -85,3 +86,9 @@ The Framey `localStorage` bridge is for disposable frontend caches, including
the cache read attempted by Steam Status 1.2.3. It does not expose another
plugin's cache or the Frame Control UI key. A real Steam Status panel reopen
and a worn-headset settings check are still needed.
HLTB for Deck 2.0.12 demonstrates the limit of the panel shim: its official
bundle can start and produce a settings view with Framey's Steam module lookup
stub, but its game page and context menu patches have no Steam target in the
isolated panel. The tests do not call its HLTB network search or Python backend.
Do not claim game length badges work in VR from this result.
+4 -1
View File
@@ -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.
+6
View File
@@ -20,6 +20,12 @@ count. So a build reaches people only when you publish it, after testing it.
GitHub temporarily show its tag as `untagged-…`. The workflow finds that
draft by its exact version title, restores the tag association and retries
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. 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.
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Launch a built Linux AppImage on Xvfb and check its bundled server.
This checks the packaged app without a headset or a desktop login session.
The profile and display are temporary, and every process started by this check
is stopped before it exits.
"""
import hashlib
import os
from pathlib import Path
import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
def server_port(profile):
for log in (profile / "config").rglob("server.log"):
ports = re.findall(r"--port (\d+)", log.read_text(errors="replace"))
if ports:
return int(ports[-1])
return None
def port_closed(port):
try:
with socket.create_connection(("127.0.0.1", port), timeout=1):
return False
except OSError:
return True
def check(appimage):
if not appimage.is_file() or not os.access(appimage, os.X_OK):
raise RuntimeError(f"AppImage is missing or cannot run: {appimage}")
if not shutil.which("xvfb-run"):
raise RuntimeError("xvfb-run is required for the AppImage smoke check")
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
passed = False
with output.open("w") as log:
proc = subprocess.Popen(
["xvfb-run", "-a", "-s", "-screen 0 1280x800x24 -nolisten tcp",
str(appimage), "--no-sandbox"],
cwd=profile, env=env, stdout=log, stderr=subprocess.STDOUT,
start_new_session=True,
)
try:
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
if proc.poll() is not None:
raise RuntimeError(f"AppImage exited {proc.returncode} before its server answered")
port = server_port(profile)
if port is not None:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2) as response:
if response.status == 200 and response.headers.get("Server", "").startswith("FrameControl/1"):
passed = True
break
except (OSError, urllib.error.URLError):
pass
time.sleep(0.5)
if not passed:
raise RuntimeError("AppImage server did not answer within 60 seconds")
except BaseException:
log.flush()
print(output.read_text(errors="replace")[-1500:], file=sys.stderr)
raise
finally:
try:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait(timeout=10)
if port is not None:
for _ in range(20):
if port_closed(port):
break
time.sleep(0.5)
else:
raise RuntimeError(f"AppImage server stayed on port {port} after shutdown")
print(f"AppImage SHA-256 {digest}: HTTP 200, FrameControl/1, server stopped")
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("usage: check-appimage-launch.py path/to/AppImage")
check(Path(sys.argv[1]).resolve())
+5
View File
@@ -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()
Binary file not shown.
Binary file not shown.
+13 -4
View File
@@ -10,11 +10,20 @@ from a local fixture so CI does not depend on the store being online.
| `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` |
| `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` |
| `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` |
| `18.zip` | Bluetooth | 3.1.1 | `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5` |
| `26.zip` | HLTB for Deck | 2.0.12 | `bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558` |
The matching source projects are
[ProtonDB Badges](https://github.com/bschelst/protondb-decky),
[Steam Status](https://github.com/bschelst/steamstatus-decky), and
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner).
[Steam Status](https://github.com/bschelst/steamstatus-decky),
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner),
[Bluetooth](https://github.com/Outpox/Bluetooth), and
[HLTB for Deck](https://github.com/morwy/hltb-for-deck).
The test verifies the ZIP digest, archive policy, API v1/v2 manifest, installed
frontend bytes, managed store receipt, and removal. It does **not** run their
Python backends or prove their JavaScript panels render in the VR shell.
frontend bytes, managed store receipt, and removal. Separate local host tests
start Bluetooth's frontend and its initial panel without running effects or
actions, and start HLTB's frontend and shallow settings view with a stub Steam
module lookup. They do **not** run plugin Python backends, Bluetooth actions,
HLTB searches, or prove JavaScript panels render in the VR shell. The
Bluetooth result and limits are in
[the fixture evidence](../../../docs/evidence/framey-bluetooth-fixture-2026-10-25.md).
+4
View File
@@ -10,6 +10,10 @@ PLUGINS = (
'folder': 'steamstatus-decky', 'sha256': '9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e'},
{'id': 50, 'name': 'Storage Cleaner', 'version': '1.4.0', 'api_version': 1,
'folder': 'decky-storage-cleaner', 'sha256': '1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591'},
{'id': 18, 'name': 'Bluetooth', 'version': '3.1.1', 'api_version': 1,
'folder': 'Bluetooth', 'sha256': 'f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5'},
{'id': 26, 'name': 'HLTB for Deck', 'version': '2.0.12', 'api_version': 1,
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
)
+3
View File
@@ -107,4 +107,7 @@ const undo=api.routerHook.addRoute('/plugin/details',LocalRoute);
assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),true);
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,1);
assert.equal(window.DFL.Navigation.Navigate('/decky/store'),false);
assert.equal(window.DFL.findModuleChild(()=>true),null);
window.DFL.Navigation.CloseSideMenus();
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0);
undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false);
+43
View File
@@ -0,0 +1,43 @@
// Start the unmodified official Bluetooth frontend and build its initial UI.
// Effects and click handlers are inert: no Bluetooth/backend operation runs.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const bundle=readFileSync(process.argv[2],'utf8');
const official=bundle.replace(/export \{ index as default \};\s*\/\/# sourceMappingURL=index.js.map\s*$/,
'window.__official = index;');
assert.notEqual(official,bundle);
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
const host=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
assert.notEqual(host,source);
const react=new Proxy({Fragment:'fragment',Component:class {},
createElement:(type,props,...children)=>({type,props,children}),
createContext:value=>({Provider:()=>{},Consumer:()=>{},_currentValue:value}),
useState:value=>[value,()=>{}],useReducer:(_,value)=>[value,()=>{}],
useEffect(){},useRef:value=>({current:value}),useMemo:fn=>fn(),
useCallback:fn=>fn,forwardRef:fn=>fn,memo:fn=>fn},
{get:(target,key)=>target[key]??(()=>{})});
const window={React:react,ReactDOM:{createRoot:()=>({render(){}})},location:{hash:''}};
const elements=new Map();
const messages=[];
const context=vm.createContext({window,navigator:{language:'en'},parent:{postMessage:message=>messages.push(message)},
document:{getElementById:id=>{if(!elements.has(id))elements.set(id,{});return elements.get(id);}},
addEventListener(){},setTimeout:()=>1,clearTimeout(){},console,URL,Blob});
vm.runInContext(host,context);
context.DFL=window.DFL;
context.SP_REACT=window.SP_REACT;
context.SP_JSX=window.SP_JSX;
context.LocalizationManager=window.LocalizationManager;
assert.equal(window.LocalizationManager.m_mapTokens.size,0);
assert.equal(window.LocalizationManager.m_mapFallbackTokens.get('Settings_Bluetooth_Devices'),'Devices');
vm.runInContext(official,context,{timeout:2000});
const plugin=window.__official();
assert.equal(plugin.content?.type?.name,'Content');
const panel=plugin.content.type();
assert.equal(panel.type,'div');
const text=JSON.stringify(panel);
assert.match(text,/Bluetooth/);
assert.match(text,/Devices/); // Readable fallback when Steam tokens are absent.
assert.doesNotMatch(text,/Settings_Bluetooth_Devices/);
assert.equal(messages.filter(message=>message.type==='framey:call').length,0);
+57
View File
@@ -0,0 +1,57 @@
// Execute the pinned official HLTB frontend's entry point with Framey's API.
// This checks module startup only; it never makes network or backend calls.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {inflateRawSync} from 'node:zlib';
import vm from 'node:vm';
const host=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
const hostTest=host.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
assert.notEqual(hostTest,host);
const zip=readFileSync(new URL('../fixtures/framey-official/26.zip',import.meta.url));
const eocd=zip.lastIndexOf(Buffer.from([0x50,0x4b,0x05,0x06]));
assert.ok(eocd>0);
let offset=zip.readUInt32LE(eocd+16),bundle;
for(let i=0;i<zip.readUInt16LE(eocd+10);i++){
assert.equal(zip.readUInt32LE(offset),0x02014b50);
const nameLength=zip.readUInt16LE(offset+28),extraLength=zip.readUInt16LE(offset+30);
const commentLength=zip.readUInt16LE(offset+32);
const name=zip.subarray(offset+46,offset+46+nameLength).toString();
if(name==='hltb-for-deck/dist/index.js'){
const local=zip.readUInt32LE(offset+42);
assert.equal(zip.readUInt32LE(local),0x04034b50);
const start=local+30+zip.readUInt16LE(local+26)+zip.readUInt16LE(local+28);
const compressed=zip.subarray(start,start+zip.readUInt32LE(offset+20));
bundle=(zip.readUInt16LE(offset+10)===8?inflateRawSync(compressed):compressed).toString();
}
offset+=46+nameLength+extraLength+commentLength;
}
assert.ok(bundle);
const official=bundle.replace(/export \{ index as default \};\s*\/\/# sourceMappingURL=index.js.map\s*$/,
'window.__official = index;');
assert.notEqual(official,bundle);
const react=new Proxy({Fragment:'fragment',Component:class {},
createElement:(type,props,...children)=>({type,props,children}),
createContext:value=>({Provider:()=>{},Consumer:()=>{},_currentValue:value}),
forwardRef:fn=>fn,memo:fn=>fn,useState:value=>[value,()=>{}],
useContext:ctx=>ctx?._currentValue,useMemo:fn=>fn(),useCallback:fn=>fn,
useRef:value=>({current:value}),useEffect(){}},
{get:(target,key)=>target[key]??(()=>{})});
const window={React:react,ReactDOM:{createRoot:()=>({render(){}})},location:{hash:''}};
const elements=new Map();
const context=vm.createContext({window,navigator:{language:'en'},parent:{postMessage(){}},
document:{getElementById:id=>{if(!elements.has(id))elements.set(id,{});return elements.get(id);}},
addEventListener(){},setTimeout:()=>1,clearTimeout(){},console,URL,Blob});
vm.runInContext(hostTest,context);
context.DFL=window.DFL;
context.SP_REACT=window.SP_REACT;
context.SP_JSX=window.SP_JSX;
vm.runInContext(official,context,{timeout:2000});
const plugin=window.__official();
assert.equal(typeof window.DFL.findModuleChild,'function');
assert.equal(window.DFL.findModuleChild(()=>{}),null); // Steam has no matching module in the VR panel.
assert.equal(plugin.content?.type?.name,'QuickAccessView');
const panel=plugin.content.type();
assert.ok(panel?.type); // Read-only settings render with empty local data.
assert.equal(typeof plugin.onDismount,'function');
plugin.onDismount();
+6 -6
View File
@@ -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);
+12
View File
@@ -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'}]}
+19
View File
@@ -5,6 +5,8 @@ import hashlib
import json
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
@@ -19,6 +21,23 @@ from framey_official import PLUGINS, archive
class OfficialBundles(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the official frontend check')
def test_bluetooth_initial_panel_uses_safe_token_fallback(self):
with zipfile.ZipFile(io.BytesIO(archive(next(p for p in PLUGINS if p['id'] == 18)))) as bundle:
with tempfile.TemporaryDirectory() as folder:
frontend = Path(folder) / 'index.js'
frontend.write_bytes(bundle.read('Bluetooth/dist/index.js'))
script = Path(__file__).parent / 'page/framey_bluetooth.mjs'
result = subprocess.run(['node', str(script), str(frontend)],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the official frontend check')
def test_hltb_frontend_starts_with_framey_module_stub(self):
script = Path(__file__).parent / 'page/framey_hltb.mjs'
result = subprocess.run(['node', str(script)], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_pinned_store_archives_install_and_pack_into_framey_panel(self):
with tempfile.TemporaryDirectory() as root:
plugins = Path(root)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Framey hosts only store-owned API v2 frontends and scopes backend calls."""
"""Framey hosts store-owned modern Decky frontends and scopes backend calls."""
import sandbox # noqa: F401
import gzip
import http.client
+6 -1
View File
@@ -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']]
+10 -3
View File
@@ -11,7 +11,14 @@
: R.createElement(type, elementProps);
};
window.SP_JSX = {jsx, jsxs:jsx, Fragment:R.Fragment};
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en']};
// Steam normally fills these token maps. Official plugins can safely use
// their own fallback strings when the isolated panel has no Steam tokens.
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en'],
m_mapTokens:new Map(), m_mapFallbackTokens:new Map([
['Settings_Bluetooth_Connected','Connected'],
['Settings_Bluetooth_NotConnected','Not connected'],
['Settings_Bluetooth_Devices','Devices'],
])};
const root = document.getElementById('framey-root');
const error = document.getElementById('framey-error');
const toastRoot = document.getElementById('framey-toasts');
@@ -155,13 +162,13 @@
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
staticClasses: {Title:'framey-title'},
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null),
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null), CloseSideMenus:()=>navigate(null),
NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }},
showModal: element => modalRoot.render(R.createElement('section', null,
R.createElement('button', {onClick:closeModal}, 'Close'), element)),
// Steam app, library, and store patches have no target inside this panel.
afterPatch: () => () => {}, createReactTreePatcher: () => () => {},
findInReactTree: () => null, findModuleExport: () => null,
findInReactTree: () => null, findModuleExport: () => null, findModuleChild: () => null,
appDetailsClasses: {}, appDetailsHeaderClasses: {},
useParams: () => ({}),
};
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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):