Compare commits

...
7 changed files with 120 additions and 11 deletions

No files matched your search

+13 -1
View File
@@ -14,9 +14,20 @@ action calls the backend, so it reads the persisted setting rather than a
browser cache. A missing, damaged, or old non-boolean value reads as the
caller's default;
the next save atomically replaces the sample's settings file.
The current file is `{"schema": 1, "enabled": true|false}`. An unversioned
`{"enabled": true|false}` file from the first sample release still reads, and
the next save writes schema 1. A file with another explicit schema returns a
visible read or save error and remains untouched, so downgrading the sample
cannot erase settings written by a newer version. Add a deliberate migration
for each future schema instead of changing this guard silently.
The panel also guards its own read and write calls synchronously. If two
controller events arrive before React redraws a disabled control, the second
event is ignored; after the request settles, a new change can be saved.
**Reset example setting** opens a local `ConfirmModal`. **Keep setting** closes
it without a backend call; **Reset** saves `false` through the same guarded
write path as the toggle. This is a safe example of putting a settings change
behind an explicit confirmation in Framey. It has not been tried with a
physical controller in a worn headset.
The **About this setting** button opens a local detail page at
`/framey-sample/details/saved-setting`. It demonstrates `routerHook.addRoute`,
@@ -48,7 +59,8 @@ plugin directory does not enable its VR panel.
Run `python3 -m unittest discover -s tests -p test_framey_sample.py` from the
repository root. It checks backend persistence and evaluates the built bundle
against Framey's actual host script, including the quick action's scoped
backend call and local route registration/navigation. The fake Frame store flow is exercised by
backend call, modal cancel/confirm behavior, and local route
registration/navigation. The fake Frame store flow is exercised by
the repository's e2e suite. No worn-headset visibility or controller-input test
has passed for this sample yet.
+9 -1
View File
@@ -31,6 +31,7 @@ function Content() {
const [busy, setBusy] = SP_REACT.useState(true);
const [message, setMessage] = SP_REACT.useState('Loading setting…');
const pending = SP_REACT.useRef(false);
const resetModalOpen = SP_REACT.useRef(false);
async function refresh() {
if (pending.current)
return;
@@ -72,7 +73,14 @@ function Content() {
setBusy(false);
}
}
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.Navigate('/framey-sample/details/saved-setting'), children: "About this setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
function confirmReset() {
if (pending.current || resetModalOpen.current)
return;
const dialog = DFL.showModal(SP_JSX.jsx(DFL.ConfirmModal, { strTitle: "Reset example setting?", strDescription: "This changes only the sample plugin's saved setting.", strOKButtonText: "Reset", strCancelButtonText: "Keep setting", onOK: () => { void change(false); } }));
resetModalOpen.current = true;
void dialog.ClosedPromise.then(() => { resetModalOpen.current = false; });
}
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: confirmReset, children: "Reset example setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.Navigate('/framey-sample/details/saved-setting'), children: "About this setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
}
function Details() {
const { topic } = DFL.useParams();
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';\nimport {useEffect, useRef, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n const pending = useRef(false);\n\n async function refresh() {\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n // Two controller events can arrive before React redraws the disabled toggle.\n // Keep writes ordered so a late first reply cannot replace the final choice.\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample',\n body: <span>Setting saved: <strong>{value ? 'on' : 'off'}</strong></span>});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nfunction Details() {\n const {topic} = useParams<{topic?: string}>();\n return <PanelSection title=\"Sample detail page\">\n <PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>\n <PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back Line truncated
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, ConfirmModal, Navigation, PanelSection, PanelSectionRow, ToggleField, showModal, useParams} from '@decky/ui';\nimport {useEffect, useRef, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n const pending = useRef(false);\n const resetModalOpen = useRef(false);\n\n async function refresh() {\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n // Two controller events can arrive before React redraws the disabled toggle.\n // Keep writes ordered so a late first reply cannot replace the final choice.\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample',\n body: <span>Setting saved: <strong>{value ? 'on' : 'off'}</strong></span>});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n function confirmReset() {\n if (pending.current || resetModalOpen.current) return;\n const dialog = showModal(<ConfirmModal strTitle=\"Reset example setting?\"\n strDescription=\"This changes only the sample plugin's saved setting.\"\n strOKButtonText=\"Reset\" strCancelButtonText=\"Keep setting\"\n onOK={() => {void change(false);}} />);\n resetModalOpen.current = true;\n void dialog.ClosedPromise.then(() => {resetModalOpen.current = false;});\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={confirmReset}>Reset example setting</ButtonItem></PanelSectionRLine truncated
+21 -6
View File
@@ -5,26 +5,41 @@ from pathlib import Path
import tempfile
import decky
SETTINGS_SCHEMA = 1
def settings_file():
# Decky provides this per-plugin path through its official Python module.
return Path(decky.DECKY_PLUGIN_SETTINGS_DIR) / 'settings.json'
def read_settings():
try:
stored = json.loads(settings_file().read_text(encoding='utf-8'))
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return {}
if not isinstance(stored, dict):
return {}
# The first sample release wrote {"enabled": bool} without a schema.
# Keep reading it; the next save writes the current format.
schema = stored.get('schema', SETTINGS_SCHEMA)
if type(schema) is not int or schema != SETTINGS_SCHEMA:
raise ValueError('Settings were written by an unsupported sample version')
return stored
class Plugin:
async def get_setting(self, key: str, default=False):
if key != 'enabled':
raise ValueError('Unknown setting')
try:
stored = json.loads(settings_file().read_text(encoding='utf-8'))
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return default
value = stored.get(key) if isinstance(stored, dict) else None
value = read_settings().get(key)
return value if type(value) is bool else default
async def set_setting(self, key: str, value: bool):
if key != 'enabled' or type(value) is not bool:
raise ValueError('Expected an enabled boolean')
# Refuse to erase settings from a newer plugin after a downgrade.
read_settings()
target = settings_file()
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = None
@@ -32,7 +47,7 @@ class Plugin:
with tempfile.NamedTemporaryFile('w', encoding='utf-8', dir=target.parent,
prefix='.settings-', delete=False) as output:
temporary = Path(output.name)
json.dump({'enabled': value}, output)
json.dump({'schema': SETTINGS_SCHEMA, 'enabled': value}, output)
output.write('\n')
temporary.chmod(0o600)
os.replace(temporary, target)
+13 -1
View File
@@ -1,5 +1,5 @@
import {callable, definePlugin, routerHook, toaster} from '@decky/api';
import {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';
import {ButtonItem, ConfirmModal, Navigation, PanelSection, PanelSectionRow, ToggleField, showModal, useParams} from '@decky/ui';
import {useEffect, useRef, useState} from 'react';
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
@@ -10,6 +10,7 @@ function Content() {
const [busy, setBusy] = useState(true);
const [message, setMessage] = useState('Loading setting…');
const pending = useRef(false);
const resetModalOpen = useRef(false);
async function refresh() {
if (pending.current) return;
@@ -49,12 +50,23 @@ function Content() {
}
}
function confirmReset() {
if (pending.current || resetModalOpen.current) return;
const dialog = showModal(<ConfirmModal strTitle="Reset example setting?"
strDescription="This changes only the sample plugin's saved setting."
strOKButtonText="Reset" strCancelButtonText="Keep setting"
onOK={() => {void change(false);}} />);
resetModalOpen.current = true;
void dialog.ClosedPromise.then(() => {resetModalOpen.current = false;});
}
return <PanelSection title="Framey settings sample">
<PanelSectionRow>
<ToggleField label="Example setting" description="Stored by the Decky Python backend"
checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />
</PanelSectionRow>
<PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>
<PanelSectionRow><ButtonItem disabled={busy} onClick={confirmReset}>Reset example setting</ButtonItem></PanelSectionRow>
<PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>
<PanelSectionRow><p role="status">{message}</p></PanelSectionRow>
</PanelSection>;
+23
View File
@@ -8,6 +8,7 @@ const bundle=readFileSync(new URL('../../examples/framey-settings/dist/index.js'
assert.match(bundle,/SP_JSX\.jsx/);
const React={Fragment:Symbol('fragment'),Component:class {},
createElement:(type,props,...children)=>({type,props:props||{},children}),
cloneElement:(element,props)=>({...element,props:{...element.props,...props}}),
useState:value=>[value,()=>{}],useRef:value=>({current:value}),
useEffect(){},isValidElement:value=>!!value?.type};
class Element {
@@ -71,6 +72,28 @@ toggle.props.onChange(false);
await new Promise(resolve=>setImmediate(resolve));
assert.equal(calls.filter(call=>call.method==='set_setting').length,2,
'a completed save must release the sample setting lock');
// The sample reset is a real Framey modal: Cancel cannot write, OK writes the
// sample setting only after explicit confirmation.
const reset=content.children[0][2].children[0];
const setCalls=()=>calls.filter(call=>call.method==='set_setting');
const dialog=()=>roots.get(elements.get('framey-modal')).value[0].children[1];
reset.props.onClick();
reset.props.onClick();
assert.equal(roots.get(elements.get('framey-modal')).value.length,1,
'a rapid second tap must not stack reset dialogs');
assert.equal(dialog().props.strTitle,'Reset example setting?');
let rendered=dialog().type(dialog().props);
rendered.children.find(child=>child?.children?.[0]==='Keep setting').props.onClick();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(setCalls().length,2,'cancel leaves the saved value untouched');
assert.equal(roots.get(elements.get('framey-modal')).value,null);
reset.props.onClick();
rendered=dialog().type(dialog().props);
rendered.children.find(child=>child?.children?.[0]==='Reset').props.onClick();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(setCalls().length,3);
assert.deepEqual(Array.from(setCalls().at(-1).args),['enabled',false]);
assert.equal(roots.get(elements.get('framey-modal')).value,null);
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),true);
assert.equal(window.DFL.useParams().topic,'saved-setting');
assert.equal(window.DFL.Navigation.Navigate('/library/home'),false);
+40 -1
View File
@@ -1,6 +1,7 @@
"""The author example stores settings as a normal Decky user plugin."""
import asyncio
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
@@ -36,7 +37,8 @@ class SamplePlugin(unittest.TestCase):
self.assertTrue(asyncio.run(first.set_setting('enabled', True)))
second = backend(folder)
self.assertTrue(asyncio.run(second.get_setting('enabled', False)))
self.assertTrue((Path(folder) / 'settings.json').is_file())
self.assertEqual(json.loads((Path(folder) / 'settings.json').read_text()),
{'schema': 1, 'enabled': True})
def test_backend_rejects_unknown_keys_and_non_boolean_values(self):
with tempfile.TemporaryDirectory() as folder:
@@ -57,6 +59,43 @@ class SamplePlugin(unittest.TestCase):
self.assertTrue(asyncio.run(plugin.set_setting('enabled', True)))
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
def test_legacy_boolean_survives_restart_and_migrates_on_save(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'settings.json'
path.write_text('{"enabled": true}\n')
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
self.assertEqual(json.loads(path.read_text()), {'enabled': True},
'Reading must not rewrite a user setting')
self.assertTrue(asyncio.run(backend(folder).set_setting('enabled', False)))
self.assertEqual(json.loads(path.read_text()), {'schema': 1, 'enabled': False})
self.assertFalse(asyncio.run(backend(folder).get_setting('enabled', True)))
def test_newer_schema_is_preserved_on_read_and_save(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'settings.json'
original = '{"schema": 2, "enabled": true, "future": "keep"}\n'
path.write_text(original)
plugin = backend(folder)
with self.assertRaisesRegex(ValueError, 'unsupported sample version'):
asyncio.run(plugin.get_setting('enabled', False))
with self.assertRaisesRegex(ValueError, 'unsupported sample version'):
asyncio.run(plugin.set_setting('enabled', False))
self.assertEqual(path.read_text(), original)
self.assertEqual(list(Path(folder).glob('.settings-*')), [])
def test_failed_atomic_replace_keeps_previous_setting(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'settings.json'
plugin = backend(folder)
self.assertTrue(asyncio.run(plugin.set_setting('enabled', True)))
previous = path.read_bytes()
with patch('os.replace', side_effect=OSError('storage unavailable')):
with self.assertRaisesRegex(OSError, 'storage unavailable'):
asyncio.run(plugin.set_setting('enabled', False))
self.assertEqual(path.read_bytes(), previous)
self.assertEqual(list(Path(folder).glob('.settings-*')), [])
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
if __name__ == '__main__':
unittest.main()