Files
saphid--frame-control/docs/framey-loader.md
T

19 KiB

Framey loader on Steam Frame

Framey is an experimental bridge for Decky store plugins inside Frame Control's SteamVR dashboard panel. It is opt in. The panel and Decky backend are ordinary user services under ~/.local/share/frame-control; neither needs root or a read-write SteamOS root filesystem. Decky's standard Quick Access menu still does not appear in the separate SteamVR CEF target.

See the complete official-store compatibility table for version-specific statuses, blockers and verification limits. Both stores and installed panels show the same digest-scoped results; a new published archive starts unverified.

Use in the headset

Install Frame Control in VR and Decky Loader on Frame from Frame Control on your computer. Open the Frame Control tab in SteamVR, then choose Decky plugins → Open official store. Search the catalogue, confirm an install, and choose Open panel from the installed plugin list. The panel contains the plugin's own controls and settings. Update and Remove affect only plugin folders that Frame Control's store manager installed. Use Scroll plugin up and Scroll plugin down above an open panel when wheel scrolling is unavailable. Point at a scrollable part of the plugin first to move that section; otherwise the buttons move the panel document. Each press moves about three quarters of the visible section and stops at its edge. Removing a plugin keeps its Decky settings. Removing the Frame Control headset panel or Decky backend keeps plugin folders until each is removed separately.

Frame Control's existing official-store manager downloads a published ZIP from Decky's content-addressed CDN, verifies its store SHA-256 hash and manifest, rejects unsafe ZIP members, path collisions, special files and root plugins, and swaps one managed plugin folder with rollback on failure. It rejects non-ARM64 ELF and Windows binaries. The Decky user service restarts after a file change. Framey's panel host accepts API v2 bundles and API v1 bundles using Decky's modern frontend initializer, with dist/index.js. Legacy (function (...) {...})(DFL, SP_REACT) bundles are also accepted: their returned initializer receives the legacy serverAPI, and keyword backend calls use Decky's legacy RPC. The wrapper runs as a blob ES module inside the same sandbox; it does not require eval. Other installed plugins remain manageable but have no Framey panel. API v1 admission is a compatibility attempt, not a guarantee that the plugin's controls work in VR. The store manager does not currently screen a plugin's runtime network downloads.

What the compatibility host supplies

The plugin bundle runs in an opaque-origin iframe in the Frame Control SteamVR panel. It receives React 18.3.1, a small DFL panel-control shim, and the Decky API v2 call/callable interface. Backend methods are sent by the authenticated parent to Steam's existing SharedJSContext CDP client, then through Decky's existing websocket connection. Framey never opens a second Decky websocket, because Decky allows only one frontend connection. The iframe does not receive Frame Control's UI key. React and ReactDOM are bundled for offline use under their MIT licenses in ui/vendor. Framey gives each plugin a bounded localStorage substitute because browser storage is denied to an opaque-origin iframe. The parent panel keeps a separate cache for each plugin: up to 64 keys, 64 KiB per value and 128 KiB total. It restores the cache when a panel reopens if the parent's browser storage is available. If storage is denied or cleared, the cache lasts only for the current panel. Use the plugin backend for settings that must survive cache loss.

Decky API v1/v2 addEventListener and removeEventListener receive real backend events while the panel is open. The parent resolves the plugin name from its installed store receipt, polls Steam's existing Decky event connection, and sends only subscribed events to that panel session. A subscription is established before a backend call can emit its response. The bridge allows 32 event names, queues at most 64 events and 48 KiB, and drops any event whose arguments exceed 8 KiB. Polling occurs about once a second, so this is unsuitable for high-rate input/audio streams. Closing/reopening a panel removes its subscriptions; abandoned CEF subscriptions expire after 15 seconds. This does not deliver global notifications while a Framey panel is closed.

The 26 October live checks used the actual official Notebook and Simple Timer Python backends in a private removable ARM64 Decky service. Notebook's default notebook saved a pointer stroke and loaded it after a backend restart. Simple Timer's start/cancel controls, countdown, completion toast and persisted recents/subtle mode worked. Simple Timer remains partial: its own expired-timer startup raises a missing timer_task error, sound/suspend are unsupported, and worn VR was not checked.

DFL module and class lookup searches a finite registry of Framey's own controls, CSS maps and an explicitly unsupported optional UI-audio player. It follows DFL's module/export filter and mapping contracts; unknown Steam modules return undefined, and unknown module mappings are empty. Returned IDs describe local Framey exports, not Steam webpack IDs. Sound playback throws an unsupported error, and Steam audio stores, function-source signatures and global patches are not fabricated. The module lookup checks record the resulting TabMaster documentation support and remaining blockers.

The host currently supplies sections, rows, buttons, toggles, text fields, sliders, grouped dropdowns, scroll containers, a modal, locale information, local panel routes and toasts, and backend calls for the plugin that owns the panel. The headset's scroll buttons send only a direction to the isolated iframe. They target the last scrollable region reached by a trusted pointer move and fall back to the iframe document when that region disappears. A result message reports whether content moved; this bridge has no backend or Steam privilege. Steam library/store page patches, global Steam UI components, file pickers, and privileged root methods have no VR target. A plugin that depends on them can render only part of its UI or show an incompatibility message. A Decky plugin's Python backend still runs as the headset user when Decky loads it, so review third-party code before opting in. The panel iframe can make HTTPS requests subject to ordinary browser CORS.

ProtonDB Badges' current API v2 bundle rendered its settings in a local Chromium browser with a stubbed backend reply on 25 October 2026. Its controls accepted pointer input. This does not verify SteamVR visibility, controller input, live Decky settings persistence, or badges on Steam game pages. The last item has no supported injection target in the Frame Control panel. The browser and fake Frame evidence records exactly what was exercised. The pinned official Steam Status 1.2.3 frontend also rendered in local Chromium with a stubbed settings reply. Its public status request worked, but its cache logged localStorage errors in Framey's v0.4.34 opaque iframe. A later local browser check confirmed that a panel-lifetime substitute avoids those errors while preserving the opaque origin. A second browser check tested the per-plugin cache after reopening a panel. These checks used a stub backend and no worn headset. Fake Frame CI also installs and removes pinned official ProtonDB Badges 1.3.4, Steam Status 1.2.3, and Storage Cleaner 1.4.0 archives, checks their API v1/v2 manifests and frontend bytes, and verifies they can be packed as Framey panels. It does not execute their actual backend code or prove their frontend controls work in VR. Storage Cleaner rendered an empty-data panel in local Chromium. Its cleanup actions were not tested; they may remove local game saves.

Framey also supplies local tabs/sidebar pages, spinners, dialog/menu wrappers and progress bars. Its explicit SteamClient subscription fallbacks return disposable handles without emitting fabricated events. Running-game reads and lifetime notifications use the scoped bridge described below; power, app launch and hardware mutation methods are not supplied. Other live Steam stores require integration work. IndexedDB is hidden because the browser exposes it but denies access in an opaque iframe; localForage can then select Framey's bounded localStorage. Direct IndexedDB users remain unsupported.

SuspensefulImage renders a native image. SteamClient Storage.GetJSON and SetObject use the same bounded per-plugin cache, including panel reopen persistence; they do not read Steam's account/global JSON store. Missing keys reject with Not found. The suspend store has no methods. Optional Steam app/account globals are undefined until a successful library read. These fallbacks let settings render without inventing live games or account identity. The exact-bundle follow-up verifies Pause Games and Tailscale Control settings in local Chromium; their primary backend actions remain unverified. PlayTime still requires Steam's MobX game observers for automatic tracking.

Panels referencing running-game APIs now receive a detached, read-only SteamUIStore.RunningApps / MainRunningApp snapshot, also exposed through DFL.Router. The authenticated headset-only /api/framey/running endpoint checks installed store ownership; the parent checks the active panel session. Poll and close requests also carry the panel's pinned SHA-256 receipt. A missing or changed installed receipt is rejected before any Steam CEF request; a rejected stale close relies on the existing finite subscription lease for cleanup. Fixed bundled code runs in Steam CEF. It copies at most 64 running app overviews, a main app and public Steam CDN capsule URLs; it never copies local artwork paths, launch commands or Steam objects. The host refreshes about every two seconds without overlapping requests. Failure clears stale context and sets SteamUIStore.FrameyRunningAvailable to false.

GameSessions.RegisterForAppLifetimeNotifications relays actual Steam {unAppID, bRunning} callbacks through a bounded 64-event queue. Snapshot changes never fabricate launch/exit callbacks. Handles support .unregister(); panel close/replacement removes the CEF subscription, with a 15-second lease (checked every five seconds) if cleanup cannot reach Steam. At most four panel sessions are retained. These are open-panel observations, not closed-panel automation or a MobX implementation. Queue overflow can lose old callbacks. appStore capsule helpers expose only projected public URLs for running apps; unavailable or local custom artwork returns an empty value.

Running-context evidence separates real Frame empty-list/subscription/cleanup reads, the exact Discord Status ARM64 backend connection check, and synthetic nonempty/transition fixtures. Real game transitions, Discord activity and worn-VR operation remain unverified.

Three parameterless SteamClient reads use Steam's actual CEF context: User.GetIPCountry, InstallFolder.GetInstallFolders and System.GetSystemInfo. The authenticated headset-only endpoint checks that the plugin is installed, selects an expression from a fixed allowlist, and caps replies at 256 KiB. The iframe cannot supply a Steam property path or executable source, and replies are discarded if the panel session changed. Unsupported APIs remain unavailable. Locked Frame checks verified all three reads and IsThereAnyDeal's country dropdown in local Chromium. Its backend settings/deal calls stayed denied; Deck Settings still needs real Steam library context. A deployed headset HTTP endpoint and worn-VR behavior remain unverified.

The fake Frame integration test now writes a Decky setting through Framey's scoped backend endpoint, reads it in a new CEF request, updates the plugin, then checks it again. It also confirms removal blocks method calls and a reinstall can read the retained setting. This tests the bridge and fixture; real Decky settings persistence on a worn Frame is still unverified.

The in-headset Open official store view loads the full official catalogue. 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. Both root and _root store tags mark a plugin as needing root, and duplicate store IDs are omitted because an ID alone could select a different archive than the visible row. The store still needs the managed Decky user service running 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 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. After a polling error, Install, Update, and Remove stay disabled while the saved job's result is unknown; use Check result before starting another change. Refresh the installed list before starting another 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.

An open plugin panel closes before its own Update or Remove starts, so its old 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. 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.

For plugin authors

Start with the Framey settings sample for a buildable Decky API v2 panel and Python settings backend.

Publish a normal Decky store API v2 release with plugin.json, a single dist/index.js ESM frontend entry point, and a Python backend if needed. Give the frontend a content React element returned by definePlugin, and build settings as panel controls from @decky/ui. Backend calls through @decky/api reach only that plugin's own backend. For best VR compatibility, keep the panel self-contained and use PanelSection, PanelSectionRow, ButtonItem, ToggleField, and DropdownItem. Handle a missing Steam UI route, store overlay, or SteamClient object gracefully. Avoid root, remote binary downloads, Steam library patches, and assumptions about Steam Deck hardware. Declare settings defaults so a fresh user service can render without pre-existing files. Test your ZIP with the store validator and the fake Frame flow before asking users to install it.

An API v2 plugin's normal content is its default VR panel. Framey also shows an optional settings React element under a Settings tab and an optional titleView above it. A plugin with only settings opens that view directly; Framey shows tabs only when both views exist. For one-tap headset controls, return frameyQuickActions: [{label: 'Refresh data', onClick: refreshData}] from the plugin initializer. Framey displays up to eight actions and reports a pending, success, or failure state. It ignores extra taps while an action is pending, and a failed action can be retried. Invalid action descriptors do not count toward the eight-action limit. These descriptor fields are Framey additions; Decky ignores them. Use a plugin backend method through @decky/api for work needing persistent state, and keep a quick action safe to repeat. See the Decky API compatibility table for tested behavior and remaining limits.

The Frame Control extension API is an alternative for a Frame-specific panel. It needs only a local manifest, HTML, and the read-only status bridge; it does not need Decky.

Removal

Use Remove on each installed Framey plugin, then Remove managed Decky and Remove under Frame Control in VR. These operations remove only Frame Control-owned folders and services. Settings, the Chromium profile, and other users' plugins are preserved. To remove one managed plugin manually, first inspect its .frame-control-store.json receipt in ~/.local/share/frame-control/decky/plugins/<folder>, then delete only that folder and run systemctl --user try-restart frame-control-decky.service. The Decky and headset service manual removal commands are in Decky on Frame and Frame Control in the headset.

Fast worn-headset check

With an already installed Framey plugin and someone wearing the headset, run python3 scripts/framey-worn-check.py --worn-confirmed from a paired computer. The script atomically acquires /tmp/frame-test.lock before reading battery or SteamVR activity. If another job owns the lock, it makes no sensor reads. It releases its own lock without starting the interaction when the headset is in standby. Within 35 seconds, use a controller to open a managed plugin panel and touch one setting. The script records a fresh trusted pointer event, the panel-ready event, SteamVR overlay visibility, and a stereo screenshot with varied pixels. It deletes its own short-lived challenge and releases only its own lock. It records the screenshot hash and color count, then removes the private temporary image from both devices. Pixel variety alone cannot prove the UI text was legible; a person must still confirm the view in the headset. The script does not install or remove a plugin, alter Steam settings, or wake the headset.

Unattended VR checks

The unattended check can wake, inspect and capture the real compositor, send OpenVR mouse input, and exercise a temporary sample against live Decky with restoration and shared-lock protection. Its device evidence distinguishes these checks from physical controller and wearer validation. A trusted pointer event and active-headset telemetry are both reproducible without wearing the Frame; they must not be labelled as physical controller proof.