mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 14:01:36 +01:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5932b6a8a4 | ||
|
|
faf7bc6224 | ||
|
|
289a971c90 | ||
|
|
f2f6085887 | ||
|
|
d1541099b8 | ||
|
|
0440959834 | ||
|
|
482d031a18 | ||
|
|
20a5ced63d | ||
|
|
02fe664583 | ||
|
|
e2ffcd34f3 |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.31",
|
||||
"version": "0.4.33",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.31",
|
||||
"version": "0.4.33",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.31",
|
||||
"version": "0.4.33",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -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.33**, 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.31**, 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 | 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 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. 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.33 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), [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. Fake Frame tests cover setting read/write through update and reinstall; page tests cover the official JSX runtime, stale job recovery and an author sample bundle. The sample is not published in the store. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.33: [#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), [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.
|
||||
@@ -42,6 +42,27 @@ files or behavior. The official store's digest checks the archive in transit;
|
||||
it does not certify plugin code. Plugins run with the headset user's access.
|
||||
For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-author.md).
|
||||
|
||||
### 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
|
||||
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. |
|
||||
| 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
|
||||
remain the only device evidence for these versions. A new store version needs a
|
||||
new check because its manifest and binaries can change.
|
||||
|
||||
The new store transaction was also exercised on the same real Frame:
|
||||
**managed loader install → ProtonDB Badges 1.3.4 store install → status →
|
||||
store update → store remove → managed loader remove**. Status reported the
|
||||
@@ -165,6 +186,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.
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-25",
|
||||
"source": "https://plugins.deckbrew.xyz/plugins",
|
||||
"method": "Downloaded exact current official store versions; local SHA-256 matched each store hash; inspected ZIP plugin.json and dist/index.js without running plugin code.",
|
||||
"limit": "Static inspection on PC. Prior backend load results are documented in docs/decky-frame.md; VR visibility and controller input remain unverified.",
|
||||
"plugins": [
|
||||
{
|
||||
"storeId": 7,
|
||||
"name": "CSS Loader",
|
||||
"storeVersion": "2.1.3",
|
||||
"sha256": "99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 14,
|
||||
"name": "ProtonDB Badges",
|
||||
"storeVersion": "1.3.4",
|
||||
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
|
||||
"manifestApiVersion": 2,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 16,
|
||||
"name": "Audio Loader",
|
||||
"storeVersion": "1.6.1",
|
||||
"sha256": "fd5a090c2e2cd4da6723d7bd08937ab31b9f8d4663510431713da531343e303f",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [
|
||||
"_root"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 18,
|
||||
"name": "Bluetooth",
|
||||
"storeVersion": "3.1.1",
|
||||
"sha256": "f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 21,
|
||||
"name": "PowerTools",
|
||||
"storeVersion": "2.0.3",
|
||||
"sha256": "47614f53b8c538c4caa15f89a01e4ab106fa328e89f78545bacb3166d104d964",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [
|
||||
"root",
|
||||
"global-dfl"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 23,
|
||||
"name": "Animation Changer",
|
||||
"storeVersion": "1.3.2",
|
||||
"sha256": "f2c62b90ca60d8a80b6d0f75d8027552b1509c7a05842c6f4a24a9072846d133",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 26,
|
||||
"name": "HLTB for Deck",
|
||||
"storeVersion": "2.0.12",
|
||||
"sha256": "bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 36,
|
||||
"name": "SteamGridDB",
|
||||
"storeVersion": "1.8.1",
|
||||
"sha256": "e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [
|
||||
"debug"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 50,
|
||||
"name": "Storage Cleaner",
|
||||
"storeVersion": "1.4.0",
|
||||
"sha256": "1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 68,
|
||||
"name": "Wine Cellar",
|
||||
"storeVersion": "0.2.1",
|
||||
"sha256": "7e077b933c234414d172b5c6d1c804b026bf39fa4e18e6663495a282637031e8",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [
|
||||
"debug"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 82,
|
||||
"name": "MagicPods",
|
||||
"storeVersion": "2.0.21",
|
||||
"sha256": "abe2eef5788cb3ff6e73fcb9783e30a54b127e28c761a9bf99e64ab5fc4be9ab",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -32,6 +32,9 @@ Frame Control now has an experimental [Framey panel host](framey-loader.md)
|
||||
for API v2 Decky frontend bundles. It renders common settings controls in the
|
||||
separate SteamVR dashboard panel, but Steam library and store page patches
|
||||
remain unavailable. Wearer-visible use is not yet verified.
|
||||
The [settings sample](../examples/framey-settings/README.md) shows an official
|
||||
Decky JSX build, backend persistence, a toggle and a local toast. Framey offers
|
||||
only official-store-owned plugins in its VR panel.
|
||||
|
||||
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
|
||||
@@ -47,9 +50,10 @@ 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 |
|
||||
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; 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 |
|
||||
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. |
|
||||
| `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 |
|
||||
|
||||
+15
-2
@@ -54,22 +54,35 @@ has no supported injection target in the Frame Control panel.
|
||||
The [browser and fake Frame evidence](evidence/framey-browser-2026-10-25.md)
|
||||
records exactly what was exercised.
|
||||
|
||||
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. The store still needs the managed Decky user service running
|
||||
before its Install or Update buttons are enabled.
|
||||
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. Refresh the installed list before starting another
|
||||
operation after an error.
|
||||
operation after an error. 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.
|
||||
|
||||
## For plugin authors
|
||||
|
||||
Start with the [Framey settings sample](../examples/framey-settings/README.md)
|
||||
for a buildable Decky API v2 panel and Python settings backend.
|
||||
|
||||
Publish a normal [Decky store](https://plugins.deckbrew.xyz/) 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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,37 @@
|
||||
# Framey settings sample
|
||||
|
||||
This is a small Decky API v2 plugin for authors. Its panel uses the official
|
||||
`@decky/api`, `@decky/ui`, and `@decky/rollup` packages. A toggle calls a Python
|
||||
backend method, stores one boolean under `decky.decky_SETTINGS_DIR`,
|
||||
and reads it back when the panel opens. The backend rejects other keys and
|
||||
values. The frontend also shows a local toast after a successful save.
|
||||
|
||||
## Build
|
||||
|
||||
From this directory, run:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
pnpm exec tsc --noEmit
|
||||
```
|
||||
|
||||
The committed `dist/index.js` is the built frontend. For a Decky release ZIP,
|
||||
put `plugin.json`, `main.py`, and `dist/index.js` inside one top-level plugin
|
||||
folder. Publish it through the official Decky store if you want Frame Control's
|
||||
managed installer to offer it. Framey deliberately opens only plugins that the
|
||||
official store installed and marked as owned; copying this example into the
|
||||
plugin directory does not enable its VR panel.
|
||||
|
||||
## Test and adapt
|
||||
|
||||
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. 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.
|
||||
|
||||
The plugin needs no root access or system changes. Its settings remain after
|
||||
the managed plugin folder is removed, following Decky's settings behavior.
|
||||
For a production plugin, add migration and recovery handling for damaged
|
||||
settings files, and test every UI path in a worn headset.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
const manifest = {"name":"Framey Settings Sample"};
|
||||
const API_VERSION = 2;
|
||||
const internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;
|
||||
if (!internalAPIConnection) {
|
||||
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.');
|
||||
}
|
||||
let api;
|
||||
try {
|
||||
api = internalAPIConnection.connect(API_VERSION, manifest.name);
|
||||
}
|
||||
catch {
|
||||
api = internalAPIConnection.connect(1, manifest.name);
|
||||
console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);
|
||||
}
|
||||
if (api._version != API_VERSION) {
|
||||
console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);
|
||||
}
|
||||
const callable = api.callable;
|
||||
const toaster = api.toaster;
|
||||
const definePlugin = (fn) => {
|
||||
return (...args) => {
|
||||
return fn(...args);
|
||||
};
|
||||
};
|
||||
|
||||
const getSetting = callable('get_setting');
|
||||
const setSetting = callable('set_setting');
|
||||
function Content() {
|
||||
const [enabled, setEnabled] = SP_REACT.useState(false);
|
||||
const [busy, setBusy] = SP_REACT.useState(true);
|
||||
const [message, setMessage] = SP_REACT.useState('Loading setting…');
|
||||
async function refresh() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const value = await getSetting('enabled', false);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
}
|
||||
catch (error) {
|
||||
setMessage(`Could not read setting: ${String(error)}`);
|
||||
}
|
||||
finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
SP_REACT.useEffect(() => { void refresh(); }, []);
|
||||
async function change(value) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await setSetting('enabled', value);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
toaster.toast({ title: 'Framey sample', body: 'Setting saved' });
|
||||
}
|
||||
catch (error) {
|
||||
setMessage(`Could not save setting: ${String(error)}`);
|
||||
}
|
||||
finally {
|
||||
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("p", { role: "status", children: message }) })] });
|
||||
}
|
||||
var index = definePlugin(() => ({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
|
||||
content: SP_JSX.jsx(Content, {}),
|
||||
}));
|
||||
|
||||
export { index as default };
|
||||
//# sourceMappingURL=index.js.map
|
||||
+1
@@ -0,0 +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, toaster} from '@decky/api';\nimport {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';\nimport {useEffect, 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\n async function refresh() {\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 setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\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', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\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><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => ({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n}));\n"],"names":[],"mappings":"AAEA,MAAA,QAAA,GAAiB,CAAA,MAAA,CAAA,wBAAA,CAAS;AAC1B,MAAA,WAAA,GAAA,CAAA;AAIA,MAAA,qBAAA,GAAA,MAAA,CAAA,2EAAA;AACA,IAAA,CAAA,qBAAA,EAAA;AACA,IAAA,MAAA,IAAA,KAAA,CAAA,+HAAA,CAAA;AACA;AACA,IAAA,GAAA;AACA,IAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,WAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA;AACA,MAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,CAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,4EAAA,CAAA,CAAA;AACA;AACA,IAAA,GAAA,CAAA,QAAA,IAAA,WAAA,EAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,8CAAA,EAAA,GAAA,CAAA,QAAA,CAAA,6BAAA,CAAA,CAAA;AACA;AAEA,MAAA,QAAA,GAAA,GAAA,CAAA,QAAA;AAIA,MAAA,OAAA,GAAA,GAAA,CAAA,OAAA;AAQA,MAAA,YAAA,GAAA,CAAA,EAAA,KAAA;AACA,IAAA,OAAA,CAAA,GAAA,IAAA,KAAA;AACA,QAAA,OAAA,EAAA,CAAA,GAAA,IAAA,CAAA;AACA,IAAA,CAAA;AACA,CAAA;;ACnCA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AACA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AAEA,SAAA,OAAA,Line truncated
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Small, unprivileged Decky backend for the Framey author example."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import decky
|
||||
|
||||
|
||||
def settings_file():
|
||||
# Decky provides this per-plugin path through its official Python module.
|
||||
return Path(decky.decky_SETTINGS_DIR) / 'settings.json'
|
||||
|
||||
|
||||
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())
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
return stored.get(key, 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')
|
||||
target = settings_file()
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temporary = None
|
||||
try:
|
||||
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)
|
||||
output.write('\n')
|
||||
temporary.chmod(0o600)
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return True
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "framey-settings-sample",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rollup -c"
|
||||
},
|
||||
"dependencies": {
|
||||
"@decky/api": "^1.1.3",
|
||||
"@decky/ui": "^4.11.0",
|
||||
"react": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@decky/rollup": "^1.0.2",
|
||||
"@types/react": "^19.1.1",
|
||||
"rollup": "^4.53.3",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Framey Settings Sample",
|
||||
"author": "Frame Control contributors",
|
||||
"flags": [],
|
||||
"api_version": 2
|
||||
}
|
||||
Generated
+1239
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
import deckyPlugin from '@decky/rollup';
|
||||
|
||||
export default deckyPlugin({});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {callable, definePlugin, toaster} from '@decky/api';
|
||||
import {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
|
||||
const setSetting = callable<[key: string, value: boolean], boolean>('set_setting');
|
||||
|
||||
function Content() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [message, setMessage] = useState('Loading setting…');
|
||||
|
||||
async function refresh() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const value = await getSetting('enabled', false);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
} catch (error) {
|
||||
setMessage(`Could not read setting: ${String(error)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {void refresh();}, []);
|
||||
|
||||
async function change(value: boolean) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await setSetting('enabled', value);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
toaster.toast({title: 'Framey sample', body: 'Setting saved'});
|
||||
} catch (error) {
|
||||
setMessage(`Could not save setting: ${String(error)}`);
|
||||
} finally {
|
||||
setBusy(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><p role="status">{message}</p></PanelSectionRow>
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
export default definePlugin(() => ({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: <span aria-hidden="true">⚙</span>,
|
||||
content: <Content />,
|
||||
}));
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "ESNext",
|
||||
"target": "ES2020",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -109,13 +109,25 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'dark')
|
||||
self.assertTrue(ok('POST', '/api/framey/method',
|
||||
{'id':123,'method':'set_setting','args':['theme','light']})['result'])
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
StoreFixture.release = 1
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'update','id':123,'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':123,'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'], [])
|
||||
self.assertEqual(ssh('test -e ~/.local/share/frame-control/decky/plugins/sample-panel && echo yes || echo no').strip(), 'no')
|
||||
status, _, _ = api('POST', '/api/framey/method',
|
||||
{'id':123,'method':'get_setting','args':['theme','dark']})
|
||||
self.assertEqual(status, 400) # No method calls while the plugin is removed.
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'install','id':123,'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
@@ -30,8 +30,14 @@ function build(steam) {
|
||||
const deckyBackend = {async call(route, plugin, method, ...args) {
|
||||
if (route !== 'loader/call_plugin_method' || plugin !== 'Sample Panel')
|
||||
throw Error('unexpected Decky plugin route');
|
||||
if (method === 'get_setting') return args[1];
|
||||
if (method === 'set_setting') return true;
|
||||
const settings = (steam.decky_settings ||= {});
|
||||
const owned = (settings[plugin] ||= {});
|
||||
if (method === 'get_setting')
|
||||
return Object.hasOwn(owned, args[0]) ? owned[args[0]] : args[1];
|
||||
if (method === 'set_setting') {
|
||||
owned[args[0]] = args[1];
|
||||
return true;
|
||||
}
|
||||
throw Error('method unavailable in fake Decky backend');
|
||||
}};
|
||||
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
|
||||
|
||||
@@ -19,6 +19,15 @@ const context=vm.createContext({window,document:{getElementById:id=>{
|
||||
navigator:{language:'en'}, parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
|
||||
setTimeout:()=>1,clearTimeout(){}});
|
||||
vm.runInContext(testSource,context);
|
||||
// Official @decky/rollup bundles use the automatic JSX runtime, including keyed lists.
|
||||
const jsx=window.SP_JSX.jsx('p',{id:'message',children:'Saved'},'message-key');
|
||||
assert.equal(jsx.type,'p');
|
||||
assert.equal(jsx.props.id,'message');
|
||||
assert.equal(jsx.props.key,'message-key');
|
||||
assert.deepEqual(jsx.children,['Saved']);
|
||||
const fragments=window.SP_JSX.jsxs(window.SP_JSX.Fragment,{children:['one','two']});
|
||||
assert.equal(fragments.type,React.Fragment);
|
||||
assert.deepEqual(fragments.children,[['one','two']]);
|
||||
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'},
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const host=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8')
|
||||
.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
|
||||
const bundle=readFileSync(new URL('../../examples/framey-settings/dist/index.js',import.meta.url),'utf8');
|
||||
assert.match(bundle,/SP_JSX\.jsx/);
|
||||
const React={Fragment:Symbol('fragment'),Component:class {},
|
||||
createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
useState:value=>[value,()=>{}],useEffect(){},isValidElement:value=>!!value?.type};
|
||||
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
|
||||
const context=vm.createContext({window,document:{getElementById:()=>({})},
|
||||
navigator:{language:'en'},parent:{postMessage(){}},addEventListener(){},
|
||||
setTimeout:()=>1,clearTimeout(){}});
|
||||
vm.runInContext(host,context);
|
||||
Object.assign(context,{SP_REACT:window.SP_REACT,SP_JSX:window.SP_JSX,DFL:window.DFL});
|
||||
vm.runInContext(bundle.replace(/export \{ index as default \};/, 'window.sample = index;'),context);
|
||||
const plugin=window.sample();
|
||||
assert.equal(plugin.name,'Framey Settings Sample');
|
||||
assert.equal(typeof plugin.content.type,'function');
|
||||
const content=plugin.content.type();
|
||||
assert.equal(content.type,window.DFL.PanelSection);
|
||||
assert.equal(content.props.title,'Framey settings sample');
|
||||
assert.equal(content.children[0][0].type,window.DFL.PanelSectionRow);
|
||||
@@ -60,6 +60,16 @@ assert.equal(el('deckyJobStatus').textContent,'Installed Plugin 2');
|
||||
assert.equal(el('deckyJobProgress').value,100);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
// A stopped Decky service must still offer recovery by removing its owned plugin.
|
||||
ctx.mockApi=async path => path==='/api/decky' ?
|
||||
{managed:true,running:false,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]} :
|
||||
Promise.reject(Error(`unexpected ${path}`));
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
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'};
|
||||
if(path.startsWith('/api/job'))return {done:true,error:'archive digest mismatch'};
|
||||
@@ -70,3 +80,17 @@ vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2"})',ctx);
|
||||
assert.match(el('deckyJobStatus').textContent,/Error: archive digest mismatch/);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
|
||||
// A saved job does not survive a server restart. Clear it instead of offering
|
||||
// Check result forever for an ID that the new server cannot know.
|
||||
session.set('frameyPluginJob',JSON.stringify({job:'old-job',action:'install',name:'Plugin 2'}));
|
||||
ctx.mockApi=async path => {
|
||||
if(path.startsWith('/api/job'))throw Error('no such job (the app may have restarted)');
|
||||
if(path==='/api/decky')return {running:true,plugins:[]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('checkDeckyJob()',ctx);
|
||||
assert.match(el('deckyJobStatus').textContent,/app restarted and lost this job result/);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
@@ -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,8 +112,66 @@ class StoreTransaction(unittest.TestCase):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(external.exists())
|
||||
|
||||
def test_remove_while_service_is_stopped_does_not_restart_it(self):
|
||||
data, _ = archive()
|
||||
self.assertEqual(self.apply('install', data).returncode, 0)
|
||||
fake = Path(self.tmp.name) / 'systemctl'
|
||||
fake.write_text('#!/bin/sh\nif [ "$2" = is-active ]; then exit 1; fi\n'
|
||||
'echo "$*" >> "$HOME/service-calls"\nexit 0\n')
|
||||
result = self.apply('remove')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(self.folder.exists())
|
||||
self.assertEqual((Path(self.tmp.name) / 'service-calls').read_text().splitlines(),
|
||||
['--user stop frame-control-decky.service'])
|
||||
|
||||
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_stopped_managed_service_allows_removing_owned_plugin(self):
|
||||
state = {'managed': True, 'running': False, 'plugins':
|
||||
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
|
||||
calls = []
|
||||
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
||||
patch.object(frame_decky_store, 'catalog') as network:
|
||||
result = frame_decky_store.change(lambda command, **kwargs: calls.append(command) or '{}',
|
||||
'remove', 42)
|
||||
self.assertEqual(result, {})
|
||||
self.assertEqual(len(calls), 1)
|
||||
network.assert_not_called()
|
||||
|
||||
def test_renamed_store_plugin_cannot_be_installed_twice(self):
|
||||
state = {'managed': True, 'running': True, 'plugins':
|
||||
[{'storeId': 42, 'folder': 'OldFolder', 'name': 'Old Name', 'version': '1.0'}]}
|
||||
catalog = [{'id': 42, 'name': 'New Name', 'root': False, 'sha256': 'a' * 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) as network, \
|
||||
patch.object(frame_decky_store.urllib.request, 'urlopen', side_effect=AssertionError('No download expected')):
|
||||
with self.assertRaisesRegex(ValueError, 'already installed'):
|
||||
frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'),
|
||||
'install', 42)
|
||||
network.assert_not_called()
|
||||
|
||||
def test_owned_plugin_can_be_removed_without_store_network(self):
|
||||
state = {'managed': True, 'running': True, 'plugins':
|
||||
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""The author example stores settings as a normal Decky user plugin."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
EXAMPLE = Path(__file__).resolve().parents[1] / 'examples/framey-settings/main.py'
|
||||
|
||||
|
||||
def backend(settings_dir):
|
||||
decky = types.ModuleType('decky')
|
||||
decky.decky_SETTINGS_DIR = settings_dir
|
||||
spec = importlib.util.spec_from_file_location('framey_sample', EXAMPLE)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {'decky': decky}):
|
||||
spec.loader.exec_module(module)
|
||||
return module.Plugin()
|
||||
|
||||
|
||||
class SamplePlugin(unittest.TestCase):
|
||||
def test_frontend_bundle_loads_in_framey_host(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent / 'page/framey_sample.mjs')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_setting_survives_backend_instance_restart(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
first = backend(folder)
|
||||
self.assertFalse(asyncio.run(first.get_setting('enabled', False)))
|
||||
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())
|
||||
|
||||
def test_backend_rejects_unknown_keys_and_non_boolean_values(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
plugin = backend(folder)
|
||||
with self.assertRaises(ValueError): asyncio.run(plugin.get_setting('other'))
|
||||
with self.assertRaises(ValueError): asyncio.run(plugin.set_setting('enabled', 'yes'))
|
||||
self.assertFalse((Path(folder) / 'settings.json').exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+10
-1
@@ -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()
|
||||
'''
|
||||
|
||||
|
||||
|
||||
+23
-9
@@ -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:
|
||||
@@ -143,7 +151,8 @@ try:
|
||||
unpacked=stage/folder
|
||||
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
|
||||
(unpacked/marker).write_text(json.dumps({'storeId':int(plugin_id),'sha256':digest,'version':version})+'\n')
|
||||
if running: service('stop','frame-control-decky.service')
|
||||
# Stop even a failed unit before removal to cancel any pending auto-restart.
|
||||
if running or action=='remove': service('stop','frame-control-decky.service')
|
||||
try:
|
||||
if action!='install': target.rename(backup)
|
||||
if action!='remove': (stage/folder).rename(target)
|
||||
@@ -159,6 +168,7 @@ try:
|
||||
finally:
|
||||
archive.unlink(missing_ok=True)
|
||||
if stage.exists(): shutil.rmtree(stage)
|
||||
if transaction_lock is not None: transaction_lock.close()
|
||||
'''
|
||||
|
||||
|
||||
@@ -167,9 +177,13 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None):
|
||||
raise ValueError('Choose a valid store plugin action')
|
||||
report('Checking installed plugins', 5)
|
||||
state = frame_decky.status(ssh)
|
||||
if not state['managed'] or not state['running']:
|
||||
raise ValueError('Start Frame Control managed Decky before changing plugins')
|
||||
if not state['managed']:
|
||||
raise ValueError('Install Frame Control managed Decky before changing plugins')
|
||||
if action != 'remove' and not state['running']:
|
||||
raise ValueError('Start Frame Control managed Decky before installing or updating plugins')
|
||||
installed = [p for p in state['plugins'] if p.get('storeId') == plugin_id]
|
||||
if action == 'install' and installed:
|
||||
raise ValueError('This store plugin is already installed; use Update or Remove')
|
||||
if action == 'remove':
|
||||
if len(installed) != 1:
|
||||
raise ValueError('Only an installed Frame Control store plugin can be removed')
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
'use strict';
|
||||
const R = window.SP_REACT = window.React;
|
||||
window.SP_REACTDOM = window.ReactDOM;
|
||||
const jsx = (type, props, key) => {
|
||||
const {children, ...elementProps} = props || {};
|
||||
if (key !== undefined) elementProps.key = key;
|
||||
return Object.prototype.hasOwnProperty.call(props || {}, 'children')
|
||||
? R.createElement(type, elementProps, children)
|
||||
: R.createElement(type, elementProps);
|
||||
};
|
||||
window.SP_JSX = {jsx, jsxs:jsx, Fragment:R.Fragment};
|
||||
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en']};
|
||||
const root = document.getElementById('framey-root');
|
||||
const error = document.getElementById('framey-error');
|
||||
|
||||
+13
-3
@@ -105,7 +105,9 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
|
||||
}catch(error){list.textContent=error.message;}}
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=0, activeFrameyId=null, deckyBusy=false;
|
||||
async function refreshDecky(){try{deckyState=await api('/api/decky');
|
||||
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':'Decky is not running. Install it from Frame Control on your computer.';
|
||||
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':
|
||||
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
|
||||
'Decky is not running. Install it from Frame Control on your computer.';
|
||||
const list=byId('deckyInstalled');list.replaceChildren();
|
||||
for(const plugin of deckyState.plugins||[]){const row=document.createElement('div');row.className='block';
|
||||
const name=document.createElement('strong');name.textContent=`${plugin.name} ${plugin.version||''}`;row.append(name);
|
||||
@@ -148,7 +150,15 @@ function deckyJobView(title,message,percent,failed=false){
|
||||
}
|
||||
async function followDeckyJob(job,action,name){
|
||||
for(let n=0;n<180;n++){
|
||||
const progress=await api(`/api/job?id=${encodeURIComponent(job)}`);
|
||||
let progress;
|
||||
try{progress=await api(`/api/job?id=${encodeURIComponent(job)}`);}
|
||||
catch(error){
|
||||
if(/no such job/i.test(error.message)){
|
||||
sessionStorage.removeItem('frameyPluginJob');
|
||||
throw Error('The app restarted and lost this job result. Refresh installed plugins to see what changed.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if(progress.done){if(progress.error){sessionStorage.removeItem('frameyPluginJob');throw Error(progress.error);}
|
||||
deckyJobView(`${action} ${name}`,progress.message||`${action} complete`,100);return;}
|
||||
deckyJobView(`${action} ${name}`,progress.stage||'Waiting for Frame…',progress.percent);
|
||||
@@ -168,7 +178,7 @@ async function deckyChange(action,plugin){if(deckyBusy||!confirm(`${action} ${pl
|
||||
finally{deckyBusy=false;await refreshDecky();}
|
||||
}
|
||||
async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){saved=null;}
|
||||
if(!saved||typeof saved.job!=='string')return;
|
||||
if(!saved||typeof saved.job!=='string'){sessionStorage.removeItem('frameyPluginJob');return;}
|
||||
deckyBusy=true;byId('deckyJobCheck').hidden=true;
|
||||
try{await followDeckyJob(saved.job,saved.action,saved.name);sessionStorage.removeItem('frameyPluginJob');}
|
||||
catch(error){deckyJobView(`${saved.action} ${saved.name}`,`Error: ${error.message}`,null,true);}
|
||||
|
||||
+3
-3
@@ -2202,7 +2202,7 @@ async function loadDecky() {
|
||||
const row=document.createElement('div');row.className='hint';
|
||||
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version||''}`;
|
||||
const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove plugin';
|
||||
remove.disabled=!d.running||!$('deckyAck').checked;
|
||||
remove.disabled=!d.managed||!$('deckyAck').checked;
|
||||
remove.onclick=()=>changeDeckyPlugin('remove',{id:plugin.storeId,name:plugin.name});
|
||||
row.append(label,remove);managedList.append(row);}
|
||||
deckyButtons();
|
||||
@@ -2236,7 +2236,7 @@ function showDeckyStore() {
|
||||
button.onclick=()=>changeDeckyPlugin(entry?'update':'install',plugin);
|
||||
row.append(button);
|
||||
if(entry){const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove';
|
||||
remove.disabled=!deckyInfo?.managed || !deckyInfo?.running || !$('deckyAck').checked;
|
||||
remove.disabled=!deckyInfo?.managed || !$('deckyAck').checked;
|
||||
remove.onclick=()=>changeDeckyPlugin('remove',plugin);row.append(remove);}
|
||||
}
|
||||
list.append(row);
|
||||
@@ -2255,7 +2255,7 @@ function deckyButtons() {
|
||||
$('deckyRemove').disabled = !ack || !deckyInfo?.managed;
|
||||
}
|
||||
$('deckyAck').onchange = () => { deckyButtons(); showDeckyStore();
|
||||
for(const button of $('deckyManagedList').querySelectorAll('button'))button.disabled=!$('deckyAck').checked||!deckyInfo?.running; };
|
||||
for(const button of $('deckyManagedList').querySelectorAll('button'))button.disabled=!$('deckyAck').checked||!deckyInfo?.managed; };
|
||||
$('deckyRefresh').onclick = loadDecky;
|
||||
$('deckyStoreRefresh').onclick = loadDeckyStore;
|
||||
$('deckyStoreSearch').oninput = showDeckyStore;
|
||||
|
||||
Reference in new issue
Block a user