Compare commits

...
Author SHA1 Message Date
SAPHID 78e33325d7 Serialize managed Decky changes across desktop and headset 2026-10-25 09:17:56 +00:00
Alex SouthwellandSAPHID 02fe664583 Prepare Frame Control 0.4.32 (#228)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 08:06:51 +00:00
Alex SouthwellandSAPHID e2ffcd34f3 Support Decky panel routes and toasts in Framey (#227)
* Host Decky plugin panels in Frame Control VR

* Record Framey VR verification limits

* Lock Frame before worn checks and clean private captures

* Browse and search the full Decky store in VR

* Show Decky plugin job progress in VR

* Show plugin quick actions and settings in Framey panel

* Add local Decky routes and toasts to Framey

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:56:38 +00:00
Alex SouthwellandSAPHID 2002b9d70e Show Decky plugin quick actions and settings in VR (#226)
* Host Decky plugin panels in Frame Control VR

* Record Framey VR verification limits

* Lock Frame before worn checks and clean private captures

* Browse and search the full Decky store in VR

* Show Decky plugin job progress in VR

* Show plugin quick actions and settings in Framey panel

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:46:32 +00:00
13 changed files with 247 additions and 28 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.31",
"version": "0.4.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.31",
"version": "0.4.32",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.31",
"version": "0.4.32",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+2 -2
View File
@@ -1,6 +1,6 @@
# What we have tested
Current for Frame Control **v0.4.31**, 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.32**, 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 |
|---|---|---|---|---|
@@ -24,6 +24,6 @@ Current for Frame Control **v0.4.31**, 25 October 2026. “Real Frame + physical
| 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 | 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: [PR #208](https://github.com/saphid/frame-control/pull/208), [compatibility table](decky-frame.md) |
| Experimental Framey VR plugin panels | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium; the fake Frame exercised the store and method bridge. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, [PR #223](https://github.com/saphid/frame-control/pull/223), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.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. The fake Frame and CI exercised the store search, install progress, quick actions, settings, local routes and toasts. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32: [#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), [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.
+7 -3
View File
@@ -57,9 +57,11 @@ change Frame Control owned plugin folders under
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
back an update if the service restart fails. The current VR shell does not
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
shows installed plugins and store actions, but plugin pages, settings, routes,
and Decky toasts are **not** available there yet. The panel itself still needs
a worn-headset visual and controller check.
shows installed plugins and store actions. Its separate Framey iframe now hosts
compatible API v2 plugin pages, settings, local routes, and panel toasts.
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).
## What was checked on the real headset
@@ -163,6 +165,8 @@ It preserves plugin files and settings so unrelated user data is not deleted.
Open **Official plugin store** to review a plugin, then confirm each install,
update, or remove. Root and x86-only plugins are blocked. The manager only
removes plugin folders that it installed; it leaves other Decky folders alone.
The desktop app and headset panel share one lock for managed Decky changes.
If another change is in progress, wait for it to finish and try again.
Restart Steam or the headset to clear the frontend already injected into its
current web helper process. No action wipes the headset.
+23
View File
@@ -37,3 +37,26 @@ To test safely, use a separate Frame Control managed Decky install, verify the
ZIP digest from the official store, install one plugin at a time, and inspect
`journalctl --user -u frame-control-decky.service`. Remove only your own
plugin folder when you finish. See the current [compatibility table](decky-frame.md).
## Framey API v2 compatibility
Framey hosts a plugin's frontend in an isolated iframe in the Frame Control
SteamVR panel. The table describes the current implementation and its tests;
none of these frontend controls has passed a worn-headset check yet.
| Decky API or UI feature | Framey behavior | Verified in |
| --- | --- | --- |
| `definePlugin` with `content`, `titleView` | Renders a React panel and optional title | Local browser with ProtonDB Badges; host unit test |
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls | Local browser and host unit test |
| Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for get/set setting; host unit test |
| `toaster.toast` | Visible toast inside the plugin panel; `dismiss` and optional `onClick` | Host unit test; no Steam system notification |
| `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 |
| 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 |
Keep routes local to the panel and give every operation a visible result. A
plugin that calls `Navigation.Navigate('/decky/store')` will not open the
Frame Control store; only paths registered by that same plugin can navigate.
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
+16 -5
View File
@@ -38,11 +38,11 @@ does not receive Frame Control's UI key. React and ReactDOM are bundled for
offline use under their MIT licenses in `ui/vendor`.
The host currently supplies sections, rows, buttons, toggles, dropdowns,
scroll containers, a modal, locale information, and backend calls for the
plugin that owns the panel. Steam library/store page patches, global Steam UI
components, Decky route hooks, 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
scroll containers, a modal, locale information, local panel routes and toasts,
and backend calls for the plugin that owns the panel. 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.
@@ -83,6 +83,17 @@ 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. 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. 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 [API v2 compatibility table](frame-plugin-author.md#framey-api-v2-compatibility)
for tested behavior and remaining limits.
The [Frame Control extension API](extensions.md) 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.
+59
View File
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface;\n})();');
assert.notEqual(testSource,source);
const states=[];
const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}),
useState:value=>[value,next=>states.push(next)], useEffect(){},isValidElement:value=>!!value?.type};
class Element {constructor(){this.children=[];this.textContent='';this.attributes={};}
append(...children){this.children.push(...children);}setAttribute(key,value){this.attributes[key]=value;}
remove(){this.removed=true;}}
const elements=new Map();
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
const listeners=[];const messages=[];const parent={postMessage:message=>messages.push(message)};
const context=vm.createContext({window,document:{getElementById:id=>{
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);},createElement:()=>new Element()},
navigator:{language:'en'}, parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(testSource,context);
const nodes=(tree,type)=>[...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))];
let called=0;
const plugin={content:{type:'content'},settings:{type:'settings'},titleView:{type:'title'},
frameyQuickActions:[{label:'Refresh data',onClick:async()=>{called++;return 'Fresh';}},
{label:'invalid'},...Array.from({length:9},(_,i)=>({label:`Action ${i}`,onClick:()=>{}}))]};
const tree=window.__surface({plugin});
const buttons=nodes(tree,'button');
assert.equal(buttons.length,9); // Panel, Settings, and seven valid actions inside the first eight entries.
assert.equal(nodes(tree,'nav')[0].props['aria-label'],'Plugin views');
assert.equal(nodes(tree,'section')[0].props['aria-label'],'Plugin quick actions');
assert.equal(nodes(tree,'header').length,1);
await buttons[2].props.onClick();
assert.equal(called,1);
assert.deepEqual(states.slice(-3),[0,'Refresh data…','Fresh',null].slice(-3));
assert.equal(nodes(window.__surface({plugin:{settings:{type:'settings'}}}),'nav').length,1);
// API v2 backend calls remain request-scoped and errors return to the caller.
const api=window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit.connect(2,'Test');
const reply=api.call('get_setting','mode','default');
assert.equal(messages[0].method,'get_setting');
assert.deepEqual(Array.from(messages[0].args),['mode','default']);
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:messages[0].id,result:'dark'}});
assert.equal(await reply,'dark');
await assert.rejects(api.call('x;alert(1)'),/Invalid backend method/);
const toast=api.toaster.toast({title:'Saved',body:'Mode updated',duration:4000});
const toastNode=elements.get('framey-toasts').children[0];
assert.equal(toastNode.attributes.role,'status');
assert.equal(toastNode.children[0].textContent,'Saved');
assert.equal(toastNode.children[1].textContent,'Mode updated');
toast.dismiss();assert.equal(toastNode.removed,true);
function LocalRoute(){return React.createElement('p',null,'Route content');}
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);
undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false);
+23
View File
@@ -15,6 +15,7 @@ import zipfile
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
import frame_decky_store
import frame_decky
def archive(flags=(), extra=None):
@@ -111,6 +112,28 @@ class StoreTransaction(unittest.TestCase):
self.assertNotEqual(result.returncode, 0)
self.assertTrue(external.exists())
def test_shared_lock_rejects_plugin_and_loader_changes(self):
import fcntl
lock_path = Path(self.tmp.name) / '.local/share/frame-control/decky-transaction.lock'
lock_path.parent.mkdir(parents=True, exist_ok=True)
data, _ = archive()
with lock_path.open('w+b') as held:
fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
plugin = self.apply('install', data)
loader = subprocess.run([sys.executable, '-', 'remove', 'none', 'none', 'none'],
input=frame_decky.APPLY, text=True, capture_output=True,
env=self.env, timeout=15)
self.assertNotEqual(plugin.returncode, 0)
self.assertNotEqual(loader.returncode, 0)
self.assertIn('Another Decky change is in progress', plugin.stderr)
self.assertIn('Another Decky change is in progress', loader.stderr)
self.assertFalse(self.folder.exists())
installed = self.apply('install', data)
self.assertEqual(installed.returncode, 0, installed.stderr)
self.assertEqual(self.apply('remove').returncode, 0)
class StoreHost(unittest.TestCase):
def test_owned_plugin_can_be_removed_without_store_network(self):
+6
View File
@@ -25,6 +25,11 @@ class Panel(unittest.TestCase):
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_plugin_quick_actions_and_settings(self):
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_actions.mjs')],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
@@ -42,6 +47,7 @@ class Panel(unittest.TestCase):
page = framey_panel.panel(14).decode()
self.assertIn('framey-bundle', page)
self.assertIn('/framey-runtime/host.js', page)
self.assertIn('framey-toasts', page)
self.assertNotIn('X-Frame-UI', page)
self.assertNotIn('SP_REACT.createElement', page) # bundled code is base64, not executable HTML.
with self.assertRaises(FileNotFoundError):
+10 -1
View File
@@ -63,7 +63,7 @@ print(json.dumps({'arch':platform.machine(), 'frameOS':os_release.get('VERSION_I
APPLY = r'''
import hashlib, json, os, pathlib, subprocess, sys
import fcntl, hashlib, json, os, pathlib, subprocess, sys
action, token, digest, version = sys.argv[1:]
home = pathlib.Path.home()
base = home / '.local/share/frame-control/decky'
@@ -72,6 +72,7 @@ binary = base / 'PluginLoader'
receipt = base / '.frame-control/receipt.json'
legacy = base / 'settings/release.json'
stage = pathlib.Path('/tmp/frame-control-decky-' + token)
transaction_lock = None
def systemd(*args):
p = subprocess.run(['systemctl','--user',*args], capture_output=True, text=True, timeout=20)
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip() or 'systemd failed')
@@ -79,6 +80,13 @@ def owned():
try: return json.loads((receipt if receipt.exists() else legacy).read_text()).get('managedBy') == 'Frame Control' and service.is_file()
except (OSError, ValueError): return False
try:
# The desktop and headset servers are separate processes. Lock the shared
# user-service transaction on the Frame before inspecting or changing it.
base.parent.mkdir(parents=True, exist_ok=True)
lock_path = base.parent / 'decky-transaction.lock'
transaction_lock = os.fdopen(os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600), 'r+b')
try: fcntl.flock(transaction_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: raise RuntimeError('Another Decky change is in progress; try again after it finishes')
if action == 'remove':
if not owned(): raise RuntimeError('No Frame Control managed Decky install to remove')
systemd('disable','--now','frame-control-decky.service')
@@ -141,6 +149,7 @@ try:
print(json.dumps({'message':'Decky Loader '+version+' is running as a user service. The VR menu is experimental.'}))
finally:
stage.unlink(missing_ok=True)
if transaction_lock is not None: transaction_lock.close()
'''
+15 -6
View File
@@ -104,7 +104,7 @@ def archive_info(data, expected_hash, expected_name):
APPLY = r'''
import hashlib,json,pathlib,shutil,subprocess,sys,zipfile
import fcntl,hashlib,json,os,pathlib,shutil,subprocess,sys,zipfile
action,token,plugin_id,digest,folder,version = sys.argv[1:]
base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins'
unit=pathlib.Path.home()/'.config/systemd/user/frame-control-decky.service'
@@ -113,6 +113,7 @@ archive=pathlib.Path('/tmp/frame-control-decky-plugin-'+token)
stage=base/('.frame-control-stage-'+token)
backup=base/('.frame-control-old-'+token)
marker='.frame-control-store.json'
transaction_lock=None
def owned():
if target.is_symlink() or not target.is_dir(): return False
try: return json.loads((target/marker).read_text()).get('storeId') == int(plugin_id)
@@ -120,12 +121,19 @@ def owned():
def service(*args):
p=subprocess.run(['systemctl','--user',*args],capture_output=True,text=True,timeout=25)
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip()[-400:] or 'Decky service failed')
if not unit.is_file(): raise RuntimeError('Install Frame Control managed Decky first')
if action not in ('install','update','remove'): raise RuntimeError('Invalid plugin action')
if action=='install' and (target.exists() or target.is_symlink()): raise RuntimeError('Plugin folder already exists')
if action in ('update','remove') and not owned(): raise RuntimeError('Only a Frame Control store plugin can be changed')
running=subprocess.run(['systemctl','--user','is-active','--quiet','frame-control-decky.service']).returncode==0
try:
# A desktop request and a headset-panel request must not change the same
# Decky user service at once. Keep this lock until rollback/cleanup ends.
base.parent.parent.mkdir(parents=True,exist_ok=True)
lock_path=base.parent.parent/'decky-transaction.lock'
transaction_lock=os.fdopen(os.open(lock_path,os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600),'r+b')
try: fcntl.flock(transaction_lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
except BlockingIOError: raise RuntimeError('Another Decky change is in progress; try again after it finishes')
if not unit.is_file(): raise RuntimeError('Install Frame Control managed Decky first')
if action not in ('install','update','remove'): raise RuntimeError('Invalid plugin action')
if action=='install' and (target.exists() or target.is_symlink()): raise RuntimeError('Plugin folder already exists')
if action in ('update','remove') and not owned(): raise RuntimeError('Only a Frame Control store plugin can be changed')
running=subprocess.run(['systemctl','--user','is-active','--quiet','frame-control-decky.service']).returncode==0
if action!='remove':
data=archive.read_bytes()
if not 1024<len(data)<=50*1024*1024 or hashlib.sha256(data).hexdigest()!=digest:
@@ -159,6 +167,7 @@ try:
finally:
archive.unlink(missing_ok=True)
if stage.exists(): shutil.rmtree(stage)
if transaction_lock is not None: transaction_lock.close()
'''
+81 -6
View File
@@ -6,7 +6,11 @@
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en']};
const root = document.getElementById('framey-root');
const error = document.getElementById('framey-error');
const toastRoot = document.getElementById('framey-toasts');
const pending = new Map();
const routes = new Map();
const routeListeners = new Set();
let currentRoute = null;
let nextId = 0;
let plugin;
@@ -48,13 +52,32 @@
R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close'));
const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal'));
const closeModal = () => modalRoot.render(null);
function navigate(path) {
if (path !== null && (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) || !routes.has(path))) return false;
currentRoute = path;
for (const listener of routeListeners) listener(path);
return true;
}
const routerHook = {
addRoute(path, component) {
if (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) ||
(typeof component !== 'function' && !R.isValidElement(component)))
throw Error('Framey route needs a local path and React component');
routes.set(path, component);
return () => {routes.delete(path); if(currentRoute === path) navigate(null);};
},
removeRoute(path) {routes.delete(path); if(currentRoute === path) navigate(null);},
// Steam routes and global components have no equivalent in the isolated panel.
addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{},
};
const DFL = window.DFL = {
definePlugin: init => init,
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem, ButtonItem,
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
DialogButton: passthrough('button'), ConfirmModal,
staticClasses: {Title:'framey-title'},
Navigation: {NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }},
Navigation: {Navigate:navigate, NavigateBack:()=>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.
@@ -64,21 +87,72 @@
useParams: () => ({}),
};
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
const noRoute = {addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{}};
const toaster = {toast: ({title, body}) => { error.textContent = [title, body].filter(Boolean).join(': '); }};
const toaster = {toast: ({title, body, duration, showToast=true, onClick}={}) => {
const data = {title, body};
if (!showToast) return {data, dismiss(){}};
const item = document.createElement('section');
item.setAttribute('role','status');
const heading = document.createElement('strong'); heading.textContent = String(title || 'Plugin notice');
const detail = document.createElement('p'); detail.textContent = String(body || '');
const close = document.createElement('button'); close.type='button'; close.textContent='Dismiss';
const dismiss = () => {item.remove(); clearTimeout(timer);};
close.onclick=dismiss; item.append(heading, detail, close);
if (typeof onClick === 'function') {
const open = document.createElement('button'); open.type='button'; open.textContent='Open';
open.onclick=()=>{onClick(); dismiss();}; item.append(open);
}
toastRoot.append(item);
const timer = setTimeout(dismiss, Math.min(30000,Math.max(3000,Number(duration)||5000)));
return {data,dismiss};
}};
class PanelBoundary extends R.Component {
constructor(props) { super(props); this.state = {failure:null}; }
static getDerivedStateFromError(failure) { return {failure}; }
render() { return this.state.failure ? R.createElement('p', null,
`This Decky panel is not compatible with Framey: ${this.state.failure.message}`) : this.props.children; }
}
function PluginSurface({plugin}) {
const [view, setView] = R.useState(plugin.content ? 'panel' : 'settings');
const [route, setRoute] = R.useState(currentRoute);
R.useEffect(() => {routeListeners.add(setRoute); return () => routeListeners.delete(setRoute);}, []);
const [running, setRunning] = R.useState(null);
const [message, setMessage] = R.useState('');
const actions = Array.isArray(plugin.frameyQuickActions) ? plugin.frameyQuickActions.slice(0, 8)
.filter(action => action && typeof action.label === 'string' && action.label.length <= 80 &&
typeof action.onClick === 'function') : [];
async function runAction(action, index) {
if (running !== null) return;
setRunning(index); setMessage(`${action.label}…`);
try {
const result = await action.onClick();
setMessage(typeof result === 'string' && result.length < 200 ? result : `${action.label} done`);
} catch (failure) { setMessage(`${action.label} failed: ${failure.message || failure}`); }
finally { setRunning(null); }
}
return R.createElement(R.Fragment, null,
plugin.titleView && R.createElement('header', null, plugin.titleView),
route && R.createElement('button', {type:'button', onClick:()=>navigate(null)}, 'Back to plugin'),
plugin.settings && R.createElement('nav', {'aria-label':'Plugin views'},
R.createElement('button', {type:'button', 'aria-current':view === 'panel' ? 'page' : undefined,
onClick:()=>setView('panel')}, 'Panel'),
R.createElement('button', {type:'button', 'aria-current':view === 'settings' ? 'page' : undefined,
onClick:()=>setView('settings')}, 'Settings')),
actions.length > 0 && R.createElement('section', {'aria-label':'Plugin quick actions'},
R.createElement('h2', null, 'Quick actions'),
...actions.map((action, index) => R.createElement('button', {key:index, type:'button',
disabled:running !== null, onClick:()=>runAction(action,index)}, action.label))),
message && R.createElement('p', {role:'status'}, message),
R.createElement('div', null, route ? (typeof routes.get(route) === 'function' ?
R.createElement(routes.get(route)) : routes.get(route)) :
(view === 'settings' ? plugin.settings : plugin.content)));
}
window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = {
connect: (version, name) => ({
_version: Math.min(version, 2),
call: (method, ...args) => ask(method, args),
callable: method => (...args) => ask(method, args),
addEventListener: () => {}, removeEventListener: () => {},
routerHook:noRoute, toaster,
routerHook, toaster,
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
executeInTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')),
injectCssIntoTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')),
@@ -95,8 +169,9 @@
try { module = await import(url); } finally { URL.revokeObjectURL(url); }
if (typeof module.default !== 'function') throw Error('The Decky frontend has no plugin entry point');
plugin = await module.default();
if (!plugin?.content) throw Error('The Decky plugin has no panel to display');
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null, plugin.content));
if (!plugin?.content && !plugin?.settings) throw Error('The Decky plugin has no panel to display');
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null,
R.createElement(PluginSurface, {plugin})));
parent.postMessage({type:'framey:ready'}, '*');
} catch (failure) {
error.textContent = `This Decky panel is not compatible with Framey: ${failure.message}`;
+2 -2
View File
@@ -59,9 +59,9 @@ def panel(plugin_id):
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}
button:focus-visible,select:focus-visible,input:focus-visible{outline:4px solid #f8d370}section{padding:12px;margin:12px 0;background:#1b3042;border-radius:10px}
button:focus-visible,select:focus-visible,input:focus-visible{outline:4px solid #f8d370}button{margin:4px 8px 4px 0}button:disabled{opacity:.55}section{padding:12px;margin:12px 0;background:#1b3042;border-radius:10px}
label{display:grid;gap:6px;margin:10px 0}small{color:#b9c9d6}</style>
<p id="framey-error" role="status"></p><div id="framey-root"></div><div id="framey-modal"></div>
<p id="framey-error" role="status"></p><div id="framey-toasts" aria-live="polite"></div><div id="framey-root"></div><div id="framey-modal"></div>
<script id="framey-bundle" type="application/json">''' + encoded + '''</script>
<script src="/framey-runtime/react.js"></script><script src="/framey-runtime/react-dom.js"></script>
<script src="/framey-runtime/host.js"></script></html>''').encode()